From 4ae2dabfc6fdadcbd202ed194e03560ae107ef0e Mon Sep 17 00:00:00 2001 From: ldm0 Date: Thu, 22 Oct 2026 20:03:10 +0800 Subject: [PATCH] fix(fonts): publish CSS declaration order without JS projection --- .../moli_cdp_smoke/groups/font_face.py | 3 + .../groups/font_face_css_order.py | 95 +++++++++++++ moli-layout/src/text.rs | 66 +++++++++ .../src/native_bridge/context_host/layout.rs | 5 +- .../context_host/layout_state.rs | 11 +- .../browser_api/fonts/selection_order.rs | 125 ++++++++++++++++++ moli-renderer-v8/src/script_vm/web_fonts.rs | 11 +- .../src/style_engine/stylesheet_resources.rs | 34 +++-- 8 files changed, 333 insertions(+), 17 deletions(-) create mode 100644 moli-cdp-smoke/moli_cdp_smoke/groups/font_face_css_order.py diff --git a/moli-cdp-smoke/moli_cdp_smoke/groups/font_face.py b/moli-cdp-smoke/moli_cdp_smoke/groups/font_face.py index 706a83be87..c0a31da132 100644 --- a/moli-cdp-smoke/moli_cdp_smoke/groups/font_face.py +++ b/moli-cdp-smoke/moli_cdp_smoke/groups/font_face.py @@ -5,6 +5,7 @@ from typing import Any from ..assertions import assert_equal, record_contract from ..config import REPO_ROOT +from .font_face_css_order import run_css_font_order_contract, run_projected_local_font_order_contract from .font_face_matching import run_font_matching_contract @@ -13,6 +14,8 @@ async def run_font_face_payload_group( fixture: str, results: list[dict[str, Any]], ) -> None: + await run_css_font_order_contract(browser, results) + await run_projected_local_font_order_contract(browser, results) context = await browser.new_context() try: page = await context.new_page() diff --git a/moli-cdp-smoke/moli_cdp_smoke/groups/font_face_css_order.py b/moli-cdp-smoke/moli_cdp_smoke/groups/font_face_css_order.py new file mode 100644 index 0000000000..a3b849e8ee --- /dev/null +++ b/moli-cdp-smoke/moli_cdp_smoke/groups/font_face_css_order.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import base64 +from typing import Any + +from ..assertions import assert_equal, record_contract +from .font_face_matching import font_matching_payload + + +async def run_css_font_order_contract(browser: Any, results: list[dict[str, Any]]) -> None: + context = await browser.new_context() + try: + fonts = font_matching_payload() + observed = {} + for names in [("narrow", "wide"), ("wide", "narrow")]: + rules = "".join( + "@font-face{font-family:PureCssOrder;src:url(data:font/ttf;base64," + + fonts[name + "Data"] + ")}" + for name in names + ) + html = ( + "" + "" + 'AAAA' + ) + page = await context.new_page() + try: + url = "data:text/html;base64," + base64.b64encode(html.encode()).decode("ascii") + await page.goto(url, wait_until="load", timeout=10_000) + observed["-".join(names)] = await page.evaluate( + "({width: document.getElementById('probe').getBoundingClientRect().width, fontsReads})" + ) + finally: + await page.close() + assert_equal(observed, { + "narrow-wide": {"width": 160, "fontsReads": 0}, + "wide-narrow": {"width": 80, "fontsReads": 0}, + }, "CSS font declaration order without observing document.fonts") + record_contract( + results, + "css_font_order_without_font_face_set_projection", + contract="Pure CSS font selection follows reverse declaration order without accessing document.fonts.", + source="Chromium 147 and pinned Chromium 145.0.7632.6", + commands=["Page.navigate", "Runtime.evaluate"], + observed=observed, + ) + finally: + await context.close() + + +async def run_projected_local_font_order_contract(browser: Any, results: list[dict[str, Any]]) -> None: + context = await browser.new_context() + try: + page = await context.new_page() + await page.goto("about:blank", wait_until="load", timeout=10_000) + observed = await page.evaluate(r"""async ({narrowData, wideData}) => { + let localName; + for (const name of ['DejaVu Sans Mono', 'Liberation Mono', 'Courier New', 'Menlo', 'Consolas']) { + const face = new FontFace('AvailableLocalOrderFont', `local(${JSON.stringify(name)})`); + try { await face.load(); localName = name; break; } catch {} + } + if (!localName) throw new Error('No supported local monospace font is installed'); + const style = document.createElement('style'); + style.textContent = `@font-face{font-family:ProjectedLocalOrder;src:local(${JSON.stringify(localName)})} + @font-face{font-family:ProjectedLocalOrder;src:url(data:font/ttf;base64,${wideData})}`; + document.head.append(style); + const faces = [...document.fonts].filter(face => face.family === 'ProjectedLocalOrder'); + await Promise.all(faces.map(face => face.load())); + globalThis.localOrderProbe = document.createElement('span'); + localOrderProbe.textContent = 'AAAA'; + localOrderProbe.style.cssText = 'display:inline-block;font:40px ProjectedLocalOrder'; + document.body.append(localOrderProbe); + const before = localOrderProbe.getBoundingClientRect().width; + const other = document.createElement('style'); + other.textContent = `@font-face{font-family:UnrelatedLocalOrder;src:url(data:font/ttf;base64,${narrowData})}`; + document.head.append(other); + return {before, states: faces.map(face => face.status)}; + }""", font_matching_payload()) + # Observe after the stylesheet mutation's native resource reconciliation, + # without rereading document.fonts to republish the projected order. + observed["after"] = await page.evaluate("localOrderProbe.getBoundingClientRect().width") + assert_equal(observed, {"before": 160, "states": ["loaded", "loaded"], "after": 160}, + "native font fetch updates preserve projected local-only CSS face order") + record_contract( + results, + "projected_local_css_font_order_survives_native_reconciliation", + contract="An exposed FontFaceSet retains complete CSS order, including local-only faces, when native font requests change.", + source="Chromium 147 and pinned Chromium 145.0.7632.6", + commands=["Runtime.evaluate"], + observed=observed, + ) + finally: + await context.close() diff --git a/moli-layout/src/text.rs b/moli-layout/src/text.rs index e80c0dce27..46c82b3589 100644 --- a/moli-layout/src/text.rs +++ b/moli-layout/src/text.rs @@ -709,6 +709,7 @@ pub struct DocumentLayoutServices { // Resource slots retain identity across removals and shared FontFaces. // Selection priority belongs to this Document's current FontFaceSet. web_font_order: BTreeMap, + has_font_face_set_order: bool, pub(crate) text_layout_passes: u64, } @@ -727,6 +728,7 @@ impl DocumentLayoutServices { system_font_policy: SystemFontPolicy::Enabled, web_fonts: BTreeMap::new(), web_font_order: BTreeMap::new(), + has_font_face_set_order: false, text_layout_passes: 0, } } @@ -739,6 +741,7 @@ impl DocumentLayoutServices { system_font_policy, web_fonts: BTreeMap::new(), web_font_order: BTreeMap::new(), + has_font_face_set_order: false, text_layout_passes: 0, } } @@ -765,6 +768,7 @@ impl DocumentLayoutServices { /// this owner. Unloaded faces keep their place for a later completion. /// Returns true only when registered candidates actually change priority. pub fn set_web_font_order(&mut self, stylesheet: &[String], manual: &[String]) -> bool { + self.has_font_face_set_order = true; let order = stylesheet .iter() .enumerate() @@ -776,6 +780,25 @@ impl DocumentLayoutServices { .map(|(index, slot)| (slot.clone(), (true, index))), ) .collect::>(); + self.update_web_font_order(order) + } + + /// Publishes native stylesheet order until a JS FontFaceSet is exposed. + /// Its complete order also includes local-only CSS faces, which have no + /// fetch-manifest entry. Later native fetch updates must preserve it. + pub fn set_stylesheet_web_font_order(&mut self, stylesheet: &[String]) -> bool { + if self.has_font_face_set_order { + return false; + } + let order = stylesheet + .iter() + .enumerate() + .map(|(index, slot)| (slot.clone(), (false, index))) + .collect(); + self.update_web_font_order(order) + } + + fn update_web_font_order(&mut self, order: BTreeMap) -> bool { if self.web_font_order == order { return false; } @@ -895,6 +918,7 @@ impl DocumentLayoutServices { system_font_policy: self.system_font_policy, web_fonts, web_font_order: self.web_font_order.clone(), + has_font_face_set_order: self.has_font_face_set_order, text_layout_passes: 0, }; let result = consume(&mut temporary); @@ -1377,6 +1401,48 @@ mod tests { .to_vec() } + #[test] + fn stylesheet_fetch_order_preserves_projected_local_font_selection() { + let mut services = + DocumentLayoutServices::with_system_font_policy(SystemFontPolicy::Disabled); + let narrow = include_bytes!("../tests/fixtures/moli-ahem-narrow.ttf"); + let wide = include_bytes!("../tests/fixtures/moli-ahem-wide.ttf"); + // Local-only CSS wrappers have a script resource slot rather than a + // stylesheet fetch entry. The complete FontFaceSet gives their order. + services + .register_web_font(WebFontRegistration::new( + "local-css", + WebFontFace::new("CSS Local Order"), + narrow.to_vec(), + )) + .unwrap(); + services + .register_web_font( + WebFontRegistration::new( + "url-css", + WebFontFace::new("CSS Local Order"), + wide.to_vec(), + ) + .with_stylesheet_scope(), + ) + .unwrap(); + services.set_web_font_order(&["local-css".into(), "url-css".into()], &[]); + let selected = |services: &mut DocumentLayoutServices| { + shape_one_character( + services.parley_mut(), + web_font_style("CSS Local Order", 400.0), + 'A', + ) + }; + assert_eq!(selected(&mut services), wide); + services.set_stylesheet_web_font_order(&["url-css".into(), "new-css".into()]); + assert_eq!(selected(&mut services), wide); + services.set_web_font_order(&["url-css".into(), "local-css".into()], &[]); + assert_eq!(selected(&mut services), narrow); + services.set_stylesheet_web_font_order(&["url-css".into()]); + assert_eq!(selected(&mut services), narrow); + } + #[test] fn stylesheet_font_selection_preserves_native_faces_and_restores_after_error() { let mut services = diff --git a/moli-renderer-v8/src/native_bridge/context_host/layout.rs b/moli-renderer-v8/src/native_bridge/context_host/layout.rs index e63114e687..83d99ee2a3 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/layout.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/layout.rs @@ -645,11 +645,14 @@ impl JsContextHost { document: DomHandle, resources: impl IntoIterator, ) { - self.document_layout_state.borrow_mut().retain_active_slots( + let changed = self.document_layout_state.borrow_mut().retain_active_slots( document, self.document_handle(), resources, ); + if changed { + self.invalidate_document_font_layout(document); + } } pub(crate) fn admit_document_web_font( diff --git a/moli-renderer-v8/src/native_bridge/context_host/layout_state.rs b/moli-renderer-v8/src/native_bridge/context_host/layout_state.rs index 0456b4bd7e..e6d8bfd5e7 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/layout_state.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/layout_state.rs @@ -308,20 +308,27 @@ impl DocumentLayoutState { document: DomHandle, main_document: DomHandle, resources: impl IntoIterator, - ) { + ) -> bool { if document == main_document { return self .web_fonts .retain_active_slots(resources, &mut self.services); } + let mut resources = resources.into_iter().peekable(); + if resources.peek().is_some() { + return self.with_web_fonts_for_document(document, main_document, |fonts, services| { + fonts.retain_active_slots(resources, services) + }); + } if let Some(fonts) = self .embedded_web_fonts .as_mut() .and_then(|fonts| fonts.get_mut(&document)) && let Some(services) = self.embedded_document_services.get_mut(&document) { - fonts.retain_active_slots(resources, services); + return fonts.retain_active_slots(resources, services); } + false } pub(super) fn admit( diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/fonts/selection_order.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/fonts/selection_order.rs index 7480e94a81..ac96cf1c28 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/fonts/selection_order.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/fonts/selection_order.rs @@ -23,6 +23,131 @@ fn order_vm() -> StandaloneScriptVmHarness { vm } +#[tokio::test] +async fn font_face_css_selection_keeps_declaration_order_without_js_projection() { + use crate::native_bridge::document::take_owner_font_face_projection_count_for_test; + + let (narrow, wide) = order_font_data(); + for (sources, expected) in [([&narrow, &wide], "160"), ([&wide, &narrow], "80")] { + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + loader.set_optional_resource_fetch_mask( + crate::protocol_types::OptionalResourceFetchMask::FONT, + ); + let (mut vm, _completions) = new_parsed_test_vm_with_loader_and_resource_completion_queue( + "https://font-selection-order.test/", + "AAAA", + &loader, + ); + vm.set_layout_policy(moli_page_types::LayoutPolicy::OnDemand); + take_owner_font_face_projection_count_for_test(); + vm.eval(&format!(r#" + Object.defineProperty(document, 'fonts', {{get() {{throw new Error('unexpected document.fonts access')}}}}); + const style=document.createElement('style'); + style.textContent='@font-face{{font-family:OrderFace;src:url(data:font/ttf;base64,{})}}@font-face{{font-family:OrderFace;src:url(data:font/ttf;base64,{})}}'; + document.head.append(style); + "#, sources[0], sources[1])).unwrap(); + vm.reconcile_document_web_fonts_for_layout(); + assert_eq!(vm.document_web_font_counts_for_test(), (2, 2, 2)); + assert_eq!( + vm.eval("String(probe.getBoundingClientRect().width)") + .unwrap(), + expected + ); + assert_eq!(take_owner_font_face_projection_count_for_test(), 0); + } +} + +#[tokio::test] +async fn font_face_css_reordering_preserves_last_duplicate_and_manual_priority() { + use crate::native_bridge::document::take_owner_font_face_projection_count_for_test; + + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + loader.set_optional_resource_fetch_mask(crate::protocol_types::OptionalResourceFetchMask::FONT); + let (mut vm, _completions) = new_parsed_test_vm_with_loader_and_resource_completion_queue( + "https://font-selection-order.test/", + "AAAA", + &loader, + ); + vm.set_layout_policy(moli_page_types::LayoutPolicy::OnDemand); + let (narrow, wide) = order_font_data(); + take_owner_font_face_projection_count_for_test(); + vm.eval(&format!(r#" + globalThis.styles=["{narrow}","{wide}"].map(data=>{{ + const style=document.createElement('style'); + style.textContent=`@font-face{{font-family:OrderFace;src:url(data:font/ttf;base64,${{data}})}}`; + document.head.append(style);return style; + }}); + "#)).unwrap(); + let width = |vm: &mut StandaloneScriptVmHarness| { + vm.reconcile_document_web_fonts_for_layout(); + assert_eq!(vm.document_web_font_counts_for_test(), (2, 2, 2)); + vm.eval("String(probe.getBoundingClientRect().width)") + .unwrap() + }; + assert_eq!(width(&mut vm), "160"); + vm.eval("document.head.append(styles[0])").unwrap(); + assert_eq!(width(&mut vm), "80"); + vm.eval("globalThis.duplicate=styles[1].cloneNode(true);document.head.append(duplicate)") + .unwrap(); + assert_eq!(width(&mut vm), "160"); + vm.eval("duplicate.remove()").unwrap(); + assert_eq!(width(&mut vm), "80"); + assert_eq!(take_owner_font_face_projection_count_for_test(), 0); + + vm.eval(&format!(r#" + document.fonts.add(new FontFace('OrderFace',Uint8Array.from(atob('{wide}'),c=>c.charCodeAt(0)))); + document.head.append(styles[1]); + "#)).unwrap(); + vm.reconcile_document_web_fonts_for_layout(); + assert_eq!(vm.document_web_font_counts_for_test(), (2, 2, 3)); + vm.eval("document.head.append(styles[0])").unwrap(); + vm.reconcile_document_web_fonts_for_layout(); + assert_eq!( + vm.eval("String(probe.getBoundingClientRect().width)") + .unwrap(), + "160" + ); + vm.eval("document.fonts.clear()").unwrap(); + assert_eq!( + vm.eval("String(probe.getBoundingClientRect().width)") + .unwrap(), + "80" + ); +} + +#[tokio::test] +async fn font_face_css_order_is_published_before_child_font_admission() { + use crate::native_bridge::document::take_owner_font_face_projection_count_for_test; + + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + loader.set_optional_resource_fetch_mask(crate::protocol_types::OptionalResourceFetchMask::FONT); + let (mut vm, _completions) = new_parsed_test_vm_with_loader_and_resource_completion_queue( + "https://font-selection-order.test/", + "", + &loader, + ); + vm.set_layout_policy(moli_page_types::LayoutPolicy::OnDemand); + let (narrow, wide) = order_font_data(); + take_owner_font_face_projection_count_for_test(); + for (first, second, expected) in [(&narrow, &wide, "160"), (&wide, &narrow, "80")] { + vm.eval(&format!(r#" + globalThis.child=frame.contentDocument; + child.open(); + child.write('AAAA'); + child.close(); + Object.defineProperty(child,'fonts',{{configurable:true,get(){{throw new Error('unexpected child.fonts access')}}}}); + "#)).unwrap(); + vm.reconcile_document_web_fonts_for_layout(); + assert_eq!( + vm.eval("String(child.getElementById('target').getBoundingClientRect().width)") + .unwrap(), + expected + ); + assert_eq!(vm.document_web_font_counts_for_test(), (0, 0, 0)); + assert_eq!(take_owner_font_face_projection_count_for_test(), 0); + } +} + #[test] fn font_face_selection_follows_owner_membership_instead_of_resource_id() { let mut vm = order_vm(); diff --git a/moli-renderer-v8/src/script_vm/web_fonts.rs b/moli-renderer-v8/src/script_vm/web_fonts.rs index a6b65b210a..4933de1303 100644 --- a/moli-renderer-v8/src/script_vm/web_fonts.rs +++ b/moli-renderer-v8/src/script_vm/web_fonts.rs @@ -87,20 +87,23 @@ impl DocumentWebFontState { &mut self, resources: impl IntoIterator, services: &mut DocumentLayoutServices, - ) { - let active = resources + ) -> bool { + let order = resources .into_iter() .filter_map(StylesheetLoadBlockingResource::web_font) .map(|font| font.slot().to_owned()) - .collect::>(); + .collect::>(); + let active = order.iter().collect::>(); + let mut changed = false; self.slots.retain(|slot, entry| { if active.contains(slot) { return true; } - entry.load.borrow().unregister(services); + changed |= entry.load.borrow().unregister(services); false }); self.loads.retain(|_, load| load.strong_count() != 0); + changed | services.set_stylesheet_web_font_order(&order) } fn bind_request( diff --git a/moli-renderer-v8/src/style_engine/stylesheet_resources.rs b/moli-renderer-v8/src/style_engine/stylesheet_resources.rs index a01449de71..37be1936b4 100644 --- a/moli-renderer-v8/src/style_engine/stylesheet_resources.rs +++ b/moli-renderer-v8/src/style_engine/stylesheet_resources.rs @@ -1,4 +1,7 @@ -use std::{collections::BTreeMap, sync::Arc}; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::Arc, +}; use crate::{css_resource_urls::StylesheetLoadBlockingResource, document_runtime::DomHandle}; @@ -94,7 +97,7 @@ impl StylesheetResourceManifest { ) -> Self { #[cfg(test)] STYLESHEET_RESOURCE_MANIFEST_BUILD_COUNT.with(|count| count.set(count.get() + 1)); - let mut web_fonts = BTreeMap::new(); + let mut web_fonts = Vec::new(); let mut imports = BTreeMap::new(); collect_resources( document_stylesheets, @@ -115,8 +118,22 @@ impl StylesheetResourceManifest { &mut imports, ); } + // Slots share resource identity, but priority follows declarations. + // Keep the last occurrence when identical rules reuse the same slot. + let mut seen = BTreeSet::new(); + web_fonts.reverse(); + web_fonts.retain(|resource| { + seen.insert( + resource + .web_font() + .expect("font resource") + .slot() + .to_owned(), + ) + }); + web_fonts.reverse(); Self { - web_fonts: web_fonts.into_values().collect::>().into(), + web_fonts: web_fonts.into(), imports: imports.into_values().collect::>().into(), } } @@ -137,7 +154,7 @@ fn collect_resources( stylesheets: &ActiveStylesheetCollection, device: &style::device::Device, custom_media: &style::stylesheets::CustomMediaMap, - web_fonts: &mut BTreeMap, + web_fonts: &mut Vec, imports: &mut BTreeMap, ) { for stylesheet in stylesheets.entries() { @@ -151,12 +168,9 @@ fn collect_resources( continue; } let resource = resource.resource(); - let Some(font) = resource.web_font() else { - continue; - }; - web_fonts - .entry(font.slot().to_owned()) - .or_insert_with(|| resource.clone()); + if resource.web_font().is_some() { + web_fonts.push(resource.clone()); + } } for import in stylesheet.import_urls() { let mut identity = import.clone();