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();