fix(fonts): publish CSS declaration order without JS projection

This commit is contained in:
ldm0
2026-10-23 04:07:15 +08:00
committed by Donough Liu
parent bd9c6448b0
commit 4ae2dabfc6
8 changed files with 333 additions and 17 deletions
@@ -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()
@@ -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 = (
"<!doctype html><script>globalThis.fontsReads=0;"
"Object.defineProperty(document,'fonts',{get(){++fontsReads;"
"throw new Error('document.fonts must remain unobserved')}})</script>"
"<style>" + rules + "</style>"
'<span id="probe" style="display:inline-block;font:40px PureCssOrder">AAAA</span>'
)
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()
+66
View File
@@ -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<String, (bool, usize)>,
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::<BTreeMap<_, _>>();
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<String, (bool, usize)>) -> 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 =
@@ -645,11 +645,14 @@ impl JsContextHost {
document: DomHandle,
resources: impl IntoIterator<Item = &'a StylesheetLoadBlockingResource>,
) {
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(
@@ -308,20 +308,27 @@ impl DocumentLayoutState {
document: DomHandle,
main_document: DomHandle,
resources: impl IntoIterator<Item = &'a StylesheetLoadBlockingResource>,
) {
) -> 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(
@@ -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/",
"<!doctype html><html><head></head><body><span id='probe' style='display:inline-block;font:40px OrderFace'>AAAA</span></body></html>",
&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/",
"<!doctype html><html><head></head><body><span id='probe' style='display:inline-block;font:40px OrderFace'>AAAA</span></body></html>",
&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/",
"<!doctype html><iframe id='frame'></iframe>",
&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('<!doctype html><style>@font-face{{font-family:OrderFace;src:url(data:font/ttf;base64,{first})}}@font-face{{font-family:OrderFace;src:url(data:font/ttf;base64,{second})}}</style><span id="target" style="display:inline-block;font:40px OrderFace">AAAA</span>');
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();
+7 -4
View File
@@ -87,20 +87,23 @@ impl DocumentWebFontState {
&mut self,
resources: impl IntoIterator<Item = &'a StylesheetLoadBlockingResource>,
services: &mut DocumentLayoutServices,
) {
let active = resources
) -> bool {
let order = resources
.into_iter()
.filter_map(StylesheetLoadBlockingResource::web_font)
.map(|font| font.slot().to_owned())
.collect::<BTreeSet<_>>();
.collect::<Vec<_>>();
let active = order.iter().collect::<BTreeSet<_>>();
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(
@@ -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::<Vec<_>>().into(),
web_fonts: web_fonts.into(),
imports: imports.into_values().collect::<Vec<_>>().into(),
}
}
@@ -137,7 +154,7 @@ fn collect_resources(
stylesheets: &ActiveStylesheetCollection,
device: &style::device::Device,
custom_media: &style::stylesheets::CustomMediaMap,
web_fonts: &mut BTreeMap<String, StylesheetLoadBlockingResource>,
web_fonts: &mut Vec<StylesheetLoadBlockingResource>,
imports: &mut BTreeMap<String, url::Url>,
) {
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();