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")