diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index f9c66e96f..489406aee 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -850,7 +850,7 @@ export const actionChangeFontFamily = register({ ExcalidrawTextElement, ExcalidrawElement | null >(); - let uniqueGlyphs = new Set(); + let uniqueChars = new Set(); let skipFontFaceCheck = false; const fontsCache = Array.from(Fonts.loadedFontsCache.values()); @@ -898,8 +898,8 @@ export const actionChangeFontFamily = register({ } if (!skipFontFaceCheck) { - uniqueGlyphs = new Set([ - ...uniqueGlyphs, + uniqueChars = new Set([ + ...uniqueChars, ...Array.from(newElement.originalText), ]); } @@ -919,12 +919,9 @@ export const actionChangeFontFamily = register({ const fontString = `10px ${getFontFamilyString({ fontFamily: nextFontFamily, })}`; - const glyphs = Array.from(uniqueGlyphs.values()).join(); + const chars = Array.from(uniqueChars.values()).join(); - if ( - skipFontFaceCheck || - window.document.fonts.check(fontString, glyphs) - ) { + if (skipFontFaceCheck || window.document.fonts.check(fontString, chars)) { // we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded for (const [element, container] of elementContainerMapping) { // trigger synchronous redraw @@ -936,8 +933,8 @@ export const actionChangeFontFamily = register({ ); } } else { - // otherwise try to load all font faces for the given glyphs and redraw elements once our font faces loaded - window.document.fonts.load(fontString, glyphs).then((fontFaces) => { + // otherwise try to load all font faces for the given chars and redraw elements once our font faces loaded + window.document.fonts.load(fontString, chars).then((fontFaces) => { for (const [element, container] of elementContainerMapping) { // use latest element state to ensure we don't have closure over an old instance in order to avoid possible race conditions (i.e. font faces load out-of-order while rapidly switching fonts) const latestElement = app.scene.getElement(element.id); diff --git a/packages/excalidraw/fonts/ExcalidrawFont.ts b/packages/excalidraw/fonts/ExcalidrawFont.ts index cb8a76fc0..d00199543 100644 --- a/packages/excalidraw/fonts/ExcalidrawFont.ts +++ b/packages/excalidraw/fonts/ExcalidrawFont.ts @@ -1,10 +1,9 @@ -import { stringToBase64, toByteString } from "../data/encode"; import { LOCAL_FONT_PROTOCOL } from "./metadata"; export interface Font { urls: URL[]; fontFace: FontFace; - getContent(): Promise; + getContent(codePoints: ReadonlySet): Promise; } export const UNPKG_PROD_URL = `https://unpkg.com/${ import.meta.env.VITE_PKG_NAME @@ -12,6 +11,10 @@ export const UNPKG_PROD_URL = `https://unpkg.com/${ : "@excalidraw/excalidraw" // fallback to latest package version (i.e. for app) }/dist/prod/`; +/** caches for lazy loaded chunks, reused across concurrent calls and separate editor instances */ +let fontEditorCache: Promise | null = null; +let brotliCache: Promise | null = null; + export class ExcalidrawFont implements Font { public readonly urls: URL[]; public readonly fontFace: FontFace; @@ -33,20 +36,31 @@ export class ExcalidrawFont implements Font { /** * Tries to fetch woff2 content, based on the registered urls. - * Returns last defined url in case of errors. * - * Note: uses browser APIs for base64 encoding - use dataurl outside the browser environment. + * 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(): Promise { + public async getContent(codePoints: ReadonlySet): Promise { let i = 0; const errorMessages = []; while (i < this.urls.length) { const url = this.urls[i]; + // it's dataurl, the font is inlined as base64, no need to fetch if (url.protocol === "data:") { - // it's dataurl, the font is inlined as base64, no need to fetch - return url.toString(); + const arrayBuffer = Buffer.from( + url.toString().split(",")[1], + "base64", + ).buffer; + + const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints( + arrayBuffer, + codePoints, + ); + + return base64; } try { @@ -57,13 +71,12 @@ export class ExcalidrawFont implements Font { }); if (response.ok) { - const mimeType = await response.headers.get("Content-Type"); - const buffer = await response.arrayBuffer(); - - return `data:${mimeType};base64,${await stringToBase64( - await toByteString(buffer), - true, - )}`; + const arrayBuffer = await response.arrayBuffer(); + const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints( + arrayBuffer, + codePoints, + ); + return base64; } // response not ok, try to continue @@ -89,6 +102,45 @@ export class ExcalidrawFont implements Font { return this.urls.length ? this.urls[this.urls.length - 1].toString() : ""; } + /** + * Converts a font data as arraybuffer into a dataurl (base64) with subsetted glyphs based on the specified `codePoints`. + * + * NOTE: only glyphs are subsetted, other metadata as GPOS tables stay, consider filtering those as well in the future + * + * @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 converted into a dataurl + */ + private static async subsetGlyphsByCodePoints( + arrayBuffer: ArrayBuffer, + codePoints: ReadonlySet, + ): Promise { + // checks for the cache first to avoid triggering the import multiple times in case of concurrent calls + if (!fontEditorCache) { + fontEditorCache = import("fonteditor-core"); + } + + const { Font, woff2 } = await fontEditorCache; + + // checks for the cache first to avoid triggering the init multiple times in case of concurrent calls + if (!brotliCache) { + brotliCache = woff2.init("/wasm/woff2.wasm"); + } + + await brotliCache; + + const font = Font.create(arrayBuffer, { + type: "woff2", + kerning: true, + hinting: true, + // subset the glyhs based on the specified codepoints! + subset: [...codePoints], + }); + + return font.toBase64({ type: "woff2", hinting: true }); + } + private static createUrls(uri: string): URL[] { if (uri.startsWith(LOCAL_FONT_PROTOCOL)) { // no url for local fonts diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index 498a679b3..0a38ff772 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -67,6 +67,7 @@ "canvas-roundrect-polyfill": "0.0.1", "clsx": "1.1.1", "cross-env": "7.0.3", + "fonteditor-core": "2.4.1", "fractional-indexing": "3.2.0", "fuzzy": "0.1.3", "image-blob-reduce": "3.0.1", diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index 1f693e644..888cb76b2 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -354,50 +354,14 @@ export const exportToSvg = async ( `; } - const fontFamilies = elements.reduce((acc, element) => { - if (isTextElement(element)) { - acc.add(element.fontFamily); - } - - return acc; - }, new Set()); - - const fontFaces = opts?.skipInliningFonts - ? [] - : await Promise.all( - Array.from(fontFamilies).map(async (x) => { - const { fonts, metadata } = Fonts.registered.get(x) ?? {}; - - if (!Array.isArray(fonts)) { - console.error( - `Couldn't find registered fonts for font-family "${x}"`, - Fonts.registered, - ); - return; - } - - if (metadata?.local) { - // don't inline local fonts - return; - } - - return Promise.all( - fonts.map( - async (font) => `@font-face { - font-family: ${font.fontFace.family}; - src: url(${await font.getContent()}); - }`, - ), - ); - }), - ); + const fontFaces = opts?.skipInliningFonts ? [] : await getFontFaces(elements); svgRoot.innerHTML = ` ${SVG_EXPORT_TAG} ${metadata} ${exportingFrameClipPath} @@ -468,3 +432,56 @@ export const getExportSize = ( return [width, height]; }; + +const getFontFaces = async ( + elements: readonly ExcalidrawElement[], +): Promise => { + const fontFamilies = new Set(); + const codePoints = new Set(); + + for (const element of elements) { + if (!isTextElement(element)) { + continue; + } + + fontFamilies.add(element.fontFamily); + + for (const codePoint of Array.from(element.originalText, (u) => + u.codePointAt(0), + )) { + if (codePoint) { + codePoints.add(codePoint); + } + } + } + + const fontFaces = await Promise.all( + Array.from(fontFamilies).map(async (x) => { + const { fonts, metadata } = Fonts.registered.get(x) ?? {}; + + if (!Array.isArray(fonts)) { + console.error( + `Couldn't find registered fonts for font-family "${x}"`, + Fonts.registered, + ); + return []; + } + + if (metadata?.local) { + // don't inline local fonts + return []; + } + + return Promise.all( + fonts.map( + async (font) => `@font-face { + font-family: ${font.fontFace.family}; + src: url(${await font.getContent(codePoints)}); + }`, + ), + ); + }), + ); + + return fontFaces.flat(); +}; diff --git a/public/wasm/woff2.wasm b/public/wasm/woff2.wasm new file mode 100644 index 000000000..7f31f44ad Binary files /dev/null and b/public/wasm/woff2.wasm differ diff --git a/yarn.lock b/yarn.lock index 39c3d90db..fa9f377e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6194,6 +6194,13 @@ fonteditor-core@2.4.0: dependencies: "@xmldom/xmldom" "^0.8.3" +fonteditor-core@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/fonteditor-core/-/fonteditor-core-2.4.1.tgz#ff4b3cd04b50f98026bedad353d0ef6692464bc9" + integrity sha512-nKDDt6kBQGq665tQO5tCRQUClJG/2MAF9YT1eKHl+I4NasdSb6DgXrv/gMjNxjo9NyaVEv9KU9VZxLHMstN1wg== + dependencies: + "@xmldom/xmldom" "^0.8.3" + for-each@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e"