import fontParser from "./FontParser.js"; import unicodeFontResolverClientFactory from "../libs/unicode-font-resolver-client.factory.js"; import { defineWorkerModule } from "troika-worker-utils"; /** * @typedef {string | {src:string, label?:string, unicodeRange?:string, lang?:string}} UserFont */ /** * @typedef {ClientOptions} FontResolverOptions * @property {Array|UserFont} [fonts] * @property {'normal'|'italic'} [style] * @property {'normal'|'bold'|number} [style] * @property {string} [unicodeFontsURL] */ /** * @typedef {Object} FontResolverResult * @property {Uint8Array} chars * @property {Array} fonts */ /** * @typedef {function} FontResolver * @param {string} text * @param {(FontResolverResult) => void} callback * @param {FontResolverOptions} [options] */ /** * Factory for the FontResolver function. * @param {FontParser} fontParser * @param {{getFontsForString: function, CodePointSet: function}} unicodeFontResolverClient * @return {FontResolver} */ export function createFontResolver(fontParser, unicodeFontResolverClient) { /** * @type {Record} */ const parsedFonts = Object.create(null) /** * @type {Record void>>} */ const loadingFonts = Object.create(null) /** * Load a given font url */ function doLoadFont(url, callback) { const onError = err => { console.error(`Failure loading font ${url}`, err) } try { const request = new XMLHttpRequest() request.open('get', url, true) request.responseType = 'arraybuffer' request.onload = function () { if (request.status >= 400) { onError(new Error(request.statusText)) } else if (request.status > 0) { try { const fontObj = fontParser(request.response) fontObj.src = url; callback(fontObj) } catch (e) { onError(e) } } } request.onerror = onError request.send() } catch(err) { onError(err) } } /** * Load a given font url if needed, invoking a callback when it's loaded. If already * loaded, the callback will be called synchronously. * @param {string} fontUrl * @param {(font: ParsedFont) => void} callback */ function loadFont(fontUrl, callback) { let font = parsedFonts[fontUrl] if (font) { callback(font) } else if (loadingFonts[fontUrl]) { loadingFonts[fontUrl].push(callback) } else { loadingFonts[fontUrl] = [callback] doLoadFont(fontUrl, fontObj => { fontObj.src = fontUrl parsedFonts[fontUrl] = fontObj loadingFonts[fontUrl].forEach(cb => cb(fontObj)) delete loadingFonts[fontUrl]; }) } } /** * For a given string of text, determine which fonts are required to fully render it and * ensure those fonts are loaded. */ return function (text, callback, { lang, fonts: userFonts = [], style = 'normal', weight = 'normal', unicodeFontsURL } = {}) { const charResolutions = new Uint8Array(text.length); const fontResolutions = []; if (!text.length) { allDone() } const fontIndices = new Map(); const fallbackRanges = [] // [[start, end], ...] if (style !== 'italic') style = 'normal' if (typeof weight !== 'number') { weight = weight === 'bold' ? 700 : 400 } if (userFonts && !Array.isArray(userFonts)) { userFonts = [userFonts] } userFonts = userFonts.slice() // filter by language .filter(def => !def.lang || def.lang.test(lang)) // switch order for easier iteration .reverse() if (userFonts.length) { const UNKNOWN = 0 const RESOLVED = 1 const NEEDS_FALLBACK = 2 let prevCharResult = UNKNOWN ;(function resolveUserFonts (startIndex = 0) { for (let i = startIndex, iLen = text.length; i < iLen; i++) { const codePoint = text.codePointAt(i) // Carry previous character's result forward if: // - it resolved to a font that also covers this character // - this character is whitespace if ( (prevCharResult === RESOLVED && fontResolutions[charResolutions[i - 1]].supportsCodePoint(codePoint)) || (i > 0 && /\s/.test(text[i])) ) { charResolutions[i] = charResolutions[i - 1] if (prevCharResult === NEEDS_FALLBACK) { fallbackRanges[fallbackRanges.length - 1][1] = i } } else { for (let j = charResolutions[i], jLen = userFonts.length; j <= jLen; j++) { if (j === jLen) { // none of the user fonts matched; needs fallback const range = prevCharResult === NEEDS_FALLBACK ? fallbackRanges[fallbackRanges.length - 1] : (fallbackRanges[fallbackRanges.length] = [i, i]) range[1] = i; prevCharResult = NEEDS_FALLBACK; } else { charResolutions[i] = j; const { src, unicodeRange } = userFonts[j]; // filter by optional explicit unicode ranges if (!unicodeRange || isCodeInRanges(codePoint, unicodeRange)) { const fontObj = parsedFonts[src]; // font not yet loaded, load it and resume if (!fontObj) { loadFont(src, () => { resolveUserFonts(i); }); return; } // if the font actually contains a glyph for this char, lock it in if (fontObj.supportsCodePoint(codePoint)) { let fontIndex = fontIndices.get(fontObj); if (typeof fontIndex !== 'number') { fontIndex = fontResolutions.length; fontResolutions.push(fontObj); fontIndices.set(fontObj, fontIndex); } charResolutions[i] = fontIndex; prevCharResult = RESOLVED; break; } } } } } if (codePoint > 0xffff && i + 1 < iLen) { charResolutions[i + 1] = charResolutions[i] i++ if (prevCharResult === NEEDS_FALLBACK) { fallbackRanges[fallbackRanges.length - 1][1] = i } } } resolveFallbacks(); })(); } else { fallbackRanges.push([0, text.length - 1]) resolveFallbacks(); } function resolveFallbacks() { if (fallbackRanges.length) { // Combine all fallback substrings into a single string for querying const fallbackString = fallbackRanges.map(range => text.substring(range[0], range[1] + 1)).join('\n') unicodeFontResolverClient.getFontsForString(fallbackString, { lang: lang || undefined, style, weight, dataUrl: unicodeFontsURL }).then(({fontUrls, chars}) => { // Extract results and put them back in the main array const fontIndexOffset = fontResolutions.length let charIdx = 0; fallbackRanges.forEach(range => { for (let i = 0, endIdx = range[1] - range[0]; i <= endIdx; i++) { charResolutions[range[0] + i] = chars[charIdx++] + fontIndexOffset } charIdx++ //skip segment separator }) // Load and parse the fallback fonts - avoiding Promise here to prevent polyfills in the worker let loadedCount = 0; fontUrls.forEach((url, i) => { loadFont(url, fontObj => { fontResolutions[i + fontIndexOffset] = fontObj if (++loadedCount === fontUrls.length) { allDone(); } }) }) }); } else { allDone(); } } function allDone() { callback({ chars: charResolutions, fonts: fontResolutions }) } function isCodeInRanges(code, ranges) { // todo optimize search - CodePointSet from unicode-font-resolver? for (let k = 0; k < ranges.length; k++) { const [start, end = start] = ranges[k] if (start <= code && code <= end) { return true } } return false } } } export const fontResolverWorkerModule = /*#__PURE__*/defineWorkerModule({ name: 'FontResolver', dependencies: [ createFontResolver, fontParser, unicodeFontResolverClientFactory, ], init(createFontResolver, fontParser, unicodeFontResolverClientFactory) { return createFontResolver(fontParser, unicodeFontResolverClientFactory()); } })