dropflow
Version:
A small CSS2 document renderer built from specifications
1,075 lines (1,074 loc) • 39.3 kB
JavaScript
import * as hb from './text-harfbuzz.js';
import langCoverage from '../gen/lang-script-coverage.js';
import wasm from './wasm.js';
import { HbSet, hb_tag, HB_OT_TAG_GSUB, HB_OT_TAG_GPOS, HB_OT_LAYOUT_DEFAULT_LANGUAGE_INDEX } from './text-harfbuzz.js';
import { environment } from './environment.js';
import { nameToCode, tagToCode } from '../gen/script-names.js';
import { Deferred } from './util.js';
// See FcStrContainsIgnoreCase in fcstr.c
function strContainsIgnoreCase(s1, s2) {
return s1.replace(/ /g, '').toLowerCase().indexOf(s2) > -1;
}
// See FcContainsWeight in fcfreetype.c
function containsWeight(s) {
if (strContainsIgnoreCase(s, 'thin'))
return 100;
if (strContainsIgnoreCase(s, 'extralight'))
return 200;
if (strContainsIgnoreCase(s, 'ultralight'))
return 200;
if (strContainsIgnoreCase(s, 'demilight'))
return 350;
if (strContainsIgnoreCase(s, 'semilight'))
return 350;
if (strContainsIgnoreCase(s, 'light'))
return 300;
if (strContainsIgnoreCase(s, 'book'))
return 380;
if (strContainsIgnoreCase(s, 'regular'))
return 400;
if (strContainsIgnoreCase(s, 'normal'))
return 400;
if (strContainsIgnoreCase(s, 'medium'))
return 500;
if (strContainsIgnoreCase(s, 'demibold'))
return 600;
if (strContainsIgnoreCase(s, 'demi'))
return 600;
if (strContainsIgnoreCase(s, 'semibold'))
return 600;
if (strContainsIgnoreCase(s, 'extrabold'))
return 800;
if (strContainsIgnoreCase(s, 'superbold'))
return 800;
if (strContainsIgnoreCase(s, 'ultrabold'))
return 800;
if (strContainsIgnoreCase(s, 'bold'))
return 700;
if (strContainsIgnoreCase(s, 'ultrablack'))
return 1000;
if (strContainsIgnoreCase(s, 'superblack'))
return 1000;
if (strContainsIgnoreCase(s, 'extrablack'))
return 1000;
// TODO ultra?
if (strContainsIgnoreCase(s, 'black'))
return 900;
if (strContainsIgnoreCase(s, 'heavy'))
return 900;
}
// See FcContainsWidth in fcfreetype.c
function containsStretch(s) {
if (strContainsIgnoreCase(s, 'ultracondensed'))
return 'ultra-condensed';
if (strContainsIgnoreCase(s, 'extracondensed'))
return 'extra-condensed';
if (strContainsIgnoreCase(s, 'semicondensed'))
return 'semi-condensed';
if (strContainsIgnoreCase(s, 'condensed'))
return 'condensed';
if (strContainsIgnoreCase(s, 'normal'))
return 'normal';
if (strContainsIgnoreCase(s, 'semiexpanded'))
return 'semi-expanded';
if (strContainsIgnoreCase(s, 'ultraexpanded'))
return 'ultra-expanded';
if (strContainsIgnoreCase(s, 'expanded'))
return 'expanded';
return 'normal';
}
// See FcContainsSlant in fcfreetype.c
function containsSlant(s) {
if (strContainsIgnoreCase(s, 'italic'))
return 'italic';
if (strContainsIgnoreCase(s, 'kursiv'))
return 'italic';
if (strContainsIgnoreCase(s, 'oblique'))
return 'oblique';
return 'normal';
}
const defaultFeatures = new Set([
hb_tag('abvf'),
hb_tag('abvs'),
hb_tag('akhn'),
hb_tag('blwf'),
hb_tag('blws'),
hb_tag('calt'),
hb_tag('ccmp'),
hb_tag('cfar'),
hb_tag('cjct'),
hb_tag('clig'),
hb_tag('fin2'),
hb_tag('fin3'),
hb_tag('fina'),
hb_tag('half'),
hb_tag('haln'),
hb_tag('init'),
hb_tag('isol'),
hb_tag('liga'),
hb_tag('ljmo'),
hb_tag('locl'),
hb_tag('ltra'),
hb_tag('ltrm'),
hb_tag('med2'),
hb_tag('medi'),
hb_tag('mset'),
hb_tag('nukt'),
hb_tag('pref'),
hb_tag('pres'),
hb_tag('pstf'),
hb_tag('psts'),
hb_tag('rclt'),
hb_tag('rlig'),
hb_tag('rkrf'),
hb_tag('rphf'),
hb_tag('rtla'),
hb_tag('rtlm'),
hb_tag('tjmo'),
hb_tag('vatu'),
hb_tag('vert'),
hb_tag('vjmo')
]);
const kerningFeatures = new Set([
hb_tag('kern')
]);
const UninitializedSpaceFeatures = 0xff;
const NoSpaceFeatures = 0;
const HasSpaceFeatures = 1 << 0;
const KerningSpaceFeatures = 1 << 1;
const NonKerningSpaceFeatures = 1 << 2;
let uniqueFamily = 1;
export class LoadedFontFace {
data;
allocated;
hbface;
hbfont;
/**
* The family name referenced within dropflow and read during font matching
*/
family;
style;
weight;
stretch;
variant;
languages;
/**
* A globally unique family name. Used like a handle when interacting with the
* render target, such as the first argument to the browser's FontFace and as
* the font string given to ctx.font
*/
uniqueFamily;
/**
* Only for logging. When users register an ArrayBuffer, this is
* anon://family-weight-style
*/
url;
spaceFeatures;
defaultSubSpaceFeatures;
nonDefaultSubSpaceFeatures;
onDestroy;
_createHb(data, url) {
const blob = hb.createBlob(new Uint8Array(data));
if (blob.countFaces() !== 1) {
blob.destroy();
if (url) {
throw new SyntaxError(`Error reading font ${url}`);
}
else {
throw new SyntaxError('Error reading font');
}
}
const hbface = hb.createFace(blob, 0);
const hbfont = hb.createFont(hbface);
blob.destroy();
return { hbface, hbfont };
}
_createUrl(desc) {
const family = encodeURI(desc.family.replaceAll(' ', '-'));
return new URL(`anon://${family}-${desc.weight}-${desc.style}`);
}
constructor(data, face, url) {
this.data = data;
this.allocated = true;
if (!url && face)
url = this._createUrl(face);
const { hbface, hbfont } = this._createHb(data, url);
this.hbface = hbface;
this.hbfont = hbfont;
const desc = face || this.describeSelfFromTables();
this.family = desc.family;
this.style = desc.style;
this.weight = desc.weight;
this.stretch = desc.stretch;
this.variant = desc.variant;
this.languages = 'languages' in desc ? desc.languages : this.getLanguages();
this.uniqueFamily = `${this.family}_${String(uniqueFamily++).padStart(4, '0')}`;
if (!url)
url = this._createUrl(this);
this.url = url;
this.spaceFeatures = UninitializedSpaceFeatures;
this.defaultSubSpaceFeatures = new Uint32Array(Math.ceil(nameToCode.size / 32));
this.nonDefaultSubSpaceFeatures = new Uint32Array(Math.ceil(nameToCode.size / 32));
}
/**
* Ensures HbFace and HbFont instances. This gets called synchronously (by the
* ctor) when the font is first loaded for proper error handling, and gets
* called again when it's added to the "font face source" (`flow.fonts`).
*/
allocate() {
if (!this.allocated) {
const { hbface, hbfont } = this._createHb(this.data);
this.hbface = hbface;
this.hbfont = hbfont;
this.allocated = true;
}
}
/**
* Deallocates HbFace and HbFont instances. This gets called when the font is
* removed from the "font face source" (`flow.fonts`) and whenever the
* FontFace is GC'd, via FinalizationRegistry. We could only do the latter,
* but GC performs much better if we don't wait for the FinalizationRegistry.
*/
deallocate() {
if (this.allocated) {
this.onDestroy?.();
this.hbface.destroy();
this.hbfont.destroy();
this.allocated = false;
}
}
getExclusiveLanguage() {
const os2 = this.hbface.referenceTable('OS/2');
const buffer = os2.getData();
const words = new Uint16Array(buffer);
const [version] = words;
if (version === 1 || version === 2 || version === 3 || version == 4 || version === 5) {
const codePageRange1 = buffer[78 /* bytes */ / 2];
const bits17to20 = codePageRange1 & 0x1E0000;
if ((codePageRange1 & (1 << 17)) === bits17to20)
return 'ja';
if ((codePageRange1 & (1 << 18)) === bits17to20)
return 'zh-cn';
if ((codePageRange1 & (1 << 19)) === bits17to20)
return 'ko';
if ((codePageRange1 & (1 << 20)) === bits17to20)
return 'zh-tw';
}
os2.destroy();
}
static isExclusiveLang(lang) {
// Fontconfig says: Keep Han languages separated by eliminating languages
// that the codePageRange bits says aren't supported
return lang === 'ja' || lang === 'zh-cn' || lang === 'ko' || lang === 'zh-tw';
}
getLanguages() {
const fontCoverage = this.hbface.collectUnicodes();
const langs = new Set();
const exclusiveLang = this.getExclusiveLanguage();
if (exclusiveLang)
langs.add(exclusiveLang);
for (const lang of langCoverage) {
// Fontconfig says: Check for Han charsets to make fonts which advertise
// support for a single language not support other Han languages
if (exclusiveLang && LoadedFontFace.isExclusiveLang(lang) && lang !== exclusiveLang) {
continue;
}
const heapu32 = new Uint32Array(wasm.instance.exports.memory.buffer);
const setPtr = heapu32[wasm.instance.exports[lang + '_coverage'].value / 4];
const testSet = new HbSet(setPtr).copy();
testSet.subtract(fontCoverage);
if (testSet.getPopulation() === 0)
langs.add(lang);
testSet.destroy();
}
fontCoverage.destroy();
return langs;
}
describeSelfFromTables() {
const subfamily = this.hbface.getName(17, 'en') || this.hbface.getName(2, 'en');
const family = this.hbface.getName(16, 'en') || this.hbface.getName(1, 'en');
const languages = this.getLanguages();
let weight = containsWeight(subfamily);
if (!weight)
weight = this.hbfont.getStyle('wght');
let style = containsSlant(subfamily);
if (!style) {
const italic = this.hbfont.getStyle('ital') !== 0;
const slant = this.hbfont.getStyle('slnt');
style = italic ? 'italic' : slant ? 'oblique' : 'normal';
}
let stretch = containsStretch(subfamily);
if (!stretch)
stretch = 'normal';
return { family, weight, style, stretch, variant: 'normal', languages };
}
getBuffer() {
const blob = this.hbface.referenceBlob();
const data = blob.getData();
blob.destroy();
return data;
}
getLookupsByLangScript(table, scriptIndex, langIndex, specificFeatures, specificLookups, otherLookups) {
const featureIndexes = this.hbface.getFeatureIndexes(table, scriptIndex, langIndex);
const featureTags = this.hbface.getFeatureTags(table, scriptIndex, langIndex);
// TODO a quick look at the HarfBuzz source makes me think this is already
// returned in hb_ot_layout_language_get_feature_indexes, but Firefox makes
// this call
const requiredIndex = this.hbface.getRequiredFeatureIndex(table, scriptIndex, langIndex);
if (requiredIndex > -1)
featureIndexes.push(requiredIndex);
for (let i = 0; i < featureIndexes.length; i++) {
const set = specificFeatures.has(featureTags[i]) ? specificLookups : otherLookups;
this.hbface.getLookupsByFeature(table, featureIndexes[i], set);
}
}
hasLookupRuleWithGlyphByScript(table, scriptIndex, glyph, specificFeatures, stopAfterSpecificFound = true) {
const numLangs = this.hbface.getNumLangsForScript(table, scriptIndex);
const specificLookups = hb.createSet();
const otherLookups = hb.createSet();
const glyphs = hb.createSet();
let inSpecific = false;
let inNonSpecific = false;
this.getLookupsByLangScript(table, scriptIndex, HB_OT_LAYOUT_DEFAULT_LANGUAGE_INDEX, specificFeatures, specificLookups, otherLookups);
for (let langIndex = 0; langIndex < numLangs; langIndex++) {
this.getLookupsByLangScript(table, scriptIndex, langIndex, specificFeatures, specificLookups, otherLookups);
}
for (const lookupIndex of specificLookups) {
this.hbface.collectGlyphs(table, lookupIndex, glyphs, glyphs, glyphs);
if (glyphs.has(glyph)) {
inSpecific = true;
break;
}
}
if (!stopAfterSpecificFound || !inSpecific) {
glyphs.clear();
for (const lookupIndex of otherLookups) {
this.hbface.collectGlyphs(table, lookupIndex, glyphs, glyphs, glyphs);
if (glyphs.has(glyph)) {
inNonSpecific = true;
break;
}
}
}
specificLookups.destroy();
otherLookups.destroy();
glyphs.destroy();
return { inSpecific, inNonSpecific };
}
checkForFeaturesInvolvingSpace() {
this.spaceFeatures = NoSpaceFeatures;
if (this.hbfont.getNominalGlyph(32)) {
const spaceGlyph = this.hbfont.getNominalGlyph(32);
const scripts = this.hbface.getScripts();
if (this.hbface.hasSubstitution()) {
for (let scriptIndex = 0; scriptIndex < scripts.length; scriptIndex++) {
const { inSpecific, inNonSpecific } = this.hasLookupRuleWithGlyphByScript(HB_OT_TAG_GSUB, scriptIndex, spaceGlyph, defaultFeatures);
if (inSpecific || inNonSpecific) {
const scriptCode = tagToCode.get(scripts[scriptIndex]) || 0;
const map = inSpecific
? this.defaultSubSpaceFeatures
: this.nonDefaultSubSpaceFeatures;
this.spaceFeatures |= HasSpaceFeatures;
map[scriptCode >>> 5] |= (1 << (scriptCode & 31));
}
}
}
if (this.hbface.hasPositioning() &&
!this.hasSubstitution(this.defaultSubSpaceFeatures, 0)) {
let inKerning = false;
let inNonKerning = false;
for (let scriptIndex = 0; scriptIndex < scripts.length; scriptIndex++) {
const { inSpecific, inNonSpecific } = this.hasLookupRuleWithGlyphByScript(HB_OT_TAG_GPOS, scriptIndex, spaceGlyph, kerningFeatures, false);
inKerning = inKerning || inSpecific;
inNonKerning = inNonKerning || inNonSpecific;
if (inKerning && inNonKerning)
break;
}
if (inKerning) {
this.spaceFeatures |= HasSpaceFeatures | KerningSpaceFeatures;
}
if (inNonKerning) {
this.spaceFeatures |= HasSpaceFeatures | NonKerningSpaceFeatures;
}
}
}
}
hasSubstitution(map, scriptCode) {
return map[scriptCode >>> 5] & (1 << (scriptCode & 31));
}
hasSubstitutionRulesWithSpaceLookups(scriptCode) {
if (this.hasSubstitution(this.defaultSubSpaceFeatures, scriptCode) ||
this.hasSubstitution(this.defaultSubSpaceFeatures, 0))
return true;
// TODO also check nonDefaultSubSpaceFeatures, but only when non-default
// font features are set, which isn't yet possible
return false;
}
spaceMayParticipateInShaping(script) {
const scriptCode = nameToCode.get(script) || 0;
if (this.spaceFeatures === UninitializedSpaceFeatures) {
this.checkForFeaturesInvolvingSpace();
}
if (!(this.spaceFeatures & HasSpaceFeatures))
return false;
if (this.hasSubstitutionRulesWithSpaceLookups(scriptCode) ||
(this.spaceFeatures & NonKerningSpaceFeatures))
return true;
// TOOD: return this.spaceFeatures & KerningSpaceFeatures if kerning is
// explicitly enabled, which isn't yet possible
return false;
}
toFontString(size) {
return `${size}px ${this.uniqueFamily}`;
}
}
function externallyRegisterFont(face) {
const cb = environment.registerFont(face);
if (typeof cb === 'function')
face.onDestroy = cb;
}
const faceToLoaded = new WeakMap();
// Currently everything is designed for only one instance of FontFaceSet since
// only one browser lets you have more than one. The spec allows you to create
// as many as you want, which could be cool. The tight coupling would require
// several changes to the implementations here and in FontFace, but it wouldn't
// be difficult.
class FontFaceSet {
#loading = new Set();
#loaded = new Set();
#failed = new Set();
#faces = new Set();
#ready = new Deferred;
status = 'loaded';
[Symbol.iterator]() {
return this.#faces.values();
}
get ready() {
return this.#ready.promise;
}
has(face) {
return this.#faces.has(face);
}
add(face) {
if (this.#faces.add(face)) {
langCascade = undefined;
urangeCascade = undefined;
if (face.status === 'loading')
this._onLoading(face);
const loaded = faceToLoaded.get(face);
if (loaded) {
loaded.allocate();
externallyRegisterFont(loaded);
}
}
return this;
}
/** @internal */
_switchToLoaded() {
this.status = 'loaded';
this.#ready.resolve(this);
this.#loaded.clear();
this.#failed.clear();
}
delete(face) {
if (this.#faces.delete(face)) {
faceToLoaded.get(face)?.deallocate();
faceToLoaded.delete(face);
this.#loaded.delete(face);
this.#failed.delete(face);
if (this.#loading.delete(face) && this.#loading.size === 0) {
this._switchToLoaded();
}
langCascade = undefined;
urangeCascade = undefined;
return true;
}
return false;
}
clear() {
for (const face of this.#faces)
faceToLoaded.get(face)?.deallocate();
this.#faces.clear();
this.#loaded.clear();
this.#failed.clear();
langCascade = undefined;
urangeCascade = undefined;
if (this.#loading.size !== 0) {
this.#loading.clear();
this._switchToLoaded();
}
}
/** @internal */
_onLoading(face) {
if (this.has(face)) {
if (this.#loading.size === 0) {
this.status = 'loading';
if (this.#ready.status !== 'unresolved') {
this.#ready = new Deferred();
}
}
this.#loading.add(face);
}
}
/** @internal */
_onLoaded(face, loaded) {
if (this.has(face)) {
langCascade = undefined;
this.#loaded.add(face);
if (this.#loading.delete(face)) {
if (this.#loading.size === 0)
this._switchToLoaded();
externallyRegisterFont(loaded);
}
}
}
/** @internal */
_onError(face) {
if (this.has(face)) {
this.#failed.add(face);
if (this.#loading.delete(face) && this.#loading.size === 0) {
this._switchToLoaded();
}
}
}
}
const loadedFaceRegistry = new FinalizationRegistry(f => f.deallocate());
let __font_face_skip_ctor_load = false;
function isWhitespace(c) {
return c === ' ' || c === '\t' || c === '\r' || c === '\n' || c === '\f';
}
function parseHex(s, i, len) {
let v = 0;
for (; len > 0; len--, i++) {
const c = s[i];
if (c === '?')
v = v << 4;
else if (c >= '0' && c <= '9')
v = (v << 4) | (c.charCodeAt(0) - 48);
else if (c >= 'a' && c <= 'f')
v = (v << 4) | (c.charCodeAt(0) - 87);
else if (c >= 'A' && c <= 'F')
v = (v << 4) | (c.charCodeAt(0) - 55);
else
throw new SyntaxError('Invalid hex digit in unicode-range');
}
return v;
}
function parseUnicodeRange(range, set) {
let i = 0;
const len = range.length;
while (i < len) {
while (i < len && isWhitespace(range[i]))
i++;
if (i === len)
break;
const c = range[i];
if (c !== 'u' && c !== 'U')
throw new SyntaxError('Expected u+ or U+ in unicode-range');
i++;
if (range[i] !== '+')
throw new SyntaxError('Expected + after u in unicode-range');
i++;
// Count hex digits and question marks
let hexLen = 0, qLen = 0;
let j = i;
while (j < len && hexLen < 6 && ((range[j] >= '0' && range[j] <= '9') ||
(range[j] >= 'a' && range[j] <= 'f') ||
(range[j] >= 'A' && range[j] <= 'F'))) {
hexLen++;
j++;
}
while (j < len && qLen < 5 && range[j] === '?') {
qLen++;
j++;
}
if (!hexLen || hexLen + qLen > 6)
throw new SyntaxError('Invalid hex digits in unicode-range');
// Parse single value or range
const start = parseHex(range, i, hexLen);
i = j;
if (qLen) {
const repeat = 1 << (qLen * 4);
set.addRange(start, start + repeat - 1);
}
else if (i < len && range[i] === '-') {
i++;
j = i;
while (j < len && j - i < 6 && ((range[j] >= '0' && range[j] <= '9') ||
(range[j] >= 'a' && range[j] <= 'f') ||
(range[j] >= 'A' && range[j] <= 'F')))
j++;
if (j === i)
throw new SyntaxError('Expected hex digits after - in unicode-range');
const end = parseHex(range, i, j - i);
if (end < start)
throw new SyntaxError('Invalid range in unicode-range');
set.addRange(start, end);
i = j;
}
else {
set.add(start);
}
while (i < len && isWhitespace(range[i]))
i++;
if (i === len)
break;
if (range[i] !== ',')
throw new SyntaxError('Expected comma between unicode-range tokens');
i++;
}
}
export class FontFace {
family;
style;
weight;
stretch;
variant;
unicodeRange;
status;
/** @internal */
_unicodeRange;
#status;
#url;
#sync;
constructor(family, source, descriptors) {
this.family = family;
this.style = descriptors?.style ?? 'normal';
if (descriptors?.weight === 'bold' || descriptors?.weight === 'bolder') {
this.weight = 700;
}
else if (descriptors?.weight === 'lighter') {
this.weight = 300;
}
else if (descriptors?.weight === 'normal') {
this.weight = 400;
}
else {
this.weight = descriptors?.weight ?? 400;
}
this.stretch = descriptors?.stretch ?? 'normal';
this.variant = descriptors?.variant ?? 'normal';
this.unicodeRange = descriptors?.unicodeRange ?? 'U+0-10FFFF';
this.status = 'unloaded';
this.#status = new Deferred();
if (descriptors?.unicodeRange) {
this._unicodeRange = hb.createSet();
parseUnicodeRange(descriptors.unicodeRange, this._unicodeRange);
}
else {
this._unicodeRange = undefined;
}
if (source instanceof URL) {
this.#url = source;
}
else if (!__font_face_skip_ctor_load) {
this.#loadData(source);
}
this.#sync = false;
}
#onError(error) {
this.status = 'error';
fonts._onError(this);
this.#status.reject(error);
}
/** @internal */
_matchToLoaded(face) {
this.status = 'loaded';
faceToLoaded.set(this, face);
langCascade = undefined;
urangeCascade = undefined;
loadedFaceRegistry.register(this, face);
fonts._onLoaded(this, face);
this.#status.resolve(this);
}
/** @internal */
_hasUnicode(unicode) {
return !this._unicodeRange || this._unicodeRange.has(unicode);
}
#loadData(data, url) {
let face;
try {
face = new LoadedFontFace(data, this, url);
}
catch (e) {
this.#onError(e);
}
if (face)
this._matchToLoaded(face);
}
load() {
if (!this.#url || this.status !== 'unloaded')
return this.#status.promise;
const url = this.#url;
this.status = 'loading';
fonts._onLoading(this);
let result;
try {
if (this.#sync) {
result = environment.resolveUrlSync(url);
}
else {
result = environment.resolveUrl(url);
}
}
catch (e) {
this.#onError(e);
if (this.#sync)
throw e;
}
if (result instanceof Promise) {
result.then((data) => this.#loadData(data, url), (error) => this.#onError(error));
}
else if (result) {
// #sync = true
this.#loadData(result, url);
}
return this.#status.promise;
}
loadSync() {
this.#sync = true;
try {
this.load();
return this;
}
finally {
this.#sync = false;
}
}
get loaded() {
return this.#status.promise;
}
}
export const fonts = new FontFaceSet();
function createFaceFromTablesImpl(source, url) {
const loaded = new LoadedFontFace(source, undefined, url);
let face;
try {
__font_face_skip_ctor_load = true;
face = new FontFace(loaded.family, url || source, loaded);
}
finally {
__font_face_skip_ctor_load = false;
}
face._matchToLoaded(loaded);
return face;
}
// Dropflow's original font registration system was designed after OS
// implementations: read the font tables and strings and generate a good
// description from that.
//
// CSS moves the responsibility of creating font descriptions to the content
// author instead of the font author, so when dropflow's font system was
// redesigned to resemble the CSS Font Loading Module, the original code to
// read font tables could have become obsolete.
//
// But one thing I wanted to keep was knowing what languages the font supports
// during font selection, since this can produce much higher quality font
// fallbacks. The CSS Font Loading Module does not provide a way to specify
// this, and the CSSWG has turned the proposal down, sadly:
// https://github.com/w3c/csswg-drafts/issues/1744
// So I added a non-standard `languages` to `FontFaceDescriptors`.
//
// The other font values are convenient for the tests, or for when you don't
// care what the description is. Plus I didn't want to delete all this work!
export function createFaceFromTables(source) {
const res = environment.resolveUrl(source);
return res.then(buf => createFaceFromTablesImpl(buf, source));
}
export function createFaceFromTablesSync(source) {
if (source instanceof URL) {
const res = environment.resolveUrlSync(source);
return createFaceFromTablesImpl(res, source);
}
else {
return createFaceFromTablesImpl(source);
}
}
class FontCascadeBase {
source;
/**
* @param source fonts in prioritized order. All else equal, fonts earlier in
* the list will be preferred over those later.
*/
constructor(source) {
this.source = source;
}
reset(source) {
this.source = source;
}
static stretchToLinear = {
'ultra-condensed': 1,
'extra-condensed': 2,
'condensed': 3,
'semi-condensed': 4,
'normal': 5,
'semi-expanded': 6,
'expanded': 7,
'extra-expanded': 8,
'ultra-expanded': 9,
};
narrowByFontStretch(style, matches) {
const toLinear = FontCascadeBase.stretchToLinear;
const desiredLinearStretch = toLinear[style.fontStretch];
const search = matches.slice();
if (desiredLinearStretch <= 5) {
search.sort((a, b) => toLinear[a.stretch] - toLinear[b.stretch]);
}
else {
search.sort((a, b) => toLinear[b.stretch] - toLinear[a.stretch]);
}
let bestDistance = 10;
let bestMatch = search[0];
for (const match of search) {
const distance = Math.abs(desiredLinearStretch - toLinear[match.stretch]);
if (distance < bestDistance) {
bestDistance = distance;
bestMatch = match;
}
}
return matches.filter(match => match.stretch === bestMatch.stretch);
}
narrowByFontStyle(style, matches) {
const italics = matches.filter(match => match.style === 'italic');
const obliques = matches.filter(match => match.style === 'oblique');
const normals = matches.filter(match => match.style === 'normal');
if (style.fontStyle === 'italic') {
return italics.length ? italics : obliques.length ? obliques : normals;
}
if (style.fontStyle === 'oblique') {
return obliques.length ? obliques : italics.length ? italics : normals;
}
return normals.length ? normals : obliques.length ? obliques : italics;
}
narrowByFontWeight(style, matches) {
const desiredWeight = style.fontWeight;
const exact = matches.find(match => match.weight === desiredWeight);
let lt400 = desiredWeight < 400;
if (exact)
return exact;
if (desiredWeight === 400) {
const exact = matches.find(match => match.weight === 500);
if (exact)
return exact;
}
else if (desiredWeight === 500) {
const exact = matches.find(match => match.weight === 400);
if (exact)
return exact;
lt400 = true;
}
const below = matches.slice().filter(match => match.weight < desiredWeight);
const above = matches.slice().filter(match => match.weight > desiredWeight);
let bestMatch = matches[0];
let bestWeightDistance = 1000;
if (lt400) {
below.sort((a, b) => b.weight - a.weight);
above.sort((a, b) => a.weight - b.weight);
for (const match of below) {
const distance = Math.abs(match.weight - style.fontWeight);
if (distance < bestWeightDistance) {
bestWeightDistance = distance;
bestMatch = match;
}
}
for (const match of above) {
const distance = Math.abs(match.weight - style.fontWeight);
if (distance < bestWeightDistance) {
bestWeightDistance = distance;
bestMatch = match;
}
}
}
else {
below.sort((a, b) => a.weight - b.weight);
above.sort((a, b) => b.weight - a.weight);
for (const match of above) {
const distance = Math.abs(match.weight - style.fontWeight);
if (distance < bestWeightDistance) {
bestWeightDistance = distance;
bestMatch = match;
}
}
for (const match of below) {
const distance = Math.abs(match.weight - style.fontWeight);
if (distance < bestWeightDistance) {
bestWeightDistance = distance;
bestMatch = match;
}
}
}
return bestMatch;
}
}
export class LangFontCascade extends FontCascadeBase {
cache;
constructor(list) {
super(list);
this.cache = new WeakMap();
}
sortByLang(style, lang) {
const ret = new Set();
const selectedFamilies = new Set();
let matches = this.cache.get(style)?.get(lang);
if (matches)
return matches;
let map1 = this.cache.get(style);
if (!map1)
this.cache.set(style, map1 = new Map());
for (const searchFamily of style.fontFamily) {
let matches = [];
for (const candidate of this.source) {
if (candidate.family.toLowerCase().trim() === searchFamily.toLowerCase().trim()) {
matches.push(candidate);
}
}
if (!matches.length)
continue;
matches = this.narrowByFontStretch(style, matches);
matches = this.narrowByFontStyle(style, matches);
const match = this.narrowByFontWeight(style, matches);
ret.add(match);
selectedFamilies.add(match.family);
}
// Now we're at the point that the spec calls system fallbacks, since
// this.matches could be empty. This could be adjusted in all kinds of
// arbitrary ways. It's most important to ensure a fallback for the
// language, which is based on the script.
let languageCandidates = [];
for (const candidate of this.source) {
if (candidate.languages.has(lang) && !ret.has(candidate)) {
languageCandidates.push(candidate);
}
}
if (languageCandidates.length) {
languageCandidates = this.narrowByFontStretch(style, languageCandidates);
languageCandidates = this.narrowByFontStyle(style, languageCandidates);
const match = this.narrowByFontWeight(style, languageCandidates);
ret.add(match);
selectedFamilies.add(match.family);
}
// Finally, push one of each of the rest of the families
const groups = new Map();
for (const candidate of this.source) {
if (!selectedFamilies.has(candidate.family)) {
let candidates = groups.get(candidate.family);
if (!candidates)
groups.set(candidate.family, candidates = []);
candidates.push(candidate);
}
}
for (let candidates of groups.values()) {
candidates = this.narrowByFontStretch(style, candidates);
candidates = this.narrowByFontStyle(style, candidates);
ret.add(this.narrowByFontWeight(style, candidates));
}
matches = [...ret];
map1.set(lang, matches);
return matches;
}
}
// Note this is NOT cached, so use it very carefully. It isn't realistic to
// cache per unicode character; you should instead hold onto the result and
// use style.fontsEqual and _hasUnicode on the first match when iterating
// over document styles and characters.
class UrangeFontCascade extends FontCascadeBase {
sortByUnicode(style, unicode) {
let ret = [];
for (const searchFamily of style.fontFamily) {
let matches = [];
for (const candidate of this.source) {
if (candidate.family.toLowerCase().trim() === searchFamily.toLowerCase().trim() &&
candidate._hasUnicode(unicode))
matches.push(candidate);
}
if (!matches.length)
continue;
matches = this.narrowByFontStretch(style, matches);
matches = this.narrowByFontStyle(style, matches);
ret.push(this.narrowByFontWeight(style, matches));
}
if (!ret.length) {
let matches = [];
for (const candidate of this.source) {
if (candidate._hasUnicode(unicode))
matches.push(candidate);
}
if (matches.length) {
matches = this.narrowByFontStretch(style, matches);
matches = this.narrowByFontStyle(style, matches);
ret.push(this.narrowByFontWeight(style, matches));
}
}
return ret;
}
}
let langCascade;
let urangeCascade;
export function getLangCascade(style, lang) {
if (!langCascade) {
const list = [];
for (const face of fonts) {
const match = faceToLoaded.get(face);
if (match)
list.push(match);
}
// reverse() is due to §4.5.1
// https://drafts.csswg.org/css-fonts/#composite-fonts
// If the unicode ranges overlap for a set of @font-face rules with the
// same family and style descriptor values, the rules are ordered in the
// reverse order they were defined; the last rule defined is the first to
// be checked for a given character.
langCascade = new LangFontCascade(list.reverse());
}
return langCascade.sortByLang(style, lang);
}
function getUrangeCascade() {
if (!urangeCascade) {
urangeCascade = new UrangeFontCascade([...fonts].reverse());
}
return urangeCascade;
}
export function eachRegisteredFont(cb) {
for (const face of fonts) {
const match = faceToLoaded.get(face);
if (match)
cb(match);
}
}
function onFontNeeded(ctx, style, unicode) {
const cascade = getUrangeCascade();
if (!cascade.source.length)
return;
// Only recalc the cascade when the style changes or when the old list's
// _first_ match doesn't support the character. That means that fallback
// list for later characters may not be ideal, but we aren't required to
// load every font in the user-specified fallback list.
if (!ctx.fontEntry?.style.fontsEqual(style, false) ||
!ctx.fontEntry.faces[0]._hasUnicode(unicode)) {
ctx.fontEntry = ctx.fontCache.find(entry => entry.style.fontsEqual(style, false));
if (!ctx.fontEntry || !ctx.fontEntry.faces[0]._hasUnicode(unicode)) {
const matches = cascade.sortByUnicode(style, unicode);
for (const font of matches)
ctx.onLoadableResource(font);
ctx.fontEntry = { style, faces: matches };
ctx.fontCache.push(ctx.fontEntry);
}
}
}
export function onLoadWalkerTextNodeForFonts(ctx, el) {
let i = 0;
while (i < el.text.length) {
const code = el.text.charCodeAt(i++);
const next = el.text.charCodeAt(i);
let unicode = code;
// Faster than using the string's builtin iterator in Firefox
if ((0xd800 <= code && code <= 0xdbff) && (0xdc00 <= next && next <= 0xdfff)) {
i++;
unicode = ((code - 0xd800) * 0x400) + (next - 0xdc00) + 0x10000;
}
onFontNeeded(ctx, el.style, unicode);
}
}
export function onLoadWalkerElementForFonts(ctx, el) {
onFontNeeded(ctx, el.style, 0x20);
}