Connect document font faces to rendering
This commit is contained in:
1 parent
0acf4e1b06
commit
5c34e74f10
5 files changed
+698
-24
No files matched your search
Vendored
+303
-19
@@ -10040,33 +10040,317 @@ if (typeof Range === 'undefined') {
|
||||
}
|
||||
|
||||
if (typeof FontFace === 'undefined') {
|
||||
const _fontFaceString = value => String(value ?? '');
|
||||
const _fontFaceBytesBase64 = bytes => {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
let out = '';
|
||||
for (let i = 0; i < bytes.length; i += 3) {
|
||||
const a = bytes[i], b = bytes[i + 1] || 0, c = bytes[i + 2] || 0;
|
||||
out += alphabet[a >> 2];
|
||||
out += alphabet[((a & 3) << 4) | (b >> 4)];
|
||||
out += i + 1 < bytes.length ? alphabet[((b & 15) << 2) | (c >> 6)] : '=';
|
||||
out += i + 2 < bytes.length ? alphabet[c & 63] : '=';
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const _fontFaceSource = source => {
|
||||
if (typeof source === 'string') {
|
||||
if (!source.trim()) throw new DOMException('The font source is empty', 'SyntaxError');
|
||||
return { css: source, binary: false };
|
||||
}
|
||||
let bytes;
|
||||
if (source instanceof ArrayBuffer) {
|
||||
bytes = new Uint8Array(source.slice(0));
|
||||
} else if (ArrayBuffer.isView(source)) {
|
||||
bytes = new Uint8Array(source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength));
|
||||
} else {
|
||||
throw new TypeError('FontFace source must be a CSS source string or BufferSource');
|
||||
}
|
||||
return {
|
||||
css: 'url("data:font/ttf;base64,' + _fontFaceBytesBase64(bytes) + '")',
|
||||
binary: true
|
||||
};
|
||||
};
|
||||
const _fontFaceDescriptor = (descriptors, name, fallback) =>
|
||||
descriptors && descriptors[name] !== undefined ? String(descriptors[name]) : fallback;
|
||||
const _fontFaceDeclarations = block => {
|
||||
const declarations = Object.create(null);
|
||||
let start = 0, depth = 0, quote = '', escaped = false;
|
||||
const commit = end => {
|
||||
const declaration = block.slice(start, end);
|
||||
const colon = declaration.indexOf(':');
|
||||
if (colon > 0) declarations[declaration.slice(0, colon).trim().toLowerCase()] =
|
||||
declaration.slice(colon + 1).trim();
|
||||
};
|
||||
for (let i = 0; i <= block.length; i++) {
|
||||
const ch = block[i];
|
||||
if (escaped) { escaped = false; continue; }
|
||||
if (ch === '\\') { escaped = true; continue; }
|
||||
if (quote) { if (ch === quote) quote = ''; continue; }
|
||||
if (ch === '"' || ch === "'") { quote = ch; continue; }
|
||||
if (ch === '(') depth++;
|
||||
else if (ch === ')') depth = Math.max(0, depth - 1);
|
||||
else if ((ch === ';' && depth === 0) || i === block.length) {
|
||||
commit(i);
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
return declarations;
|
||||
};
|
||||
const _fontFaceAuthoredRules = doc => {
|
||||
const out = [];
|
||||
for (const style of doc.querySelectorAll('style')) {
|
||||
const css = style.textContent || '';
|
||||
const pattern = /@font-face\s*\{([\s\S]*?)\}/gi;
|
||||
let match;
|
||||
while ((match = pattern.exec(css))) {
|
||||
const declarations = _fontFaceDeclarations(match[1]);
|
||||
const family = (declarations['font-family'] || '').trim().replace(/^(['"])(.*)\1$/, '$2');
|
||||
const source = declarations.src || '';
|
||||
if (!family || !source) continue;
|
||||
out.push({
|
||||
family,
|
||||
source,
|
||||
descriptors: {
|
||||
style: declarations['font-style'] || 'normal',
|
||||
weight: declarations['font-weight'] || 'normal',
|
||||
stretch: declarations['font-stretch'] || 'normal',
|
||||
unicodeRange: declarations['unicode-range'] || 'U+0-10FFFF',
|
||||
variant: declarations['font-variant'] || 'normal',
|
||||
featureSettings: declarations['font-feature-settings'] || 'normal',
|
||||
variationSettings: declarations['font-variation-settings'] || 'normal',
|
||||
display: declarations['font-display'] || 'auto',
|
||||
ascentOverride: declarations['ascent-override'] || 'normal',
|
||||
descentOverride: declarations['descent-override'] || 'normal',
|
||||
lineGapOverride: declarations['line-gap-override'] || 'normal'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
globalThis.FontFace = class FontFace {
|
||||
constructor(family, source, descriptors={}) {
|
||||
this.family = family;
|
||||
this.style = descriptors.style || 'normal';
|
||||
this.weight = descriptors.weight || 'normal';
|
||||
this.stretch = descriptors.stretch || 'normal';
|
||||
this.unicodeRange = descriptors.unicodeRange || 'U+0-10FFFF';
|
||||
this.variant = descriptors.variant || 'normal';
|
||||
this.featureSettings = descriptors.featureSettings || 'normal';
|
||||
this.status = 'unloaded';
|
||||
if (arguments.length < 2) throw new TypeError('FontFace requires family and source');
|
||||
this._sets = new Set();
|
||||
this._family = _fontFaceString(family);
|
||||
if (!this._family.trim()) throw new DOMException('The font family is empty', 'SyntaxError');
|
||||
const normalizedSource = _fontFaceSource(source);
|
||||
this._source = normalizedSource.css;
|
||||
this._style = _fontFaceDescriptor(descriptors, 'style', 'normal');
|
||||
this._weight = _fontFaceDescriptor(descriptors, 'weight', 'normal');
|
||||
this._stretch = _fontFaceDescriptor(descriptors, 'stretch', 'normal');
|
||||
this._unicodeRange = _fontFaceDescriptor(descriptors, 'unicodeRange', 'U+0-10FFFF');
|
||||
this._variant = _fontFaceDescriptor(descriptors, 'variant', 'normal');
|
||||
this._featureSettings = _fontFaceDescriptor(descriptors, 'featureSettings', 'normal');
|
||||
this._variationSettings = _fontFaceDescriptor(descriptors, 'variationSettings', 'normal');
|
||||
this._display = _fontFaceDescriptor(descriptors, 'display', 'auto');
|
||||
this._ascentOverride = _fontFaceDescriptor(descriptors, 'ascentOverride', 'normal');
|
||||
this._descentOverride = _fontFaceDescriptor(descriptors, 'descentOverride', 'normal');
|
||||
this._lineGapOverride = _fontFaceDescriptor(descriptors, 'lineGapOverride', 'normal');
|
||||
this._status = normalizedSource.binary ? 'loaded' : 'unloaded';
|
||||
this._loadedPromise = normalizedSource.binary ? Promise.resolve(this) : null;
|
||||
}
|
||||
_changed() { for (const set of this._sets) set._faceChanged(this); }
|
||||
_setDescriptor(slot, value) {
|
||||
this[slot] = String(value);
|
||||
this._changed();
|
||||
}
|
||||
get family() { return this._family; }
|
||||
set family(value) { this._setDescriptor('_family', value); }
|
||||
get style() { return this._style; }
|
||||
set style(value) { this._setDescriptor('_style', value); }
|
||||
get weight() { return this._weight; }
|
||||
set weight(value) { this._setDescriptor('_weight', value); }
|
||||
get stretch() { return this._stretch; }
|
||||
set stretch(value) { this._setDescriptor('_stretch', value); }
|
||||
get unicodeRange() { return this._unicodeRange; }
|
||||
set unicodeRange(value) { this._setDescriptor('_unicodeRange', value); }
|
||||
get variant() { return this._variant; }
|
||||
set variant(value) { this._setDescriptor('_variant', value); }
|
||||
get featureSettings() { return this._featureSettings; }
|
||||
set featureSettings(value) { this._setDescriptor('_featureSettings', value); }
|
||||
get variationSettings() { return this._variationSettings; }
|
||||
set variationSettings(value) { this._setDescriptor('_variationSettings', value); }
|
||||
get display() { return this._display; }
|
||||
set display(value) { this._setDescriptor('_display', value); }
|
||||
get ascentOverride() { return this._ascentOverride; }
|
||||
set ascentOverride(value) { this._setDescriptor('_ascentOverride', value); }
|
||||
get descentOverride() { return this._descentOverride; }
|
||||
set descentOverride(value) { this._setDescriptor('_descentOverride', value); }
|
||||
get lineGapOverride() { return this._lineGapOverride; }
|
||||
set lineGapOverride(value) { this._setDescriptor('_lineGapOverride', value); }
|
||||
get status() { return this._status; }
|
||||
get loaded() {
|
||||
if (!this._loadedPromise) {
|
||||
this._loadedPromise = new Promise((resolve, reject) => {
|
||||
this._resolveLoaded = resolve;
|
||||
this._rejectLoaded = reject;
|
||||
});
|
||||
}
|
||||
return this._loadedPromise;
|
||||
}
|
||||
load() {
|
||||
if (this._status === 'loaded') return this.loaded;
|
||||
if (this._status === 'loading') return this.loaded;
|
||||
this._status = 'loading';
|
||||
this._changed();
|
||||
const loaded = this.loaded;
|
||||
Promise.resolve().then(() => {
|
||||
if (this._status !== 'loading') return;
|
||||
this._status = 'loaded';
|
||||
this._resolveLoaded?.(this);
|
||||
this._changed();
|
||||
});
|
||||
return loaded;
|
||||
}
|
||||
load() { this.status = 'loaded'; return Promise.resolve(this); }
|
||||
};
|
||||
|
||||
const _fontFaceSelection = font => {
|
||||
const value = String(font);
|
||||
const size = /(?:^|\s)(?:\d*\.?\d+)(?:px|pt|pc|in|cm|mm|q|em|rem|ex|ch|vw|vh|vmin|vmax|%)(?:\s*\/\s*[^\s]+)?\s+(.+)$/i.exec(value);
|
||||
if (!size) throw new DOMException('Invalid font shorthand', 'SyntaxError');
|
||||
const family = size[1].split(',')[0].trim().replace(/^(['"])(.*)\1$/, '$2').toLowerCase();
|
||||
const prefix = value.slice(0, size.index + size[0].length - size[1].length).toLowerCase();
|
||||
const weight = /\b(?:[1-9]00|bold)\b/.exec(prefix)?.[0] || 'normal';
|
||||
const style = /\b(?:italic|oblique)\b/.exec(prefix)?.[0] || 'normal';
|
||||
return { family, weight: weight === 'bold' ? 700 : weight === 'normal' ? 400 : +weight, style };
|
||||
};
|
||||
const _fontFaceMatches = (face, selection) => {
|
||||
if (face.family.trim().replace(/^(['"])(.*)\1$/, '$2').toLowerCase() !== selection.family) return false;
|
||||
const faceWeight = face.weight.toLowerCase() === 'bold' ? 700 :
|
||||
face.weight.toLowerCase() === 'normal' ? 400 : +(face.weight.split(/\s+/)[0]) || 400;
|
||||
const italic = /^(?:italic|oblique)/i.test(face.style);
|
||||
return Math.abs(faceWeight - selection.weight) < 350 && italic === (selection.style !== 'normal');
|
||||
};
|
||||
|
||||
globalThis.FontFaceSet = class FontFaceSet extends EventTarget {
|
||||
constructor() { super(); this.status = 'loaded'; this.ready = Promise.resolve(this); }
|
||||
add() { return this; }
|
||||
check() { return true; }
|
||||
clear() {}
|
||||
delete() { return false; }
|
||||
load() { return Promise.resolve([]); }
|
||||
forEach() {}
|
||||
has() { return false; }
|
||||
[Symbol.iterator]() { return [][Symbol.iterator](); }
|
||||
constructor(initialFaces=[], ownerDocument=null) {
|
||||
super();
|
||||
this._faces = new Set();
|
||||
this._ownerDocument = ownerDocument;
|
||||
this._cssFaces = new Map();
|
||||
this._status = 'loaded';
|
||||
this._readyPromise = Promise.resolve(this);
|
||||
this.onloading = null;
|
||||
this.onloadingdone = null;
|
||||
this.onloadingerror = null;
|
||||
if (initialFaces != null) for (const face of initialFaces) this.add(face);
|
||||
}
|
||||
get status() { return this._status; }
|
||||
get ready() { return this._readyPromise; }
|
||||
get size() { this._discoverCssFaces(); return this._faces.size; }
|
||||
_discoverCssFaces() {
|
||||
if (!this._ownerDocument) return;
|
||||
const retained = new Set();
|
||||
for (const rule of _fontFaceAuthoredRules(this._ownerDocument)) {
|
||||
const key = JSON.stringify([rule.family, rule.source, rule.descriptors]);
|
||||
retained.add(key);
|
||||
if (this._cssFaces.has(key)) continue;
|
||||
try {
|
||||
const face = new FontFace(rule.family, rule.source, rule.descriptors);
|
||||
face._cssConnected = true;
|
||||
face._sets.add(this);
|
||||
this._cssFaces.set(key, face);
|
||||
this._faces.add(face);
|
||||
} catch (_) {}
|
||||
}
|
||||
for (const [key, face] of this._cssFaces) {
|
||||
if (retained.has(key)) continue;
|
||||
face._sets.delete(this);
|
||||
this._faces.delete(face);
|
||||
this._cssFaces.delete(key);
|
||||
}
|
||||
}
|
||||
_dispatch(type, faces) {
|
||||
const event = new Event(type);
|
||||
event.fontfaces = faces;
|
||||
this.dispatchEvent(event);
|
||||
const handler = this['on' + type];
|
||||
if (typeof handler === 'function') {
|
||||
try { handler.call(this, event); } catch (error) { console.error(error); }
|
||||
}
|
||||
}
|
||||
_syncNative() {
|
||||
if (!this._ownerDocument || typeof Deno.core.ops.op_set_dynamic_fonts !== 'function') return;
|
||||
const registrations = [];
|
||||
for (const face of this._faces) registrations.push({
|
||||
...(face._cssConnected ? { skip: true } : {}),
|
||||
family: face.family,
|
||||
source: face._source,
|
||||
style: face.style,
|
||||
weight: face.weight,
|
||||
unicodeRange: face.unicodeRange
|
||||
});
|
||||
Deno.core.ops.op_set_dynamic_fonts(JSON.stringify(registrations.filter(face => !face.skip)));
|
||||
}
|
||||
_faceChanged(face) {
|
||||
this._syncNative();
|
||||
if (face.status === 'loading' && this._status !== 'loading') {
|
||||
this._status = 'loading';
|
||||
const pending = Array.from(this._faces).filter(candidate => candidate.status === 'loading');
|
||||
this._readyPromise = Promise.all(pending.map(candidate => candidate.loaded)).then(() => {
|
||||
this._status = 'loaded';
|
||||
this._dispatch('loadingdone', Array.from(this._faces));
|
||||
return this;
|
||||
});
|
||||
this._dispatch('loading', [face]);
|
||||
}
|
||||
}
|
||||
add(face) {
|
||||
if (!(face instanceof FontFace)) throw new TypeError('FontFaceSet.add requires a FontFace');
|
||||
this._discoverCssFaces();
|
||||
if (!this._faces.has(face)) {
|
||||
this._faces.add(face);
|
||||
face._sets.add(this);
|
||||
this._syncNative();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
check(font, text=' ') {
|
||||
void String(text);
|
||||
this._discoverCssFaces();
|
||||
const selection = _fontFaceSelection(font);
|
||||
const matches = Array.from(this._faces).filter(face => _fontFaceMatches(face, selection));
|
||||
return matches.length === 0 || matches.every(face => face.status === 'loaded');
|
||||
}
|
||||
clear() {
|
||||
for (const face of Array.from(this._faces)) {
|
||||
if (face._cssConnected) continue;
|
||||
face._sets.delete(this);
|
||||
this._faces.delete(face);
|
||||
}
|
||||
this._syncNative();
|
||||
}
|
||||
delete(face) {
|
||||
this._discoverCssFaces();
|
||||
if (!(face instanceof FontFace) || face._cssConnected || !this._faces.delete(face)) return false;
|
||||
face._sets.delete(this);
|
||||
this._syncNative();
|
||||
return true;
|
||||
}
|
||||
load(font, text=' ') {
|
||||
void String(text);
|
||||
this._discoverCssFaces();
|
||||
const selection = _fontFaceSelection(font);
|
||||
const matches = Array.from(this._faces).filter(face => _fontFaceMatches(face, selection));
|
||||
return Promise.all(matches.map(face => face.load())).then(() => matches);
|
||||
}
|
||||
forEach(callback, thisArg=undefined) {
|
||||
if (typeof callback !== 'function') throw new TypeError('FontFaceSet.forEach callback must be callable');
|
||||
this._discoverCssFaces();
|
||||
for (const face of this._faces) callback.call(thisArg, face, face, this);
|
||||
}
|
||||
has(face) { this._discoverCssFaces(); return this._faces.has(face); }
|
||||
entries() { this._discoverCssFaces(); return Array.from(this._faces, face => [face, face])[Symbol.iterator](); }
|
||||
keys() { this._discoverCssFaces(); return Array.from(this._faces).values(); }
|
||||
values() { this._discoverCssFaces(); return Array.from(this._faces).values(); }
|
||||
[Symbol.iterator]() { return this.values(); }
|
||||
};
|
||||
Object.defineProperty(Document.prototype, 'fonts', {
|
||||
get() {
|
||||
if (!this._fonts) this._fonts = new FontFaceSet();
|
||||
if (!this._fonts) this._fonts = new FontFaceSet([], this);
|
||||
return this._fonts;
|
||||
},
|
||||
configurable: true
|
||||
|
||||
@@ -111,6 +111,11 @@ pub struct ObscuraState {
|
||||
/// relayout of the same document reuses it without refetching.
|
||||
#[cfg(feature = "render")]
|
||||
pub render_resources: obscura_render::RenderResourceCache,
|
||||
/// Script-created faces in this document's `FontFaceSet`. This is separate
|
||||
/// from the DOM so the bridge does not manufacture a selector-visible
|
||||
/// `<style>` element merely to feed the renderer.
|
||||
#[cfg(feature = "render")]
|
||||
pub dynamic_fonts: Vec<obscura_render::DynamicFontFace>,
|
||||
#[cfg(feature = "render")]
|
||||
pub viewport: (f32, f32),
|
||||
/// Root scrolling offset in CSS pixels. With render enabled this is
|
||||
@@ -148,6 +153,8 @@ impl ObscuraState {
|
||||
#[cfg(feature = "render")]
|
||||
render_resources: obscura_render::RenderResourceCache::default(),
|
||||
#[cfg(feature = "render")]
|
||||
dynamic_fonts: Vec::new(),
|
||||
#[cfg(feature = "render")]
|
||||
viewport: (1280.0, 720.0),
|
||||
#[cfg(feature = "render")]
|
||||
scroll_offset: (0.0, 0.0),
|
||||
@@ -2019,6 +2026,65 @@ fn op_url_encode_query(#[string] query: &str, #[string] label: &str, special: bo
|
||||
obscura_net::url_encode_query(query, label, special).unwrap_or_else(|| query.to_string())
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DynamicFontFaceInput {
|
||||
family: String,
|
||||
source: String,
|
||||
style: String,
|
||||
weight: String,
|
||||
unicode_range: String,
|
||||
}
|
||||
|
||||
/// Replace the native snapshot of `document.fonts`. The JS implementation
|
||||
/// remains the source of truth for set semantics; this narrow bridge only
|
||||
/// supplies resource descriptors to the render preparation path.
|
||||
#[cfg(feature = "render")]
|
||||
#[op2(fast)]
|
||||
fn op_set_dynamic_fonts(state: &OpState, #[string] registrations: &str) -> bool {
|
||||
let Ok(inputs) = serde_json::from_str::<Vec<DynamicFontFaceInput>>(registrations) else {
|
||||
return false;
|
||||
};
|
||||
// Keep the observable registry broad enough for generated font families
|
||||
// (large applications commonly register dozens of subset faces). The
|
||||
// renderer independently caps decoded resources after ASCII filtering and
|
||||
// URL deduplication. BufferSource faces arrive as data URLs, so cap their
|
||||
// aggregate descriptor payload as well as each entry.
|
||||
if inputs.len() > 256
|
||||
|| inputs
|
||||
.iter()
|
||||
.try_fold(0usize, |total, face| total.checked_add(face.source.len()))
|
||||
.map_or(true, |total| total > 64 * 1024 * 1024)
|
||||
|| inputs.iter().any(|face| {
|
||||
face.family.len() > 1024
|
||||
|| face.source.len() > 12 * 1024 * 1024
|
||||
|| face.style.len() > 256
|
||||
|| face.weight.len() > 256
|
||||
|| face.unicode_range.len() > 4096
|
||||
})
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let fonts = inputs
|
||||
.into_iter()
|
||||
.map(|face| obscura_render::DynamicFontFace {
|
||||
family: face.family,
|
||||
source: face.source,
|
||||
style: face.style,
|
||||
weight: face.weight,
|
||||
unicode_range: face.unicode_range,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let shared = state.borrow::<SharedState>().clone();
|
||||
let mut state = shared.borrow_mut();
|
||||
if state.dynamic_fonts != fonts {
|
||||
state.dynamic_fonts = fonts;
|
||||
state.prepared_render = None;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn build_extension() -> Extension {
|
||||
let mut ops = vec![
|
||||
op_dom(),
|
||||
@@ -2048,6 +2114,7 @@ pub fn build_extension() -> Extension {
|
||||
// probes with typeof before calling, so the op's absence is a clean fallback.
|
||||
#[cfg(feature = "render")]
|
||||
{
|
||||
ops.push(op_set_dynamic_fonts());
|
||||
ops.push(op_image_metadata());
|
||||
ops.push(op_layout_geometry());
|
||||
ops.push(op_layout_metrics());
|
||||
@@ -2091,11 +2158,12 @@ pub(crate) fn ensure_prepared_render(
|
||||
if stale {
|
||||
let prepared = {
|
||||
let dom = state.dom.as_ref()?;
|
||||
obscura_render::prepare_dom(
|
||||
obscura_render::prepare_dom_with_dynamic_fonts(
|
||||
dom,
|
||||
viewport,
|
||||
base_url.as_deref(),
|
||||
&mut state.render_resources,
|
||||
&state.dynamic_fonts,
|
||||
)?
|
||||
};
|
||||
state.prepared_render = Some(prepared);
|
||||
|
||||
@@ -197,6 +197,7 @@ impl ObscuraJsRuntime {
|
||||
{
|
||||
gs.prepared_render = None;
|
||||
gs.render_resources = obscura_render::RenderResourceCache::default();
|
||||
gs.dynamic_fonts.clear();
|
||||
gs.scroll_offset = (0.0, 0.0);
|
||||
}
|
||||
}
|
||||
@@ -1120,6 +1121,7 @@ impl ObscuraJsRuntime {
|
||||
{
|
||||
state.prepared_render = None;
|
||||
state.render_resources = obscura_render::RenderResourceCache::default();
|
||||
state.dynamic_fonts.clear();
|
||||
}
|
||||
state.dom.take()
|
||||
}
|
||||
@@ -1956,6 +1958,129 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_face_set_tracks_authored_and_script_created_faces() {
|
||||
let mut rt = setup_runtime(
|
||||
r#"<html><head><style>
|
||||
@font-face {
|
||||
font-family: "Authored One";
|
||||
src: url("https://assets.test/one.woff2") format("woff2");
|
||||
font-weight: 350 650;
|
||||
}
|
||||
@font-face {
|
||||
font-family: AuthoredTwo;
|
||||
src: url(data:font/woff2;base64,d09GMg==);
|
||||
font-style: italic;
|
||||
}
|
||||
</style></head><body></body></html>"#,
|
||||
);
|
||||
let result = rt
|
||||
.evaluate(
|
||||
r#"(() => {
|
||||
const authored = Array.from(document.fonts);
|
||||
const cssDelete = document.fonts.delete(authored[0]);
|
||||
const dynamic = new FontFace("Dynamic", "url('/dynamic.ttf')", {
|
||||
style: "oblique 12deg",
|
||||
weight: "700",
|
||||
stretch: "condensed",
|
||||
unicodeRange: "U+20-7E",
|
||||
display: "swap"
|
||||
});
|
||||
const addResult = document.fonts.add(dynamic);
|
||||
const visited = [];
|
||||
document.fonts.forEach((value, key, set) => {
|
||||
visited.push(value === key && set === document.fonts);
|
||||
});
|
||||
const afterAdd = [
|
||||
document.fonts.size,
|
||||
document.fonts.has(dynamic),
|
||||
addResult === document.fonts,
|
||||
dynamic.family,
|
||||
dynamic.style,
|
||||
dynamic.weight,
|
||||
dynamic.stretch,
|
||||
dynamic.unicodeRange,
|
||||
dynamic.display,
|
||||
visited.every(Boolean)
|
||||
];
|
||||
const deleted = document.fonts.delete(dynamic);
|
||||
document.fonts.clear();
|
||||
const bytes = new Uint8Array([0, 1, 2, 253, 254, 255]);
|
||||
const binary = new FontFace("Binary", bytes, { weight: 600 });
|
||||
return [
|
||||
authored.length,
|
||||
authored.map(face => face.family),
|
||||
cssDelete,
|
||||
afterAdd,
|
||||
deleted,
|
||||
document.fonts.size,
|
||||
binary.status,
|
||||
binary.loaded === binary.load()
|
||||
];
|
||||
})()"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
serde_json::json!([
|
||||
2,
|
||||
["Authored One", "AuthoredTwo"],
|
||||
false,
|
||||
[3, true, true, "Dynamic", "oblique 12deg", "700", "condensed", "U+20-7E", "swap", true],
|
||||
true,
|
||||
2,
|
||||
"loaded",
|
||||
true
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn font_face_load_updates_status_set_readiness_and_matching() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
rt.execute_script(
|
||||
"font-face-lifecycle",
|
||||
r#"
|
||||
globalThis.__fontEvents = [];
|
||||
const face = new FontFace("Lifecycle", "url('/lifecycle.woff2')", {
|
||||
weight: "700"
|
||||
});
|
||||
document.fonts.onloading = event => __fontEvents.push([event.type, event.fontfaces.length]);
|
||||
document.fonts.onloadingdone = event => __fontEvents.push([event.type, event.fontfaces.length]);
|
||||
document.fonts.add(face);
|
||||
globalThis.__fontBefore = [
|
||||
face.status,
|
||||
document.fonts.status,
|
||||
document.fonts.check("700 16px Lifecycle")
|
||||
];
|
||||
globalThis.__fontLoadResult = "pending";
|
||||
document.fonts.load("700 16px Lifecycle").then(faces => {
|
||||
__fontLoadResult = [faces.length, faces[0] === face, face.status,
|
||||
document.fonts.check("700 16px Lifecycle")];
|
||||
});
|
||||
document.fonts.ready.then(set => {
|
||||
globalThis.__fontReady = set === document.fonts;
|
||||
});
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
rt.run_event_loop_bounded(100).await.unwrap();
|
||||
let result = rt
|
||||
.evaluate(
|
||||
"return [__fontBefore, __fontLoadResult, __fontReady, __fontEvents];",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
serde_json::json!([
|
||||
["unloaded", "loaded", false],
|
||||
[1, true, "loaded", true],
|
||||
true,
|
||||
[["loading", 1], ["loadingdone", 1]]
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animation_frame_requires_a_callable_callback() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
@@ -3154,6 +3279,66 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[test]
|
||||
fn script_registered_url_font_reaches_render_resource_collection() {
|
||||
let loads = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
|
||||
let loader_loads = std::sync::Arc::clone(&loads);
|
||||
let font = include_bytes!("../../obscura-render/assets/liberation-serif.ttf").to_vec();
|
||||
let mut rt = parser_image_runtime(
|
||||
r#"<html><body style="margin:0">
|
||||
<span id="sample" style="display:inline-block;width:max-content;
|
||||
font-family:DynamicFixture;font-size:40px;white-space:nowrap">WWWWiiii</span>
|
||||
</body></html>"#,
|
||||
move |url: &str| {
|
||||
loader_loads.lock().expect("font loads").push(url.to_string());
|
||||
(url == "http://example.com/fonts/dynamic.ttf").then(|| font.clone())
|
||||
},
|
||||
);
|
||||
rt.set_viewport(400.0, 100.0);
|
||||
let before = rt
|
||||
.evaluate("document.getElementById('sample').getBoundingClientRect().width")
|
||||
.unwrap()
|
||||
.as_f64()
|
||||
.expect("fallback width");
|
||||
assert!(loads.lock().expect("initial loads").is_empty());
|
||||
|
||||
let registered = rt
|
||||
.evaluate(
|
||||
r#"(() => {
|
||||
const face = new FontFace("DynamicFixture",
|
||||
"url('../fonts/dynamic.ttf') format('truetype')",
|
||||
{ weight: "normal", style: "normal", unicodeRange: "U+20-7E" });
|
||||
return [document.fonts.add(face) === document.fonts,
|
||||
document.fonts.size, document.fonts.has(face)];
|
||||
})()"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(registered, serde_json::json!([true, 1, true]));
|
||||
{
|
||||
let state = rt.state.borrow();
|
||||
assert_eq!(state.dynamic_fonts.len(), 1);
|
||||
assert!(
|
||||
state.prepared_render.is_none(),
|
||||
"changing the document font registry must invalidate prepared text geometry"
|
||||
);
|
||||
}
|
||||
|
||||
let after = rt
|
||||
.evaluate("document.getElementById('sample').getBoundingClientRect().width")
|
||||
.unwrap()
|
||||
.as_f64()
|
||||
.expect("dynamic font width");
|
||||
assert_ne!(before, after, "registered face must affect final text geometry");
|
||||
assert_eq!(
|
||||
*loads.lock().expect("dynamic font loads"),
|
||||
vec!["http://example.com/fonts/dynamic.ttf".to_string()]
|
||||
);
|
||||
rt.screenshot_prepared((400.0, 100.0), Some("http://example.com/page/index.html"))
|
||||
.expect("dynamic font screenshot");
|
||||
assert_eq!(loads.lock().expect("repeated font loads").len(), 1);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[test]
|
||||
fn rendered_layout_cache_is_invalidated_by_style_mutations() {
|
||||
|
||||
@@ -28,9 +28,9 @@ pub use dom::{
|
||||
mod paint;
|
||||
#[cfg(feature = "paint")]
|
||||
pub use paint::{
|
||||
paint_dom, paint_dom_scrolled, paint_prepared, prepare_dom, screenshot_png,
|
||||
screenshot_png_scrolled, screenshot_prepared, PreparedRender, RenderResourceCache,
|
||||
RenderResourceLoader, SelectedImage,
|
||||
paint_dom, paint_dom_scrolled, paint_prepared, prepare_dom, prepare_dom_with_dynamic_fonts,
|
||||
screenshot_png, screenshot_png_scrolled, screenshot_prepared, DynamicFontFace,
|
||||
PreparedRender, RenderResourceCache, RenderResourceLoader, SelectedImage,
|
||||
};
|
||||
|
||||
// Real inline text layout (cosmic-text) lives behind the paint feature; the
|
||||
@@ -467,6 +467,12 @@ pub struct LayoutStyle {
|
||||
/// alignment keywords. Kept separate from flex/grid `align-items`: using
|
||||
/// one field for both made `text-align:left` shrink-wrap flex children.
|
||||
pub text_align: Option<taffy::AlignItems>,
|
||||
/// Computed inherited `text-indent`. Font/viewport-relative lengths are
|
||||
/// resolved to pixels during the top-down inheritance pass; percentages
|
||||
/// remain typed until the final inline-formatting-context width is known.
|
||||
/// `None` during cascade means inherit, while the root resolves to the
|
||||
/// initial zero length.
|
||||
pub text_indent: Option<Dimension>,
|
||||
pub align_items: Option<taffy::AlignItems>,
|
||||
pub justify_items: Option<taffy::JustifyItems>,
|
||||
pub align_self: Option<taffy::AlignSelf>,
|
||||
|
||||
@@ -31,6 +31,19 @@ pub trait RenderResourceLoader {
|
||||
fn load(&mut self, url: &str) -> Option<Vec<u8>>;
|
||||
}
|
||||
|
||||
/// One script-created `FontFace` registered with the document's
|
||||
/// `FontFaceSet`. The JS runtime owns the registry and supplies this snapshot
|
||||
/// alongside the DOM when preparing a render; keeping it out of the DOM avoids
|
||||
/// exposing an implementation-only `<style>` element to page selectors.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct DynamicFontFace {
|
||||
pub family: String,
|
||||
pub source: String,
|
||||
pub style: String,
|
||||
pub weight: String,
|
||||
pub unicode_range: String,
|
||||
}
|
||||
|
||||
impl<F> RenderResourceLoader for F
|
||||
where
|
||||
F: FnMut(&str) -> Option<Vec<u8>>,
|
||||
@@ -415,6 +428,20 @@ pub fn prepare_dom(
|
||||
viewport: (f32, f32),
|
||||
base_url: Option<&str>,
|
||||
resources: &mut RenderResourceCache,
|
||||
) -> Option<PreparedRender> {
|
||||
prepare_dom_with_dynamic_fonts(tree, viewport, base_url, resources, &[])
|
||||
}
|
||||
|
||||
/// Prepare a DOM with the document's script-created `FontFace` registrations.
|
||||
///
|
||||
/// Authored rules and dynamic faces deliberately share the same bounded
|
||||
/// resource cache, decoder, descriptor matching, and shaping database.
|
||||
pub fn prepare_dom_with_dynamic_fonts(
|
||||
tree: &DomTree,
|
||||
viewport: (f32, f32),
|
||||
base_url: Option<&str>,
|
||||
resources: &mut RenderResourceCache,
|
||||
dynamic_fonts: &[DynamicFontFace],
|
||||
) -> Option<PreparedRender> {
|
||||
if !viewport.0.is_finite()
|
||||
|| !viewport.1.is_finite()
|
||||
@@ -429,7 +456,7 @@ pub fn prepare_dom(
|
||||
// each URL is still fetched at most once.
|
||||
let (mut intrinsic, mut selected_images) =
|
||||
collect_image_intrinsics(tree, viewport, base_url, resources);
|
||||
let fonts = collect_web_fonts(tree, base_url, resources);
|
||||
let fonts = collect_web_fonts(tree, base_url, resources, dynamic_fonts);
|
||||
// Most framework pages use web fonts and many decorative SVG icons, but
|
||||
// only SVG text needs the page font faces. Avoid cloning/loading the page
|
||||
// font database for ordinary icons and HTML-only text.
|
||||
@@ -2064,6 +2091,7 @@ fn collect_web_fonts(
|
||||
tree: &DomTree,
|
||||
base_url: Option<&str>,
|
||||
cache: &mut RenderResourceCache,
|
||||
dynamic_fonts: &[DynamicFontFace],
|
||||
) -> Vec<crate::inline::WebFont> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut fonts = Vec::new();
|
||||
@@ -2095,6 +2123,25 @@ fn collect_web_fonts(
|
||||
));
|
||||
}
|
||||
}
|
||||
for face in dynamic_fonts {
|
||||
let descriptor_block = format!(
|
||||
"src:{};font-weight:{};font-style:{};unicode-range:{}",
|
||||
face.source, face.weight, face.style, face.unicode_range
|
||||
);
|
||||
if !font_face_covers_ascii(&descriptor_block) {
|
||||
continue;
|
||||
}
|
||||
let Some(src) = font_face_urls(&descriptor_block).into_iter().next() else {
|
||||
continue;
|
||||
};
|
||||
rules.push((
|
||||
font_resource_key(&src, base_url),
|
||||
src,
|
||||
(!face.family.is_empty()).then(|| face.family.clone()),
|
||||
font_face_weight(&descriptor_block),
|
||||
font_face_italic(&descriptor_block),
|
||||
));
|
||||
}
|
||||
|
||||
// Critical web fonts are normally preloaded from the document with a URL
|
||||
// already resolved relative to the HTML. Fetch those first, while retaining
|
||||
@@ -5220,6 +5267,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_text_indent_clips_label_without_hiding_background_icon() {
|
||||
let tree = parse_html(
|
||||
r#"<html><body style="margin:0">
|
||||
<a style="display:block;width:24px;height:24px;overflow:hidden;
|
||||
white-space:nowrap;text-indent:-9999px;color:black;background-color:blue;
|
||||
background-image:url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='16'%20height='16'%3E%3Crect%20width='16'%20height='16'%20fill='red'/%3E%3C/svg%3E");
|
||||
background-position:center;background-size:16px 16px;background-repeat:no-repeat">Bluesky (@mozilla.org)</a>
|
||||
</body></html>"#,
|
||||
);
|
||||
let pixmap = paint_dom(&tree, (32.0, 32.0), None).expect("pixmap");
|
||||
let corner = pixmap.pixel(1, 1).expect("anchor background");
|
||||
assert!(
|
||||
corner.blue() > 200 && corner.red() < 40 && corner.green() < 40,
|
||||
"the anchor background must remain visible: {corner:?}"
|
||||
);
|
||||
let icon = pixmap.pixel(12, 12).expect("centered SVG icon");
|
||||
assert!(
|
||||
icon.red() > 200 && icon.green() < 40 && icon.blue() < 40,
|
||||
"the background SVG must paint independently of indented text: {icon:?}"
|
||||
);
|
||||
for y in 0..24 {
|
||||
for x in 0..24 {
|
||||
let pixel = pixmap.pixel(x, y).expect("anchor pixel");
|
||||
assert!(
|
||||
pixel.red() > 40 || pixel.green() > 40 || pixel.blue() > 40,
|
||||
"black label glyph leaked through the 24px overflow clip at ({x},{y})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_background_size_preserves_auto_axis_ratio() {
|
||||
let source = "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='200'%20height='50'%3E%3C/svg%3E";
|
||||
@@ -6345,4 +6424,56 @@ mod tests {
|
||||
assert!(font_face_covers_ascii(face));
|
||||
assert_eq!(font_face_urls(face), vec!["example.otf"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_font_face_uses_shared_fetch_decode_and_layout_path() {
|
||||
let tree = parse_html(
|
||||
r#"<html><body><span id="sample" style="display:inline-block;width:max-content;
|
||||
font:40px DynamicFixture;white-space:nowrap">WWWWiiii</span></body></html>"#,
|
||||
);
|
||||
let sample = tree
|
||||
.query_selector("#sample")
|
||||
.expect("valid selector")
|
||||
.expect("sample");
|
||||
let fallback = prepare_dom(
|
||||
&tree,
|
||||
(400.0, 100.0),
|
||||
Some("https://example.test/page/index.html"),
|
||||
&mut RenderResourceCache::default(),
|
||||
)
|
||||
.expect("fallback render")
|
||||
.document_rect(sample)
|
||||
.expect("fallback geometry")
|
||||
.width;
|
||||
let loads = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let loader_loads = Arc::clone(&loads);
|
||||
let mut resources = RenderResourceCache::with_loader(move |url: &str| {
|
||||
loader_loads.lock().expect("font loads").push(url.to_string());
|
||||
(url == "https://example.test/fonts/fixture.ttf")
|
||||
.then(|| SERIF_FONT_BYTES.to_vec())
|
||||
});
|
||||
let prepared = prepare_dom_with_dynamic_fonts(
|
||||
&tree,
|
||||
(400.0, 100.0),
|
||||
Some("https://example.test/page/index.html"),
|
||||
&mut resources,
|
||||
&[DynamicFontFace {
|
||||
family: "DynamicFixture".to_string(),
|
||||
source: "url('../../fonts/fixture.ttf') format('truetype')".to_string(),
|
||||
style: "normal".to_string(),
|
||||
weight: "400".to_string(),
|
||||
unicode_range: "U+20-7E".to_string(),
|
||||
}],
|
||||
)
|
||||
.expect("dynamic font render");
|
||||
let dynamic = prepared
|
||||
.document_rect(sample)
|
||||
.expect("dynamic geometry")
|
||||
.width;
|
||||
assert_ne!(fallback, dynamic, "dynamic face must participate in shaping");
|
||||
assert_eq!(
|
||||
*loads.lock().expect("dynamic font loads"),
|
||||
vec!["https://example.test/fonts/fixture.ttf".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user