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 35f6f43f83..706a83be87 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_matching import run_font_matching_contract async def run_font_face_payload_group( @@ -160,5 +161,6 @@ async def run_font_face_payload_group( commands=["Runtime.evaluate"], observed=observed, ) + await run_font_matching_contract(browser, fixture, results) finally: await context.close() diff --git a/moli-cdp-smoke/moli_cdp_smoke/groups/font_face_matching.py b/moli-cdp-smoke/moli_cdp_smoke/groups/font_face_matching.py new file mode 100644 index 0000000000..8241475acc --- /dev/null +++ b/moli-cdp-smoke/moli_cdp_smoke/groups/font_face_matching.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import base64 +from typing import Any + +from ..assertions import assert_equal, record_contract +from ..config import REPO_ROOT + + +FONT_MATCHING_PROBE = r"""async ({narrowData, wideData}) => { + const bytes = data => Uint8Array.from(atob(data), c => c.charCodeAt(0)); + const load = async (query, text) => { + try { return (await document.fonts.load(query, text)).map(face => face.family); } + catch (error) { return error.name; } + }; + const bad = (family, descriptors = {}) => { + const face = new FontFace(family, 'url(data:font/ttf;base64,AA==)', descriptors); + face.loaded.catch(() => {}); + document.fonts.add(face); + return face; + }; + const range = bad('ReviewRange', {unicodeRange: 'U+0041'}); + const text = { + checks: ['B', ''].map(text => document.fonts.check('16px ReviewRange', text)), + loads: [await load('16px ReviewRange', 'B'), await load('16px ReviewRange', '')], + status: range.status, + included: document.fonts.check('16px ReviewRange', 'A'), + }; + const properties = []; + const calculations = []; + for (const [family, goodDescriptors, badDescriptors, query] of [ + ['ReviewMathWeight', {weight:'400'}, {weight:'700'}, 'calc(400 + 300) 16px ReviewMathWeight'], + ['ReviewMathStyle', {style:'oblique 14deg'}, {style:'oblique 20deg'}, 'oblique calc(10deg + 10deg) 16px ReviewMathStyle'], + ['ReviewMathWidth', {}, {stretch:'calc(50% + 25%)'}, '16px ReviewMathWidth'], + ]) { + document.fonts.add(new FontFace(family,bytes(wideData),goodDescriptors)); + bad(family,badDescriptors); + calculations.push([document.fonts.check(query,'A'),await load(query,'A')]); + } + calculations.push([ + document.fonts.check('condensed 16px ReviewMathWidth', 'A'), + await load('condensed 16px ReviewMathWidth', 'A'), + ]); + for (const [family, descriptors, selectedQuery] of [ + ['ReviewWeight', {weight: '700'}, 'bold 16px ReviewWeight'], + ['ReviewStyle', {style: 'italic'}, 'italic 16px ReviewStyle'], + ['ReviewWidth', {stretch: 'condensed'}, 'condensed 16px ReviewWidth'], + ]) { + const good = new FontFace(family, bytes(wideData)); + const other = bad(family, descriptors); + document.fonts.add(good); + properties.push([ + document.fonts.check(`16px ${family}`, 'A'), + await load(`16px ${family}`, 'A'), other.status, + document.fonts.check(selectedQuery, 'A'), + ]); + } + const normal = new FontFace('ReviewCapabilityRange', bytes(wideData), {unicodeRange: 'U+0042'}); + document.fonts.add(normal); + const bold = bad('ReviewCapabilityRange', {weight: '700', unicodeRange: 'U+0041'}); + const capabilityBeforeRange = [document.fonts.check('16px ReviewCapabilityRange', 'A'), + await load('16px ReviewCapabilityRange', 'A'), bold.status]; + normal.unicodeRange = 'U+0041'; + const descriptorWrites = [document.fonts.check('16px ReviewCapabilityRange', 'A')]; + bold.weight = '400'; + descriptorWrites.push(document.fonts.check('16px ReviewCapabilityRange', 'A')); + bold.weight = '700'; + descriptorWrites.push(document.fonts.check('16px ReviewCapabilityRange', 'A'), + document.fonts.check('bolder 16px ReviewCapabilityRange', 'A')); + + bad('ReviewEmoji', {unicodeRange: 'U+1F600'}); + const scalars = ['😁', '😀'].map(text => document.fonts.check('16px ReviewEmoji', text)); + const first = new FontFace('ReviewFirstFamily', bytes(wideData)); + const second = new FontFace('ReviewSecondFamily', bytes(wideData)); + document.fonts.add(first); document.fonts.add(second); + const familyOrder = await load('16px ReviewSecondFamily, ReviewFirstFamily', 'A'); + + const span = document.createElement('span'); + span.textContent = 'AAAA'; + span.style.cssText = 'display:inline-block;font:40px ReviewOrder'; + document.body.append(span); + const width = () => span.getBoundingClientRect().width; + const narrow = new FontFace('ReviewOrder', bytes(narrowData)); + const wide = new FontFace('ReviewOrder', bytes(wideData)); + document.fonts.add(narrow); + const order = [width()]; + document.fonts.add(wide); order.push(width()); + document.fonts.delete(narrow); document.fonts.delete(wide); + document.fonts.add(wide); document.fonts.add(narrow); order.push(width()); + document.fonts.delete(wide); document.fonts.add(wide); order.push(width()); + + const style = document.createElement('style'); + style.textContent = `@font-face {font-family:ReviewCss;src:url(data:font/ttf;base64,${narrowData})}`; + document.head.append(style); + const cssFace = [...document.fonts].find(face => face.family === 'ReviewCss'); + await cssFace.load(); + span.style.fontFamily = 'ReviewCss'; + const cssVsManual = [width()]; + const manual = new FontFace('ReviewCss', bytes(wideData)); + document.fonts.add(manual); cssVsManual.push(width()); + document.fonts.delete(manual); cssVsManual.push(width()); + + const lateManual = new FontFace('ReviewLateCss', bytes(wideData)); + document.fonts.add(lateManual); + span.style.fontFamily = 'ReviewLateCss'; + const lateCss = [width()]; + const lateStyle = document.createElement('style'); + lateStyle.textContent = `@font-face {font-family:ReviewLateCss;src:url(data:font/ttf;base64,${narrowData})}`; + document.head.append(lateStyle); + await [...document.fonts].find(face => face.family === 'ReviewLateCss').load(); + lateCss.push(width()); + + const frame = document.createElement('iframe'); + document.body.append(frame); + const child = frame.contentDocument; + child.open(); + child.write('AAAA'); + child.close(); + child.fonts.add(wide); child.fonts.add(narrow); + span.style.fontFamily = 'ReviewOrder'; + const widths = () => [width(), child.getElementById('target').getBoundingClientRect().width]; + const owners = [widths()]; + document.fonts.delete(narrow); document.fonts.add(narrow); owners.push(widths()); + child.fonts.delete(wide); child.fonts.add(wide); owners.push(widths()); + return {text, properties, calculations, capabilityBeforeRange, descriptorWrites, scalars, familyOrder, + order, cssVsManual, lateCss, owners}; +}""" + + +EXPECTED_FONT_MATCHING = { + "text": {"checks": [True, True], "loads": [[], []], "status": "unloaded", "included": False}, + "properties": [ + [True, ["ReviewWeight"], "unloaded", False], + [True, ["ReviewStyle"], "unloaded", False], + [True, ["ReviewWidth"], "unloaded", False], + ], + "calculations": [ + [False, "NetworkError"], + [False, "NetworkError"], + [True, ["ReviewMathWidth"]], + [False, "NetworkError"], + ], + "capabilityBeforeRange": [True, [], "unloaded"], + "descriptorWrites": [True, False, True, False], + "scalars": [True, False], + "familyOrder": ["ReviewSecondFamily", "ReviewFirstFamily"], + "order": [80, 160, 80, 160], + "cssVsManual": [80, 160, 80], + "lateCss": [160, 160], + "owners": [[160, 80], [80, 80], [80, 160]], +} + + +def font_matching_payload() -> dict[str, str]: + fixtures = REPO_ROOT / "moli-layout/tests/fixtures" + return { + label + "Data": base64.b64encode((fixtures / filename).read_bytes()).decode("ascii") + for label, filename in [("narrow", "moli-ahem-narrow.ttf"), ("wide", "moli-ahem-wide.ttf")] + } + + +async def run_font_matching_contract(browser: Any, fixture: str, results: list[dict[str, Any]]) -> None: + context = await browser.new_context() + try: + page = await context.new_page() + await page.goto(f"{fixture}/plain", wait_until="load", timeout=10_000) + observed = await page.evaluate(FONT_MATCHING_PROBE, font_matching_payload()) + assert_equal(observed, EXPECTED_FONT_MATCHING, "FontFaceSet matching and per-document selection order") + record_contract( + results, + "font_face_matching_and_owner_order", + contract="FontFaceSet matches width/style/weight before text ranges and selects faces in each owner's CSS/manual insertion order.", + source="Chromium 147 and CSS Font Loading matching rules", + commands=["Runtime.evaluate"], + observed=observed, + ) + finally: + await context.close() diff --git a/moli-layout/src/text.rs b/moli-layout/src/text.rs index bc2825ce5f..e80c0dce27 100644 --- a/moli-layout/src/text.rs +++ b/moli-layout/src/text.rs @@ -706,6 +706,9 @@ pub struct DocumentLayoutServices { local_font_sources: Option>, system_font_policy: SystemFontPolicy, web_fonts: BTreeMap, + // Resource slots retain identity across removals and shared FontFaces. + // Selection priority belongs to this Document's current FontFaceSet. + web_font_order: BTreeMap, pub(crate) text_layout_passes: u64, } @@ -723,6 +726,7 @@ impl DocumentLayoutServices { local_font_sources: None, system_font_policy: SystemFontPolicy::Enabled, web_fonts: BTreeMap::new(), + web_font_order: BTreeMap::new(), text_layout_passes: 0, } } @@ -734,6 +738,7 @@ impl DocumentLayoutServices { local_font_sources: None, system_font_policy, web_fonts: BTreeMap::new(), + web_font_order: BTreeMap::new(), text_layout_passes: 0, } } @@ -756,6 +761,46 @@ impl DocumentLayoutServices { self.web_fonts.len() } + /// Publishes CSS-connected order followed by manual insertion order for + /// 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 { + let order = stylesheet + .iter() + .enumerate() + .map(|(index, slot)| (slot.clone(), (false, index))) + .chain( + manual + .iter() + .enumerate() + .map(|(index, slot)| (slot.clone(), (true, index))), + ) + .collect::>(); + if self.web_font_order == order { + return false; + } + let previous = ordered_web_fonts(&self.web_fonts, &self.web_font_order) + .into_iter() + .map(|(slot, _)| slot.clone()) + .collect::>(); + self.web_font_order = order; + let current = ordered_web_fonts(&self.web_fonts, &self.web_font_order) + .into_iter() + .map(|(slot, _)| slot.clone()) + .collect::>(); + if current == previous { + return false; + } + if self.parley.is_some() { + self.parley = Some(Box::new(build_parley_services( + self.system_font_policy, + &self.web_fonts, + &self.web_font_order, + ))); + } + true + } + /// Adds or replaces one owner-validated font face. /// /// This API performs no document/generation check: the resource owner must @@ -789,6 +834,7 @@ impl DocumentLayoutServices { self.parley = Some(Box::new(build_parley_services( self.system_font_policy, &self.web_fonts, + &self.web_font_order, ))); } Ok(outcome) @@ -803,6 +849,7 @@ impl DocumentLayoutServices { self.parley = Some(Box::new(build_parley_services( self.system_font_policy, &self.web_fonts, + &self.web_font_order, ))); } true @@ -847,6 +894,7 @@ impl DocumentLayoutServices { local_font_sources: None, system_font_policy: self.system_font_policy, web_fonts, + web_font_order: self.web_font_order.clone(), text_layout_passes: 0, }; let result = consume(&mut temporary); @@ -873,15 +921,31 @@ impl DocumentLayoutServices { self.parley = Some(Box::new(build_parley_services( self.system_font_policy, &self.web_fonts, + &self.web_font_order, ))); } self.parley.as_deref_mut().expect("Parley was initialized") } } +fn ordered_web_fonts<'a>( + fonts: &'a BTreeMap, + order: &BTreeMap, +) -> Vec<(&'a String, &'a RegisteredWebFont)> { + let mut fonts = fonts.iter().collect::>(); + fonts.sort_by_key(|(slot, font)| { + order + .get(*slot) + .copied() + .unwrap_or((!font.stylesheet, usize::MAX)) + }); + fonts +} + fn build_parley_services( system_font_policy: SystemFontPolicy, web_fonts: &BTreeMap, + web_font_order: &BTreeMap, ) -> ParleyDocumentServices { let mut collection = Collection::new(CollectionOptions { shared: false, @@ -895,7 +959,10 @@ fn build_parley_services( source_cache: Default::default(), }; let mut web_font_families = BTreeMap::::new(); - for (source_order, font) in web_fonts.values().enumerate() { + for (source_order, (_, font)) in ordered_web_fonts(web_fonts, web_font_order) + .into_iter() + .enumerate() + { let data: Arc + Send + Sync> = Arc::new(Arc::clone(&font.sfnt_bytes)); let blob = Blob::new(data); let selector_fonts = font_context.collection.register_fonts( @@ -1168,7 +1235,11 @@ mod tests { #[test] fn system_family_substitution_preserves_css_fallback_order() { - let mut parley = build_parley_services(SystemFontPolicy::Disabled, &BTreeMap::new()); + let mut parley = build_parley_services( + SystemFontPolicy::Disabled, + &BTreeMap::new(), + &BTreeMap::new(), + ); parley.system_font_family_resolver = Some(SystemFontFamilyResolver::new( &mut parley.font_context.collection, )); diff --git a/moli-layout/tests/fixtures/README.md b/moli-layout/tests/fixtures/README.md index 43660b35a4..3add19dd58 100644 --- a/moli-layout/tests/fixtures/README.md +++ b/moli-layout/tests/fixtures/README.md @@ -22,6 +22,12 @@ These three files are licensed under the repository's MIT OR Apache-2.0 terms. | `moli-ahem.ttf` | `a7d23739002b2f3f20aa6d50d64b220287d9993dbd4e68aee84eba9f7b6eb336` | | `moli-ahem.woff` | `17b7bee15b40e764d785d69412ec4fdc87715a5c107d46e31664fb6393f29f85` | | `moli-ahem.woff2` | `c054d1e545d4c234c8c4bfea2bf903d8304fad3b20a51dab60338cea29c793d2` | +| `moli-ahem-narrow.ttf` | `198ca6ee6823ca2dd6e6a8e8ab50c235750aed172cda10b17bee32c32680ea03` | +| `moli-ahem-wide.ttf` | `c4049fa36a8a19e45ca4f99a494f50078e7cd6f9aa025ee5d82c93c26824fba0` | + +The narrow and wide variants use 500-unit and 1000-unit advances. Four letters +at 40px have widths of 80px and 160px respectively, providing controls for +FontFaceSet selection order without relying on installed fonts. ## Subset fallback faces diff --git a/moli-layout/tests/fixtures/moli-ahem-narrow.ttf b/moli-layout/tests/fixtures/moli-ahem-narrow.ttf new file mode 100644 index 0000000000..17a0f33992 Binary files /dev/null and b/moli-layout/tests/fixtures/moli-ahem-narrow.ttf differ diff --git a/moli-layout/tests/fixtures/moli-ahem-wide.ttf b/moli-layout/tests/fixtures/moli-ahem-wide.ttf new file mode 100644 index 0000000000..14b08edb1e Binary files /dev/null and b/moli-layout/tests/fixtures/moli-ahem-wide.ttf differ diff --git a/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/font_face_set/loading.rs b/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/font_face_set/loading.rs index 853b2834da..24b4c3448f 100644 --- a/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/font_face_set/loading.rs +++ b/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/font_face_set/loading.rs @@ -28,8 +28,9 @@ pub(in crate::context_bootstrap) fn font_face_set_check_callback<'s>( let Some(parsed) = webidl::parse_args::(scope, &args) else { return; }; - let _ = &parsed.text; - let Some(faces) = font_face_set_matching_faces_array(scope, args.this(), &parsed.font) else { + let Some(faces) = + font_face_set_matching_faces_array(scope, args.this(), &parsed.font, &parsed.text) + else { webidl::throw_dom_exception( scope, "SyntaxError", @@ -59,8 +60,9 @@ pub(in crate::context_bootstrap) fn font_face_set_load_callback<'s>( let Some(parsed) = webidl::parse_args::(scope, &args) else { return; }; - let _ = &parsed.text; - let Some(matching_faces) = font_face_set_matching_faces_array(scope, this, &parsed.font) else { + let Some(matching_faces) = + font_face_set_matching_faces_array(scope, this, &parsed.font, &parsed.text) + else { rv.set( make_rejected_dom_exception_promise( scope, diff --git a/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/query.rs b/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/query.rs index dc71988d64..dbe6a27946 100644 --- a/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/query.rs +++ b/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/query.rs @@ -2,33 +2,32 @@ use super::storage::font_face_set_faces_array; use super::*; use crate::util::serialize_v8_iter_array; -fn font_face_matches_query<'s>( - scope: &mut v8::PinScope<'s, '_>, - face: v8::Local<'s, v8::Object>, - families: &[String], -) -> bool { - let family = super::loading::string_slot(scope, face, FONT_FACE_FAMILY_SLOT); - let family = moli_css_parse::unquote_css_string(&family); - families - .iter() - .any(|query| query.eq_ignore_ascii_case(&family)) +mod selection; + +struct Candidate<'s> { + face: v8::Local<'s, v8::Value>, + family: String, + selection: selection::Selection, + ranges: Vec, } pub(super) fn font_face_set_matching_faces_array<'s>( scope: &mut v8::PinScope<'s, '_>, object: v8::Local<'s, v8::Object>, query: &str, + text: &str, ) -> Option> { if !moli_css_parse::font_load_query_is_valid(query) { return None; } // System font shorthands are valid queries, but Stylo's Servo configuration // does not expose their platform families for matching authored faces. - let families = moli_css_parse::parse_font_shorthand(query) - .map(|query| query.families) - .unwrap_or_default(); + let Some(query) = moli_css_parse::parse_font_shorthand(query) else { + return serialize_v8_iter_array(scope, std::iter::empty::>()); + }; + let requested = selection::Selection::new(&query.weight, &query.stretch, &query.style); let faces = font_face_set_faces_array(scope, object)?; - let mut matching = Vec::new(); + let mut candidates = Vec::new(); for index in 0..faces.length() { let Some(face) = faces.get_index(scope, index) else { continue; @@ -36,10 +35,45 @@ pub(super) fn font_face_set_matching_faces_array<'s>( let Ok(face_object) = v8::Local::::try_from(face) else { continue; }; - if !font_face_matches_query(scope, face_object, &families) { + let family = super::loading::string_slot(scope, face_object, FONT_FACE_FAMILY_SLOT); + let weight = super::loading::string_slot(scope, face_object, FONT_FACE_WEIGHT_SLOT); + let stretch = super::loading::string_slot(scope, face_object, FONT_FACE_STRETCH_SLOT); + let style = super::loading::string_slot(scope, face_object, FONT_FACE_STYLE_SLOT); + let ranges = super::loading::string_slot(scope, face_object, FONT_FACE_UNICODE_RANGE_SLOT); + candidates.push(Candidate { + face, + family: moli_css_parse::unquote_css_string(&family), + selection: selection::Selection::new(&weight, &stretch, &style), + ranges: crate::css_resource_urls::parse_font_unicode_ranges(&ranges) + .unwrap_or_default(), + }); + } + let text = text.chars().collect::>(); + let mut matching = Vec::new(); + for family in query.families { + let family_faces = candidates + .iter() + .filter(|face| family.eq_ignore_ascii_case(&face.family)) + .collect::>(); + let Some(best) = family_faces + .iter() + .min_by(|a, b| a.selection.compare(b.selection, requested)) + else { continue; + }; + for face in &family_faces { + // Select capabilities from the entire family first. Filtering by + // text or load state earlier could choose an unrelated fallback + // weight/style instead of the CSS-matched segmented face. + if face.selection.compare(best.selection, requested).is_eq() + && text.iter().any(|&character| { + face.ranges.is_empty() + || face.ranges.iter().any(|range| range.contains(character)) + }) + { + matching.push(face.face); + } } - matching.push(face); } serialize_v8_iter_array(scope, matching) } diff --git a/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/query/selection.rs b/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/query/selection.rs new file mode 100644 index 0000000000..92d7080676 --- /dev/null +++ b/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/query/selection.rs @@ -0,0 +1,258 @@ +use std::cmp::Ordering; + +use crate::css_resource_urls::{parse_font_stretch_lower_bound, parse_font_weight_lower_bound}; +use moli_css_parse::{CssNumericContext, CssNumericKind, UnitlessAngle}; + +fn components(mut value: &str) -> Vec<&str> { + let mut values = Vec::new(); + while !value.trim_start().is_empty() { + value = value.trim_start(); + let word_end = value.find(char::is_whitespace).unwrap_or(value.len()); + let len = if value.find('(').is_some_and(|index| index < word_end) { + moli_css_parse::balanced_function_len(value).unwrap_or(value.len()) + } else { + word_end + }; + values.push(&value[..len]); + value = &value[len..]; + } + values +} + +#[derive(Clone, Copy, Debug)] +struct Range { + min: f32, + max: f32, +} + +impl Range { + fn parse(value: &str, parse: impl Fn(&str) -> Option, initial: f32) -> Self { + let mut values = components(value).into_iter(); + let min = values.next().and_then(&parse).unwrap_or(initial); + let max = values.next().and_then(parse).unwrap_or(min); + Self { min, max } + } + + fn contains(self, value: f32) -> bool { + self.min <= value && value <= self.max + } + + fn width_rank(self, requested: f32) -> (u8, f32) { + if self.contains(requested) { + (0, 0.0) + } else if requested <= 100.0 { + if self.max < requested { + (1, requested - self.max) + } else { + (2, self.min - requested) + } + } else if self.min > requested { + (1, self.min - requested) + } else { + (2, requested - self.max) + } + } + + fn weight_rank(self, requested: f32) -> (u8, f32) { + if self.contains(requested) { + return (0, 0.0); + } + if (400.0..=500.0).contains(&requested) { + if self.min > requested && self.min <= 500.0 { + (1, self.min - requested) + } else if self.max < requested { + (2, requested - self.max) + } else { + (3, self.min - 500.0) + } + } else if requested < 400.0 { + if self.max < requested { + (1, requested - self.max) + } else { + (2, self.min - requested) + } + } else if self.min > requested { + (1, self.min - requested) + } else { + (2, requested - self.max) + } + } + + fn style_rank(self, requested: f32) -> (u8, f32) { + if self.contains(requested) { + return (0, 0.0); + } + // Match Blink's slope search: italic and the default oblique slope are + // 14deg, with asymmetric fallback on either side of zero and +/-14deg. + if requested >= 14.0 { + if self.min > requested { + (1, self.min - requested) + } else { + (2, requested - self.max) + } + } else if requested >= 0.0 { + if self.max >= 0.0 && self.max < requested { + (1, requested - self.max) + } else if self.min > requested { + (2, self.min) + } else { + (3, -self.max) + } + } else if requested > -14.0 { + if self.min > requested && self.min <= 0.0 { + (1, self.min - requested) + } else if self.max < requested { + (2, -self.max) + } else { + (3, self.min) + } + } else if self.max < requested { + (1, requested - self.max) + } else { + (2, self.min - requested) + } + } +} + +/// Font capabilities are selected before testing unicode-range, and do not +/// depend on whether the source has been loaded or contains a particular glyph. +#[derive(Clone, Copy, Debug)] +pub(super) struct Selection { + width: Range, + slope: Range, + weight: Range, +} + +impl Selection { + pub(super) fn new(weight: &str, stretch: &str, style: &str) -> Self { + let angles = components(style); + let angle = |value: &str| { + moli_css_parse::resolve_css_numeric( + value, + CssNumericKind::Angle(UnitlessAngle::ZeroOnly), + CssNumericContext::default(), + ) + .and_then(|value| value.angle_degrees()) + .map(|value| (value as f32).clamp(-90.0, 90.0)) + }; + let min = match angles.first().copied() { + Some("italic") => 14.0, + Some("oblique") => angles.get(1).and_then(|value| angle(value)).unwrap_or(14.0), + _ => 0.0, + }; + let max = angles.get(2).and_then(|value| angle(value)).unwrap_or(min); + let weight_value = |value: &str| match value { + // FontFaceSet resolves relative weights against initial normal. + "bolder" => Some(700.0), + "lighter" => Some(100.0), + _ => moli_css_parse::resolve_css_number(value, None) + .map(|value| (value as f32).clamp(1.0, 1000.0)) + .or_else(|| parse_font_weight_lower_bound(value)), + }; + let width_value = |value: &str| { + parse_font_stretch_lower_bound(value).or_else(|| { + moli_css_parse::resolve_css_numeric( + value, + CssNumericKind::Percentage, + CssNumericContext::default(), + ) + .and_then(|value| value.percentage()) + .map(|value| value as f32) + }) + }; + Self { + width: Range::parse(stretch, width_value, 100.0), + slope: Range { min, max }, + weight: Range::parse(weight, weight_value, 400.0), + } + } + + pub(super) fn compare(self, other: Self, requested: Self) -> Ordering { + let first = [ + self.width.width_rank(requested.width.min), + self.slope.style_rank(requested.slope.min), + self.weight.weight_rank(requested.weight.min), + ]; + let second = [ + other.width.width_rank(requested.width.min), + other.slope.style_rank(requested.slope.min), + other.weight.weight_rank(requested.weight.min), + ]; + for (first, second) in first.into_iter().zip(second) { + let ordering = first + .0 + .cmp(&second.0) + .then_with(|| first.1.total_cmp(&second.1)); + if ordering != Ordering::Equal { + return ordering; + } + } + Ordering::Equal + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn weight_search_prefers_500_then_lower_weights_before_600_for_normal() { + let face = |weight| Selection::new(weight, "normal", "normal"); + assert_eq!( + face("500").compare(face("300"), face("400")), + Ordering::Less + ); + assert_eq!( + face("300").compare(face("600"), face("400")), + Ordering::Less + ); + assert_eq!( + face("400").compare(face("500"), face("450")), + Ordering::Greater + ); + assert_eq!( + face("600").compare(face("400"), face("550")), + Ordering::Less + ); + } + + #[test] + fn descriptor_ranges_and_width_then_style_then_weight_control_matching() { + let query = Selection::new("700", "normal", "oblique 20deg"); + assert_eq!( + Selection::new("100 900", "normal", "oblique 10deg 30deg") + .compare(Selection::new("700", "normal", "normal"), query), + Ordering::Less + ); + assert_eq!( + Selection::new("400", "normal", "normal") + .compare(Selection::new("700", "condensed", "oblique 20deg"), query), + Ordering::Less + ); + let narrow = Selection::new("400", "75%", "normal"); + let wide = Selection::new("400", "125%", "normal"); + assert_eq!( + narrow.compare(wide, Selection::new("400", "normal", "normal")), + Ordering::Less + ); + assert_eq!( + narrow.compare(wide, Selection::new("400", "110%", "normal")), + Ordering::Greater + ); + } + + #[test] + fn calculation_components_keep_whitespace_and_descriptor_range_boundaries() { + let ranged = Selection::new( + "calc(100 + 200) calc(800 - 100)", + "calc(50% + 25%) calc(100% + 25%)", + "oblique calc(5deg + 5deg) calc(15deg + 15deg)", + ); + let requested = Selection::new("calc(400 + 100)", "normal", "oblique calc(10deg + 10deg)"); + assert_eq!( + ranged.compare(Selection::new("400", "normal", "normal"), requested), + Ordering::Less, + "ranged={ranged:?}, requested={requested:?}" + ); + } +} diff --git a/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/storage.rs b/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/storage.rs index a883adafed..755c4f9d84 100644 --- a/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/storage.rs +++ b/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/storage.rs @@ -250,12 +250,48 @@ pub(crate) fn rebuild_font_face_set_faces<'s>( serialize_v8_iter_array(scope, combined).unwrap_or_else(|| v8::Array::new(scope, 0)); set_font_face_set_slot_value(scope, object, FONT_FACE_SET_FACES_SLOT, combined.into()); sync_font_face_set_size(scope, object); + sync_font_face_set_matching_order(scope, object); // Publish membership before changing owners can start a loading cycle. // Reentrant FontFaceSet queries must see the same members as that cycle. sync_font_face_set_owners(scope, object, previous, combined); super::events::finish_font_set_if_idle(scope, object); } +fn sync_font_face_set_matching_order<'s>( + scope: &mut v8::PinScope<'s, '_>, + fonts: v8::Local<'s, v8::Object>, +) { + let Some((host_ptr, document)) = font_face_set_document_context(scope, fonts) else { + return; + }; + let connected = font_face_set_connected_faces_array(scope, fonts); + let manual = font_face_set_manual_faces_array(scope, fonts); + let mut slots = |faces: Option>| { + let mut slots = Vec::new(); + if let Some(faces) = faces { + for index in 0..faces.length() { + if let Some(face) = faces + .get_index(scope, index) + .and_then(|face| v8::Local::::try_from(face).ok()) + && let Some(load) = super::state::get(scope, face) + { + slots.push(load.borrow().slot().to_owned()); + } + } + } + slots + }; + let connected = slots(connected); + let manual = slots(manual); + // SAFETY: the native font service cannot call V8 while borrowed. + let host = unsafe { &*host_ptr }; + if host.with_font_services(document, |services| { + services.set_web_font_order(&connected, &manual) + }) { + host.invalidate_document_font_layout(document); + } +} + pub(super) fn font_face_set_owner_snapshot<'s>( scope: &mut v8::PinScope<'s, '_>, face: v8::Local<'s, v8::Object>, diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/fonts.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/fonts.rs index 9663d549b0..9f709fa228 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/fonts.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/fonts.rs @@ -2,7 +2,9 @@ use super::*; use base64::Engine; use std::time::Duration; +mod matching; mod readiness; +mod selection_order; mod unicode_ranges; pub(super) fn fixture_font_url() -> String { diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/fonts/matching.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/fonts/matching.rs new file mode 100644 index 0000000000..dd4797f5c0 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/fonts/matching.rs @@ -0,0 +1,193 @@ +use super::*; + +#[test] +fn font_set_queries_resolve_css_calculations_in_requests_and_descriptors() { + let mut vm = new_storage_test_vm("https://font-query-calculations.test/"); + let url = fixture_font_url(); + vm.eval(&format!(r#" + const bytes=Uint8Array.from(atob('{}'),c=>c.charCodeAt(0)); + globalThis.calculations=[]; + for (const [family,goodDescriptor,badDescriptor,query] of [ + ['MathWeight',{{weight:'400'}},{{weight:'700'}},'calc(400 + 300) 16px MathWeight'], + ['MathStyle',{{style:'oblique 14deg'}},{{style:'oblique 20deg'}},'oblique calc(10deg + 10deg) 16px MathStyle'], + ['MathWidth',{{}},{{stretch:'calc(50% + 25%)'}},'16px MathWidth'] + ]) {{ + const good=new FontFace(family,bytes,goodDescriptor); + const bad=new FontFace(family,'url(data:font/ttf;base64,AA==)',badDescriptor); + bad.loaded.catch(()=>{{}}); + document.fonts.add(good);document.fonts.add(bad); + calculations.push(document.fonts.check(query,'A')); + document.fonts.load(query,'A').then( + faces=>calculations.push([faces.length,faces[0]===good,bad.status]), + error=>calculations.push(error.name)); + }} + "#,url.split_once(',').unwrap().1)).unwrap(); + assert_eq!( + vm.eval("JSON.stringify(calculations)").unwrap(), + r#"[false,false,true,"NetworkError","NetworkError",[1,true,"unloaded"]]"# + ); + vm.eval( + r#" + globalThis.widthCalculations = [document.fonts.check('condensed 16px MathWidth','A')]; + document.fonts.load('condensed 16px MathWidth','A').then( + () => widthCalculations.push('unexpected loaded normal face'), + error => widthCalculations.push(error.name)); + "#, + ) + .unwrap(); + assert_eq!( + vm.eval("JSON.stringify(widthCalculations)").unwrap(), + r#"[false,"NetworkError"]"# + ); +} + +#[test] +fn font_set_queries_filter_text_without_loading_unrelated_sources() { + let mut vm = new_storage_test_vm("https://font-query-text.test/"); + vm.eval( + r#" + globalThis.face = new FontFace('ReviewProbe', + 'url(data:font/ttf;base64,AA==)', {unicodeRange:'U+0041'}); + face.loaded.catch(()=>{}); + document.fonts.add(face); + globalThis.queryResults = {checks:[], loads:[]}; + for (const text of ['B', '']) { + queryResults.checks.push(document.fonts.check('16px ReviewProbe',text)); + document.fonts.load('16px ReviewProbe',text).then( + faces=>queryResults.loads.push([text,faces.length,face.status]), + error=>queryResults.loads.push([text,error.name,face.status])); + } + "#, + ) + .unwrap(); + assert_eq!( + vm.eval("JSON.stringify({...queryResults,status:face.status})") + .unwrap(), + r#"{"checks":[true,true],"loads":[["B",0,"unloaded"],["",0,"unloaded"]],"status":"unloaded"}"# + ); + vm.eval( + r#" + globalThis.matched = [document.fonts.check('16px ReviewProbe','A')]; + document.fonts.load('16px ReviewProbe','A').catch(e=>matched.push(e.name)); + "#, + ) + .unwrap(); + assert_eq!( + vm.eval("JSON.stringify(matched)").unwrap(), + r#"[false,"NetworkError"]"# + ); +} + +#[test] +fn font_set_queries_select_weight_style_and_stretch_before_loading() { + let mut vm = new_storage_test_vm("https://font-query-properties.test/"); + let url = fixture_font_url(); + vm.eval(&format!( + r#" + const bytes=Uint8Array.from(atob('{}'),c=>c.charCodeAt(0)); + globalThis.queryResults={{checks:[],loads:[]}}; + globalThis.badFaces=[]; + globalThis.goodFaces=[]; + for (const [family,descriptor] of [ + ['WeightMatch',{{weight:'700'}}],['StyleMatch',{{style:'italic'}}], + ['WidthMatch',{{stretch:'condensed'}}] + ]) {{ + const good=new FontFace(family,bytes); + const bad=new FontFace(family,'url(data:font/ttf;base64,AA==)',descriptor); + bad.loaded.catch(()=>{{}}); + goodFaces.push(good);badFaces.push(bad); + document.fonts.add(good);document.fonts.add(bad); + queryResults.checks.push(document.fonts.check(`16px ${{family}}`,'A')); + document.fonts.load(`16px ${{family}}`,'A').then( + faces=>queryResults.loads.push([faces.length,faces[0]===good,bad.status]), + error=>queryResults.loads.push(error.name)); + }} + "#, + url.split_once(',').unwrap().1 + )) + .unwrap(); + assert_eq!( + vm.eval("JSON.stringify(queryResults)").unwrap(), + r#"{"checks":[true,true,true],"loads":[[1,true,"unloaded"],[1,true,"unloaded"],[1,true,"unloaded"]]}"# + ); + vm.eval(r#" + globalThis.selected = []; + for (const query of ['bold 16px WeightMatch','italic 16px StyleMatch','condensed 16px WidthMatch']) { + selected.push(document.fonts.check(query,'A')); + document.fonts.load(query,'A').catch(e=>selected.push(e.name)); + } + "#).unwrap(); + assert_eq!( + vm.eval("JSON.stringify(selected)").unwrap(), + r#"[false,false,false,"NetworkError","NetworkError","NetworkError"]"# + ); +} + +#[test] +fn font_set_queries_select_capabilities_before_unicode_ranges_and_track_descriptor_writes() { + let mut vm = new_storage_test_vm("https://font-query-order.test/"); + let url = fixture_font_url(); + vm.eval(&format!( + r#" + const bytes=Uint8Array.from(atob('{}'),c=>c.charCodeAt(0)); + globalThis.normal=new FontFace('RangeWeight',bytes,{{unicodeRange:'U+0042'}}); + globalThis.bold=new FontFace('RangeWeight','url(data:font/ttf;base64,AA==)', + {{weight:'700',unicodeRange:'U+0041'}}); + bold.loaded.catch(()=>{{}}); + document.fonts.add(normal);document.fonts.add(bold); + globalThis.queryResults=[document.fonts.check('16px RangeWeight','A')]; + document.fonts.load('16px RangeWeight','A').then( + faces=>queryResults.push(faces.length,bold.status),e=>queryResults.push(e.name)); + "#, + url.split_once(',').unwrap().1 + )) + .unwrap(); + assert_eq!( + vm.eval("JSON.stringify(queryResults)").unwrap(), + r#"[true,0,"unloaded"]"# + ); + vm.eval( + r#" + globalThis.writes=[]; + normal.unicodeRange='U+0041'; + writes.push(document.fonts.check('16px RangeWeight','A')); + bold.weight='400'; + writes.push(document.fonts.check('16px RangeWeight','A')); + bold.weight='700'; + writes.push(document.fonts.check('16px RangeWeight','A')); + writes.push(document.fonts.check('bolder 16px RangeWeight','A')); + "#, + ) + .unwrap(); + assert_eq!( + vm.eval("JSON.stringify(writes)").unwrap(), + "[true,false,true,false]" + ); +} + +#[test] +fn font_set_queries_match_unicode_scalars_and_preserve_family_list_order() { + let mut vm = new_storage_test_vm("https://font-query-scalars.test/"); + let url = fixture_font_url(); + vm.eval(&format!( + r#" + const bytes=Uint8Array.from(atob('{}'),c=>c.charCodeAt(0)); + globalThis.first=new FontFace('FirstQueryFamily',bytes); + globalThis.second=new FontFace('SecondQueryFamily',bytes); + const emoji=new FontFace('EmojiQueryFamily','url(data:font/ttf;base64,AA==)', + {{unicodeRange:'U+1F600'}}); + emoji.loaded.catch(()=>{{}}); + document.fonts.add(first);document.fonts.add(second);document.fonts.add(emoji); + globalThis.queryResults=[document.fonts.check('16px EmojiQueryFamily','😁'), + document.fonts.check('16px EmojiQueryFamily','😀')]; + document.fonts.load('16px SecondQueryFamily, FirstQueryFamily','A').then( + faces=>queryResults.push(faces.length,faces[0]===second,faces[1]===first)); + "#, + url.split_once(',').unwrap().1 + )) + .unwrap(); + assert_eq!( + vm.eval("JSON.stringify(queryResults)").unwrap(), + "[true,false,2,true,true]" + ); +} 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 new file mode 100644 index 0000000000..7480e94a81 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/fonts/selection_order.rs @@ -0,0 +1,115 @@ +use super::*; + +fn order_font_data() -> (String, String) { + let encode = |bytes: &[u8]| base64::engine::general_purpose::STANDARD.encode(bytes); + ( + encode(include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../moli-layout/tests/fixtures/moli-ahem-narrow.ttf" + ))), + encode(include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../moli-layout/tests/fixtures/moli-ahem-wide.ttf" + ))), + ) +} + +fn order_vm() -> StandaloneScriptVmHarness { + let mut vm = new_parsed_test_vm( + "https://font-selection-order.test/", + "AAAA", + ); + vm.set_layout_policy(moli_page_types::LayoutPolicy::OnDemand); + vm +} + +#[test] +fn font_face_selection_follows_owner_membership_instead_of_resource_id() { + let mut vm = order_vm(); + let (narrow, wide) = order_font_data(); + vm.eval(&format!( + r#" + const bytes=data=>Uint8Array.from(atob(data),c=>c.charCodeAt(0)); + globalThis.narrow=new FontFace('OrderFace',bytes('{narrow}')); + globalThis.wide=new FontFace('OrderFace',bytes('{wide}')); + document.fonts.add(narrow); + "# + )) + .unwrap(); + let width = |vm: &mut StandaloneScriptVmHarness| { + vm.eval("String(probe.getBoundingClientRect().width)") + .unwrap() + }; + assert_eq!(width(&mut vm), "80"); + vm.eval("document.fonts.add(wide)").unwrap(); + assert_eq!(width(&mut vm), "160"); + vm.eval("document.fonts.delete(narrow);document.fonts.delete(wide);document.fonts.add(wide);document.fonts.add(narrow)").unwrap(); + assert_eq!( + vm.eval("[...document.fonts].map(f=>f===wide?'wide':'narrow').join(',')") + .unwrap(), + "wide,narrow" + ); + assert_eq!(width(&mut vm), "80"); + vm.eval("document.fonts.delete(wide);document.fonts.add(wide)") + .unwrap(); + assert_eq!(width(&mut vm), "160"); +} + +#[test] +fn manually_added_font_faces_take_priority_over_css_connected_faces() { + let mut vm = order_vm(); + let (narrow, wide) = order_font_data(); + vm.eval(&format!(r#" + const style=document.createElement('style'); + style.textContent='@font-face{{font-family:OrderFace;src:url(data:font/ttf;base64,{narrow})}}'; + document.head.append(style); + globalThis.narrow=[...document.fonts][0]; + narrow.load(); + "#)).unwrap(); + assert_eq!(vm.eval("narrow.status").unwrap(), "loaded"); + assert_eq!( + vm.eval("String(probe.getBoundingClientRect().width)") + .unwrap(), + "80" + ); + vm.eval(&format!(r#" + globalThis.wide=new FontFace('OrderFace',Uint8Array.from(atob('{wide}'),c=>c.charCodeAt(0))); + document.fonts.add(wide); + "#)).unwrap(); + assert_eq!( + vm.eval("String(probe.getBoundingClientRect().width)") + .unwrap(), + "160" + ); + vm.eval("document.fonts.delete(wide)").unwrap(); + assert_eq!( + vm.eval("String(probe.getBoundingClientRect().width)") + .unwrap(), + "80" + ); +} + +#[test] +fn shared_font_faces_have_independent_selection_order_in_each_document() { + let mut vm = order_vm(); + let (narrow, wide) = order_font_data(); + vm.eval(&format!(r#" + const bytes=data=>Uint8Array.from(atob(data),c=>c.charCodeAt(0)); + globalThis.narrow=new FontFace('OrderFace',bytes('{narrow}')); + globalThis.wide=new FontFace('OrderFace',bytes('{wide}')); + globalThis.child=frame.contentDocument; + child.open();child.write('AAAA');child.close(); + document.fonts.add(narrow);document.fonts.add(wide); + child.fonts.add(wide);child.fonts.add(narrow); + "#)).unwrap(); + let widths = |vm: &mut StandaloneScriptVmHarness| { + vm.eval("[probe.getBoundingClientRect().width,child.getElementById('target').getBoundingClientRect().width].join(',')").unwrap() + }; + assert_eq!(widths(&mut vm), "160,80"); + vm.eval("document.fonts.delete(narrow);document.fonts.add(narrow)") + .unwrap(); + assert_eq!(widths(&mut vm), "80,80"); + vm.eval("child.fonts.delete(wide);child.fonts.add(wide)") + .unwrap(); + assert_eq!(widths(&mut vm), "80,160"); +} diff --git a/scripts/generate-layout-test-font.py b/scripts/generate-layout-test-font.py index 95a62b2da2..234d752c95 100644 --- a/scripts/generate-layout-test-font.py +++ b/scripts/generate-layout-test-font.py @@ -57,7 +57,7 @@ def box_glyph(): return pen.glyph() -def build_ttf() -> None: +def build_ttf(output: Path = TTF_PATH, advance: int = 600) -> None: builder = FontBuilder(1000, isTTF=True) codepoints = list(range(0x21, 0x7F)) codepoints += [0x00A0, 0x00DF] @@ -79,7 +79,7 @@ def build_ttf() -> None: glyphs = {name: box_glyph() for name in glyph_names.values()} glyphs.update({".notdef": box_glyph(), "space": empty_glyph()}) builder.setupGlyf(glyphs) - builder.setupHorizontalMetrics({glyph: (600, 0) for glyph in glyph_order}) + builder.setupHorizontalMetrics({glyph: (advance, 0) for glyph in glyph_order}) builder.setupHorizontalHeader(ascent=800, descent=-200, lineGap=0) builder.setupNameTable( { @@ -104,7 +104,7 @@ def build_ttf() -> None: builder.setupPost() builder.setupMaxp() builder.setupHead(created=OPEN_TYPE_UNIX_EPOCH, modified=OPEN_TYPE_UNIX_EPOCH) - builder.save(TTF_PATH) + builder.save(output) def verify_source(source: Path, expected_sha256: str) -> None: @@ -143,6 +143,8 @@ def main() -> None: verify_source(DEJAVU_SOURCE, DEJAVU_SOURCE_SHA256) verify_source(DROID_CJK_SOURCE, DROID_CJK_SOURCE_SHA256) build_ttf() + build_ttf(FIXTURES / "moli-ahem-narrow.ttf", 500) + build_ttf(FIXTURES / "moli-ahem-wide.ttf", 1000) SYSTEM_FONT_TTF_PATH.parent.mkdir(parents=True, exist_ok=True) SYSTEM_FONT_TTF_PATH.write_bytes(TTF_PATH.read_bytes()) convert("woff")