From 5c34e74f10699ee0fefa71e5d575983e911f4539 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Fri, 31 Jul 2026 03:31:21 +0000 Subject: [PATCH] Connect document font faces to rendering --- crates/obscura-js/js/bootstrap.js | 322 +++++++++++++++++++++++++++-- crates/obscura-js/src/ops.rs | 70 ++++++- crates/obscura-js/src/runtime.rs | 185 +++++++++++++++++ crates/obscura-render/src/lib.rs | 12 +- crates/obscura-render/src/paint.rs | 133 +++++++++++- 5 files changed, 698 insertions(+), 24 deletions(-) diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 6a6c818..9b4611c 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -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 diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index df2eaa4..ee607c5 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -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 + /// `"#, + ); + 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(""); + 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(""); @@ -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::::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#" + WWWWiiii + "#, + 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() { diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index e7fc16c..37a7a78 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -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, + /// 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, pub align_items: Option, pub justify_items: Option, pub align_self: Option, diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index b3f4f61..b5ef095 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -31,6 +31,19 @@ pub trait RenderResourceLoader { fn load(&mut self, url: &str) -> Option>; } +/// 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 `