import { stringToBase64, toByteString } from "../data/encode"; import { LOCAL_FONT_PROTOCOL } from "./metadata"; import loadWoff2 from "./wasm/woff2.loader"; import loadHbSubset from "./wasm/hb-subset.loader"; export interface Font { urls: URL[]; fontFace: FontFace; getContent(codePoints: ReadonlySet): Promise; } export const UNPKG_FALLBACK_URL = `https://unpkg.com/${ import.meta.env.VITE_PKG_NAME ? `${import.meta.env.VITE_PKG_NAME}@${import.meta.env.PKG_VERSION}` // should be provided by vite during package build : "@excalidraw/excalidraw" // fallback to latest package version (i.e. for app) }/dist/prod/`; export class ExcalidrawFont implements Font { public readonly urls: URL[]; public readonly fontFace: FontFace; constructor(family: string, uri: string, descriptors?: FontFaceDescriptors) { this.urls = ExcalidrawFont.createUrls(uri); const sources = this.urls .map((url) => `url(${url}) ${ExcalidrawFont.getFormat(url)}`) .join(", "); this.fontFace = new FontFace(family, sources, { display: "swap", style: "normal", weight: "400", ...descriptors, }); } /** * Tries to fetch woff2 content, based on the registered urls (from first to last, treated as fallbacks). * * NOTE: assumes usage of `dataurl` outside the browser environment * * @returns base64 with subsetted glyphs based on the passed codepoint, last defined url otherwise */ public async getContent(codePoints: ReadonlySet): Promise { let i = 0; const errorMessages = []; while (i < this.urls.length) { const url = this.urls[i]; // it's dataurl (server), the font is inlined as base64, no need to fetch if (url.protocol === "data:") { const arrayBuffer = Buffer.from( url.toString().split(",")[1], "base64", ).buffer; const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints( arrayBuffer, codePoints, ); return base64; } try { const response = await fetch(url, { headers: { Accept: "font/woff2", }, }); if (response.ok) { const arrayBuffer = await response.arrayBuffer(); const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints( arrayBuffer, codePoints, ); return base64; } // response not ok, try to continue errorMessages.push( `"${url.toString()}" returned status "${response.status}"`, ); } catch (e) { errorMessages.push(`"${url.toString()}" returned error "${e}"`); } i++; } console.error( `Failed to fetch font "${ this.fontFace.family }" from urls "${this.urls.toString()}`, JSON.stringify(errorMessages, undefined, 2), ); // in case of issues, at least return the last url as a content // defaults to unpkg for bundled fonts (so that we don't have to host them forever) and http url for others return this.urls.length ? this.urls[this.urls.length - 1].toString() : ""; } /** * Tries to subset glyphs in a font based on the used codepoints, returning the font as daturl. * * @param arrayBuffer font data buffer, preferrably in the woff2 format, though others should work as well * @param codePoints codepoints used to subset the glyphs * * @returns font with subsetted glyphs (all glyphs in case of errors) converted into a dataurl */ private static async subsetGlyphsByCodePoints( arrayBuffer: ArrayBuffer, codePoints: ReadonlySet, ): Promise { try { // lazy loaded wasm modules to avoid multiple initializations in case of concurrent triggers const { compress, decompress } = await loadWoff2(); const { subset } = await loadHbSubset(); const decompressedBinary = decompress(arrayBuffer).buffer; const subsetSnft = subset(decompressedBinary, codePoints); const compressedBinary = compress(subsetSnft.buffer); return ExcalidrawFont.toBase64(compressedBinary.buffer); } catch (e) { console.error("Skipped glyph subsetting", e); // Fallback to encoding whole font in case of errors return ExcalidrawFont.toBase64(arrayBuffer); } } private static async toBase64(arrayBuffer: ArrayBuffer) { let base64: string; if (typeof Buffer !== "undefined") { // node + server-side base64 = Buffer.from(arrayBuffer).toString("base64"); } else { base64 = await stringToBase64(await toByteString(arrayBuffer), true); } return `data:font/woff2;base64,${base64}`; } private static createUrls(uri: string): URL[] { if (uri.startsWith(LOCAL_FONT_PROTOCOL)) { // no url for local fonts return []; } if (uri.startsWith("http") || uri.startsWith("data")) { // one url for http imports or data url return [new URL(uri)]; } // absolute assets paths, which are found in tests and excalidraw-app build, won't work with base url, so we are stripping initial slash away const assetUrl: string = uri.replace(/^\/+/, ""); const urls: URL[] = []; if (typeof window.EXCALIDRAW_ASSET_PATH === "string") { const normalizedBaseUrl = this.normalizeBaseUrl( window.EXCALIDRAW_ASSET_PATH, ); urls.push(new URL(assetUrl, normalizedBaseUrl)); } else if (Array.isArray(window.EXCALIDRAW_ASSET_PATH)) { window.EXCALIDRAW_ASSET_PATH.forEach((path) => { const normalizedBaseUrl = this.normalizeBaseUrl(path); urls.push(new URL(assetUrl, normalizedBaseUrl)); }); } // fallback url for bundled fonts urls.push(new URL(assetUrl, UNPKG_FALLBACK_URL)); return urls; } private static getFormat(url: URL) { try { const parts = new URL(url).pathname.split("."); if (parts.length === 1) { return ""; } return `format('${parts.pop()}')`; } catch (error) { return ""; } } private static normalizeBaseUrl(baseUrl: string) { let result = baseUrl; // in case user passed a root-relative url (~absolute path), // like "/" or "/some/path", or relative (starts with "./"), // prepend it with `location.origin` if (/^\.?\//.test(result)) { result = new URL( result.replace(/^\.?\/+/, ""), window?.location?.origin, ).toString(); } // ensure there is a trailing slash, otherwise url won't be correctly concatenated result = `${result.replace(/\/+$/, "")}/`; return result; } }