mirror of
https://github.com/lexmount/moli.git
synced 2026-10-30 08:07:38 +00:00
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:
@@ -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
@@ -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,
|
||||
));
|
||||
|
||||
Vendored
+6
@@ -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
|
||||
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+6
-4
@@ -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");
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user