Connect document font faces to rendering

This commit is contained in:
SGavrl committed 2026-07-31 03:31:21 +00:00
1 parent 0acf4e1b06
commit 5c34e74f10
5 files changed
+698 -24

No files matched your search

+303 -19
View File
@@ -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