fix(fonts): match queries and honor owner selection order

Select width, style and weight capabilities before filtering FontFaceSet
queries by text and unicodeRange. Share the matching path for check/load,
including CSS calculations and descriptor ranges, so unrelated unloaded
or broken sources do not affect a query.

Publish each Document's CSS/manual FontFaceSet order independently from
stable resource slots. Preserve manual overrides, re-addition priority
and different orders for shared faces across Documents.

Cover the matching and actual DOM geometry regressions with generated
narrow/wide fonts and a shared Chromium/Moli CDP oracle.
This commit is contained in:
ldm0
2026-10-23 04:07:15 +08:00
committed by Donough Liu
parent ff15becb62
commit 3a0f6ca178
14 changed files with 924 additions and 25 deletions
@@ -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()
@@ -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('<!doctype html><span id="target" style="display:inline-block;font:40px ReviewOrder">AAAA</span>');
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()
+73 -2
View File
@@ -706,6 +706,9 @@ pub struct DocumentLayoutServices {
local_font_sources: Option<Box<font_source::LocalFontSources>>,
system_font_policy: SystemFontPolicy,
web_fonts: BTreeMap<String, RegisteredWebFont>,
// 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)>,
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::<BTreeMap<_, _>>();
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::<Vec<_>>();
self.web_font_order = order;
let current = ordered_web_fonts(&self.web_fonts, &self.web_font_order)
.into_iter()
.map(|(slot, _)| slot.clone())
.collect::<Vec<_>>();
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<String, RegisteredWebFont>,
order: &BTreeMap<String, (bool, usize)>,
) -> Vec<(&'a String, &'a RegisteredWebFont)> {
let mut fonts = fonts.iter().collect::<Vec<_>>();
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<String, RegisteredWebFont>,
web_font_order: &BTreeMap<String, (bool, usize)>,
) -> 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::<String, SegmentedWebFontFamily>::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<dyn AsRef<[u8]> + 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,
));
+6
View File
@@ -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
Binary file not shown.
Binary file not shown.
@@ -28,8 +28,9 @@ pub(in crate::context_bootstrap) fn font_face_set_check_callback<'s>(
let Some(parsed) = webidl::parse_args::<FontFaceSetCheckArgs>(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::<FontFaceSetLoadArgs>(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,
@@ -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<moli_layout::WebFontUnicodeRange>,
}
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<v8::Local<'s, v8::Array>> {
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::<v8::Local<'s, v8::Value>>());
};
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::<v8::Object>::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::<Vec<_>>();
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::<Vec<_>>();
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)
}
@@ -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<f32>, 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:?}"
);
}
}
@@ -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<v8::Local<'s, v8::Array>>| {
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::<v8::Object>::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>,
@@ -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 {
@@ -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]"
);
}
@@ -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/",
"<!doctype html><html><head></head><body><span id='probe' style='display:inline-block;font:40px OrderFace'>AAAA</span><iframe id='frame'></iframe></body></html>",
);
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('<!doctype html><span id="target" style="display:inline-block;font:40px OrderFace">AAAA</span>');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");
}
+5 -3
View File
@@ -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")