Browse Source

Glyph subsetting for SVG export

Marcel Mraz 1 year ago
parent
commit
9c91cf93dd

+ 7 - 10
packages/excalidraw/actions/actionProperties.tsx

@@ -850,7 +850,7 @@ export const actionChangeFontFamily = register({
         ExcalidrawTextElement,
         ExcalidrawElement | null
       >();
-      let uniqueGlyphs = new Set<string>();
+      let uniqueChars = new Set<string>();
       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);

+ 66 - 14
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<string>;
+  getContent(codePoints: ReadonlySet<number>): Promise<string>;
 }
 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<typeof import("fonteditor-core")> | null = null;
+let brotliCache: Promise<typeof import("fonteditor-core").woff2> | 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<string> {
+  public async getContent(codePoints: ReadonlySet<number>): Promise<string> {
     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<number>,
+  ): Promise<string> {
+    // 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

+ 1 - 0
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",

+ 55 - 38
packages/excalidraw/scene/export.ts

@@ -354,50 +354,14 @@ export const exportToSvg = async (
         </clipPath>`;
   }
 
-  const fontFamilies = elements.reduce((acc, element) => {
-    if (isTextElement(element)) {
-      acc.add(element.fontFamily);
-    }
-
-    return acc;
-  }, new Set<number>());
-
-  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}
   <defs>
     <style class="style-fonts">
-      ${fontFaces.flat().filter(Boolean).join("\n")}
+      ${fontFaces.join("\n")}
     </style>
     ${exportingFrameClipPath}
   </defs>
@@ -468,3 +432,56 @@ export const getExportSize = (
 
   return [width, height];
 };
+
+const getFontFaces = async (
+  elements: readonly ExcalidrawElement[],
+): Promise<string[]> => {
+  const fontFamilies = new Set<number>();
+  const codePoints = new Set<number>();
+
+  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();
+};

BIN
public/wasm/woff2.wasm


+ 7 - 0
yarn.lock

@@ -6194,6 +6194,13 @@ [email protected]:
   dependencies:
     "@xmldom/xmldom" "^0.8.3"
 
[email protected]:
+  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"