瀏覽代碼

fix: load font faces in Safari manually (#8693)

Marcel Mraz 10 月之前
父節點
當前提交
03028eaa8c

+ 15 - 8
packages/excalidraw/components/App.tsx

@@ -49,7 +49,7 @@ import {
 } from "../appState";
 import type { PastedMixedContent } from "../clipboard";
 import { copyTextToSystemClipboard, parseClipboard } from "../clipboard";
-import { ARROW_TYPE, type EXPORT_IMAGE_TYPES } from "../constants";
+import { ARROW_TYPE, isSafari, type EXPORT_IMAGE_TYPES } from "../constants";
 import {
   APP_NAME,
   CURSOR_TYPE,
@@ -2320,11 +2320,11 @@ class App extends React.Component<AppProps, AppState> {
     // clear the shape and image cache so that any images in initialData
     // can be loaded fresh
     this.clearImageShapeCache();
-    // FontFaceSet loadingdone event we listen on may not always
-    // fire (looking at you Safari), so on init we manually load all
-    // fonts and rerender scene text elements once done. This also
-    // seems faster even in browsers that do fire the loadingdone event.
-    this.fonts.loadSceneFonts();
+
+    // manually loading the font faces seems faster even in browsers that do fire the loadingdone event
+    this.fonts.loadSceneFonts().then((fontFaces) => {
+      this.fonts.onLoaded(fontFaces);
+    });
   };
 
   private isMobileBreakpoint = (width: number, height: number) => {
@@ -2567,8 +2567,8 @@ class App extends React.Component<AppProps, AppState> {
       ),
       // rerender text elements on font load to fix #637 && #1553
       addEventListener(document.fonts, "loadingdone", (event) => {
-        const loadedFontFaces = (event as FontFaceSetLoadEvent).fontfaces;
-        this.fonts.onLoaded(loadedFontFaces);
+        const fontFaces = (event as FontFaceSetLoadEvent).fontfaces;
+        this.fonts.onLoaded(fontFaces);
       }),
       // Safari-only desktop pinch zoom
       addEventListener(
@@ -3236,6 +3236,13 @@ class App extends React.Component<AppProps, AppState> {
       }
     });
 
+    // paste event may not fire FontFace loadingdone event in Safari, hence loading font faces manually
+    if (isSafari) {
+      Fonts.loadElementsFonts(newElements).then((fontFaces) => {
+        this.fonts.onLoaded(fontFaces);
+      });
+    }
+
     if (opts.files) {
       this.addMissingFiles(opts.files);
     }

+ 1 - 0
packages/excalidraw/constants.ts

@@ -2,6 +2,7 @@ import cssVariables from "./css/variables.module.scss";
 import type { AppProps, AppState } from "./types";
 import type { ExcalidrawElement, FontFamilyValues } from "./element/types";
 import { COLOR_PALETTE } from "./colors";
+
 export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
 export const isWindows = /^Win/.test(navigator.platform);
 export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);

+ 245 - 75
packages/excalidraw/fonts/Fonts.ts

@@ -3,11 +3,17 @@ import {
   FONT_FAMILY_FALLBACKS,
   CJK_HAND_DRAWN_FALLBACK_FONT,
   WINDOWS_EMOJI_FALLBACK_FONT,
+  isSafari,
+  getFontFamilyFallbacks,
 } from "../constants";
 import { isTextElement } from "../element";
-import { charWidth, getContainerElement } from "../element/textElement";
+import {
+  charWidth,
+  containsCJK,
+  getContainerElement,
+} from "../element/textElement";
 import { ShapeCache } from "../scene/ShapeCache";
-import { getFontString } from "../utils";
+import { getFontString, PromisePool, promiseTry } from "../utils";
 import { ExcalidrawFontFace } from "./ExcalidrawFontFace";
 
 import { CascadiaFontFaces } from "./Cascadia";
@@ -73,6 +79,13 @@ export class Fonts {
     this.scene = scene;
   }
 
+  /**
+   * Get all the font families for the given scene.
+   */
+  public getSceneFamilies = () => {
+    return Fonts.getUniqueFamilies(this.scene.getNonDeletedElements());
+  };
+
   /**
    * if we load a (new) font, it's likely that text elements using it have
    * already been rendered using a fallback font. Thus, we want invalidate
@@ -81,7 +94,7 @@ export class Fonts {
    * Invalidates text elements and rerenders scene, provided that at least one
    * of the supplied fontFaces has not already been processed.
    */
-  public onLoaded = (fontFaces: readonly FontFace[]) => {
+  public onLoaded = (fontFaces: readonly FontFace[]): void => {
     // bail if all fonts with have been processed. We're checking just a
     // subset of the font properties (though it should be enough), so it
     // can technically bail on a false positive.
@@ -127,12 +140,40 @@ export class Fonts {
 
   /**
    * Load font faces for a given scene and trigger scene update.
+   *
+   * FontFaceSet loadingdone event we listen on may not always
+   * fire (looking at you Safari), so on init we manually load all
+   * fonts and rerender scene text elements once done.
+   *
+   * For Safari we make sure to check against each loaded font face
+   * with the unique characters per family in the scene,
+   * otherwise fonts might remain unloaded.
    */
   public loadSceneFonts = async (): Promise<FontFace[]> => {
     const sceneFamilies = this.getSceneFamilies();
-    const loaded = await Fonts.loadFontFaces(sceneFamilies);
-    this.onLoaded(loaded);
-    return loaded;
+    const charsPerFamily = isSafari
+      ? Fonts.getCharsPerFamily(this.scene.getNonDeletedElements())
+      : undefined;
+
+    return Fonts.loadFontFaces(sceneFamilies, charsPerFamily);
+  };
+
+  /**
+   * Load font faces for passed elements - use when the scene is unavailable (i.e. export).
+   *
+   * For Safari we make sure to check against each loaded font face,
+   * with the unique characters per family in the elements
+   * otherwise fonts might remain unloaded.
+   */
+  public static loadElementsFonts = async (
+    elements: readonly ExcalidrawElement[],
+  ): Promise<FontFace[]> => {
+    const fontFamilies = Fonts.getUniqueFamilies(elements);
+    const charsPerFamily = isSafari
+      ? Fonts.getCharsPerFamily(elements)
+      : undefined;
+
+    return Fonts.loadFontFaces(fontFamilies, charsPerFamily);
   };
 
   /**
@@ -144,17 +185,48 @@ export class Fonts {
   };
 
   /**
-   * Load font faces for passed elements - use when the scene is unavailable (i.e. export).
+   * Generate CSS @font-face declarations for the given elements.
    */
-  public static loadElementsFonts = async (
+  public static async generateFontFaceDeclarations(
     elements: readonly ExcalidrawElement[],
-  ): Promise<FontFace[]> => {
-    const fontFamilies = Fonts.getElementsFamilies(elements);
-    return await Fonts.loadFontFaces(fontFamilies);
-  };
+  ) {
+    const families = Fonts.getUniqueFamilies(elements);
+    const charsPerFamily = Fonts.getCharsPerFamily(elements);
+
+    // for simplicity, assuming we have just one family with the CJK handdrawn fallback
+    const familyWithCJK = families.find((x) =>
+      getFontFamilyFallbacks(x).includes(CJK_HAND_DRAWN_FALLBACK_FONT),
+    );
+
+    if (familyWithCJK) {
+      const characters = Fonts.getCharacters(charsPerFamily, familyWithCJK);
+
+      if (containsCJK(characters)) {
+        const family = FONT_FAMILY_FALLBACKS[CJK_HAND_DRAWN_FALLBACK_FONT];
+
+        // adding the same characters to the CJK handrawn family
+        charsPerFamily[family] = new Set(characters);
+
+        // the order between the families and fallbacks is important, as fallbacks need to be defined first and in the reversed order
+        // so that they get overriden with the later defined font faces, i.e. in case they share some codepoints
+        families.unshift(FONT_FAMILY_FALLBACKS[CJK_HAND_DRAWN_FALLBACK_FONT]);
+      }
+    }
+
+    // don't trigger hundreds of concurrent requests (each performing fetch, creating a worker, etc.),
+    // instead go three requests at a time, in a controlled manner, without completely blocking the main thread
+    // and avoiding potential issues such as rate limits
+    const iterator = Fonts.fontFacesStylesGenerator(families, charsPerFamily);
+    const concurrency = 3;
+    const fontFaces = await new PromisePool(iterator, concurrency).all();
+
+    // dedup just in case (i.e. could be the same font faces with 0 glyphs)
+    return Array.from(new Set(fontFaces));
+  }
 
   private static async loadFontFaces(
     fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
+    charsPerFamily?: Record<number, Set<string>>,
   ) {
     // add all registered font faces into the `document.fonts` (if not added already)
     for (const { fontFaces, metadata } of Fonts.registered.values()) {
@@ -170,81 +242,96 @@ export class Fonts {
       }
     }
 
-    const loadedFontFaces = await Promise.all(
-      fontFamilies.map(async (fontFamily) => {
-        const fontString = getFontString({
-          fontFamily,
-          fontSize: 16,
-        });
+    // loading 10 font faces at a time, in a controlled manner
+    const iterator = Fonts.fontFacesLoader(fontFamilies, charsPerFamily);
+    const concurrency = 10;
+    const fontFaces = await new PromisePool(iterator, concurrency).all();
+    return fontFaces.flat().filter(Boolean);
+  }
+
+  private static *fontFacesLoader(
+    fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
+    charsPerFamily?: Record<number, Set<string>>,
+  ): Generator<Promise<void | readonly [number, FontFace[]]>> {
+    for (const [index, fontFamily] of fontFamilies.entries()) {
+      const font = getFontString({
+        fontFamily,
+        fontSize: 16,
+      });
+
+      // WARN: without "text" param it does not have to mean that all font faces are loaded, instead it could be just one!
+      // for Safari on init, we rather check with the "text" param, even though it's less efficient, as otherwise fonts might remain unloaded
+      const text =
+        isSafari && charsPerFamily
+          ? Fonts.getCharacters(charsPerFamily, fontFamily)
+          : "";
 
-        // WARN: without "text" param it does not have to mean that all font faces are loaded, instead it could be just one!
-        if (!window.document.fonts.check(fontString)) {
+      if (!window.document.fonts.check(font, text)) {
+        yield promiseTry(async () => {
           try {
             // WARN: browser prioritizes loading only font faces with unicode ranges for characters which are present in the document (html & canvas), other font faces could stay unloaded
             // we might want to retry here, i.e.  in case CDN is down, but so far I didn't experience any issues - maybe it handles retry-like logic under the hood
-            return await window.document.fonts.load(fontString);
+            const fontFaces = await window.document.fonts.load(font, text);
+
+            return [index, fontFaces];
           } catch (e) {
             // don't let it all fail if just one font fails to load
             console.error(
-              `Failed to load font "${fontString}" from urls "${Fonts.registered
+              `Failed to load font "${font}" from urls "${Fonts.registered
                 .get(fontFamily)
                 ?.fontFaces.map((x) => x.urls)}"`,
               e,
             );
           }
-        }
-
-        return Promise.resolve();
-      }),
-    );
-
-    return loadedFontFaces.flat().filter(Boolean) as FontFace[];
+        });
+      }
+    }
   }
 
-  /**
-   * WARN: should be called just once on init, even across multiple instances.
-   */
-  private static init() {
-    const fonts = {
-      registered: new Map<
-        ValueOf<typeof FONT_FAMILY | typeof FONT_FAMILY_FALLBACKS>,
-        { metadata: FontMetadata; fontFaces: ExcalidrawFontFace[] }
-      >(),
-    };
-
-    const init = (
-      family: keyof typeof FONT_FAMILY | keyof typeof FONT_FAMILY_FALLBACKS,
-      ...fontFacesDescriptors: ExcalidrawFontFaceDescriptor[]
-    ) => {
-      const fontFamily =
-        FONT_FAMILY[family as keyof typeof FONT_FAMILY] ??
-        FONT_FAMILY_FALLBACKS[family as keyof typeof FONT_FAMILY_FALLBACKS];
-
-      // default to Excalifont metrics
-      const metadata =
-        FONT_METADATA[fontFamily] ?? FONT_METADATA[FONT_FAMILY.Excalifont];
+  private static *fontFacesStylesGenerator(
+    families: Array<number>,
+    charsPerFamily: Record<number, Set<string>>,
+  ): Generator<Promise<void | readonly [number, string]>> {
+    for (const [familyIndex, family] of families.entries()) {
+      const { fontFaces, metadata } = Fonts.registered.get(family) ?? {};
+
+      if (!Array.isArray(fontFaces)) {
+        console.error(
+          `Couldn't find registered fonts for font-family "${family}"`,
+          Fonts.registered,
+        );
+        continue;
+      }
 
-      Fonts.register.call(fonts, family, metadata, ...fontFacesDescriptors);
-    };
+      if (metadata?.local) {
+        // don't inline local fonts
+        continue;
+      }
 
-    init("Cascadia", ...CascadiaFontFaces);
-    init("Comic Shanns", ...ComicShannsFontFaces);
-    init("Excalifont", ...ExcalifontFontFaces);
-    // keeping for backwards compatibility reasons, uses system font (Helvetica on MacOS, Arial on Win)
-    init("Helvetica", ...HelveticaFontFaces);
-    // used for server-side pdf & png export instead of helvetica (technically does not need metrics, but kept in for consistency)
-    init("Liberation Sans", ...LiberationFontFaces);
-    init("Lilita One", ...LilitaFontFaces);
-    init("Nunito", ...NunitoFontFaces);
-    init("Virgil", ...VirgilFontFaces);
+      for (const [fontFaceIndex, fontFace] of fontFaces.entries()) {
+        yield promiseTry(async () => {
+          try {
+            const characters = Fonts.getCharacters(charsPerFamily, family);
+            const fontFaceCSS = await fontFace.toCSS(characters);
 
-    // fallback font faces
-    init(CJK_HAND_DRAWN_FALLBACK_FONT, ...XiaolaiFontFaces);
-    init(WINDOWS_EMOJI_FALLBACK_FONT, ...EmojiFontFaces);
+            if (!fontFaceCSS) {
+              return;
+            }
 
-    Fonts._initialized = true;
+            // giving a buffer of 10K font faces per family
+            const fontFaceOrder = familyIndex * 10_000 + fontFaceIndex;
+            const fontFaceTuple = [fontFaceOrder, fontFaceCSS] as const;
 
-    return fonts.registered;
+            return fontFaceTuple;
+          } catch (error) {
+            console.error(
+              `Couldn't transform font-face to css for family "${fontFace.fontFace.family}"`,
+              error,
+            );
+          }
+        });
+      }
+    }
   }
 
   /**
@@ -288,17 +375,55 @@ export class Fonts {
   }
 
   /**
-   * Gets all the font families for the given scene.
+   * WARN: should be called just once on init, even across multiple instances.
    */
-  public getSceneFamilies = () => {
-    return Fonts.getElementsFamilies(this.scene.getNonDeletedElements());
-  };
+  private static init() {
+    const fonts = {
+      registered: new Map<
+        ValueOf<typeof FONT_FAMILY | typeof FONT_FAMILY_FALLBACKS>,
+        { metadata: FontMetadata; fontFaces: ExcalidrawFontFace[] }
+      >(),
+    };
 
-  private static getAllFamilies() {
-    return Array.from(Fonts.registered.keys());
+    const init = (
+      family: keyof typeof FONT_FAMILY | keyof typeof FONT_FAMILY_FALLBACKS,
+      ...fontFacesDescriptors: ExcalidrawFontFaceDescriptor[]
+    ) => {
+      const fontFamily =
+        FONT_FAMILY[family as keyof typeof FONT_FAMILY] ??
+        FONT_FAMILY_FALLBACKS[family as keyof typeof FONT_FAMILY_FALLBACKS];
+
+      // default to Excalifont metrics
+      const metadata =
+        FONT_METADATA[fontFamily] ?? FONT_METADATA[FONT_FAMILY.Excalifont];
+
+      Fonts.register.call(fonts, family, metadata, ...fontFacesDescriptors);
+    };
+
+    init("Cascadia", ...CascadiaFontFaces);
+    init("Comic Shanns", ...ComicShannsFontFaces);
+    init("Excalifont", ...ExcalifontFontFaces);
+    // keeping for backwards compatibility reasons, uses system font (Helvetica on MacOS, Arial on Win)
+    init("Helvetica", ...HelveticaFontFaces);
+    // used for server-side pdf & png export instead of helvetica (technically does not need metrics, but kept in for consistency)
+    init("Liberation Sans", ...LiberationFontFaces);
+    init("Lilita One", ...LilitaFontFaces);
+    init("Nunito", ...NunitoFontFaces);
+    init("Virgil", ...VirgilFontFaces);
+
+    // fallback font faces
+    init(CJK_HAND_DRAWN_FALLBACK_FONT, ...XiaolaiFontFaces);
+    init(WINDOWS_EMOJI_FALLBACK_FONT, ...EmojiFontFaces);
+
+    Fonts._initialized = true;
+
+    return fonts.registered;
   }
 
-  private static getElementsFamilies(
+  /**
+   * Get all the unique font families for the given elements.
+   */
+  private static getUniqueFamilies(
     elements: ReadonlyArray<ExcalidrawElement>,
   ): Array<ExcalidrawTextElement["fontFamily"]> {
     return Array.from(
@@ -310,6 +435,51 @@ export class Fonts {
       }, new Set<number>()),
     );
   }
+
+  /**
+   * Get all the unique characters per font family for the given scene.
+   */
+  private static getCharsPerFamily(
+    elements: ReadonlyArray<ExcalidrawElement>,
+  ): Record<number, Set<string>> {
+    const charsPerFamily: Record<number, Set<string>> = {};
+
+    for (const element of elements) {
+      if (!isTextElement(element)) {
+        continue;
+      }
+
+      // gather unique codepoints only when inlining fonts
+      for (const char of element.originalText) {
+        if (!charsPerFamily[element.fontFamily]) {
+          charsPerFamily[element.fontFamily] = new Set();
+        }
+
+        charsPerFamily[element.fontFamily].add(char);
+      }
+    }
+
+    return charsPerFamily;
+  }
+
+  /**
+   * Get characters for a given family.
+   */
+  private static getCharacters(
+    charsPerFamily: Record<number, Set<string>>,
+    family: number,
+  ) {
+    return charsPerFamily[family]
+      ? Array.from(charsPerFamily[family]).join("")
+      : "";
+  }
+
+  /**
+   * Get all registered font families.
+   */
+  private static getAllFamilies() {
+    return Array.from(Fonts.registered.keys());
+  }
 }
 
 /**

+ 6 - 122
packages/excalidraw/scene/export.ts

@@ -9,14 +9,7 @@ import type {
 import type { Bounds } from "../element/bounds";
 import { getCommonBounds, getElementAbsoluteCoords } from "../element/bounds";
 import { renderSceneToSvg } from "../renderer/staticSvgScene";
-import {
-  arrayToMap,
-  distance,
-  getFontString,
-  PromisePool,
-  promiseTry,
-  toBrandedType,
-} from "../utils";
+import { arrayToMap, distance, getFontString, toBrandedType } from "../utils";
 import type { AppState, BinaryFiles } from "../types";
 import {
   DEFAULT_EXPORT_PADDING,
@@ -25,9 +18,6 @@ import {
   SVG_NS,
   THEME,
   THEME_FILTER,
-  FONT_FAMILY_FALLBACKS,
-  getFontFamilyFallbacks,
-  CJK_HAND_DRAWN_FALLBACK_FONT,
 } from "../constants";
 import { getDefaultAppState } from "../appState";
 import { serializeAsJSON } from "../data/json";
@@ -44,12 +34,11 @@ import {
 import { newTextElement } from "../element";
 import { type Mutable } from "../utility-types";
 import { newElementWith } from "../element/mutateElement";
-import { isFrameLikeElement, isTextElement } from "../element/typeChecks";
+import { isFrameLikeElement } from "../element/typeChecks";
 import type { RenderableElementsMap } from "./types";
 import { syncInvalidIndices } from "../fractionalIndex";
 import { renderStaticScene } from "../renderer/staticScene";
 import { Fonts } from "../fonts";
-import { containsCJK } from "../element/textElement";
 
 const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
 
@@ -375,7 +364,10 @@ export const exportToSvg = async (
         </clipPath>`;
   }
 
-  const fontFaces = opts?.skipInliningFonts ? [] : await getFontFaces(elements);
+  const fontFaces = !opts?.skipInliningFonts
+    ? await Fonts.generateFontFaceDeclarations(elements)
+    : [];
+
   const delimiter = "\n      "; // 6 spaces
 
   svgRoot.innerHTML = `
@@ -454,111 +446,3 @@ export const getExportSize = (
 
   return [width, height];
 };
-
-const getFontFaces = async (
-  elements: readonly ExcalidrawElement[],
-): Promise<string[]> => {
-  const fontFamilies = new Set<number>();
-  const charsPerFamily: Record<number, Set<string>> = {};
-
-  for (const element of elements) {
-    if (!isTextElement(element)) {
-      continue;
-    }
-
-    fontFamilies.add(element.fontFamily);
-
-    // gather unique codepoints only when inlining fonts
-    for (const char of element.originalText) {
-      if (!charsPerFamily[element.fontFamily]) {
-        charsPerFamily[element.fontFamily] = new Set();
-      }
-
-      charsPerFamily[element.fontFamily].add(char);
-    }
-  }
-
-  const orderedFamilies = Array.from(fontFamilies);
-
-  // for simplicity, assuming we have just one family with the CJK handdrawn fallback
-  const familyWithCJK = orderedFamilies.find((x) =>
-    getFontFamilyFallbacks(x).includes(CJK_HAND_DRAWN_FALLBACK_FONT),
-  );
-
-  if (familyWithCJK) {
-    const characters = getChars(charsPerFamily[familyWithCJK]);
-
-    if (containsCJK(characters)) {
-      const family = FONT_FAMILY_FALLBACKS[CJK_HAND_DRAWN_FALLBACK_FONT];
-
-      // adding the same characters to the CJK handrawn family
-      charsPerFamily[family] = new Set(characters);
-
-      // the order between the families and fallbacks is important, as fallbacks need to be defined first and in the reversed order
-      // so that they get overriden with the later defined font faces, i.e. in case they share some codepoints
-      orderedFamilies.unshift(
-        FONT_FAMILY_FALLBACKS[CJK_HAND_DRAWN_FALLBACK_FONT],
-      );
-    }
-  }
-
-  const iterator = fontFacesIterator(orderedFamilies, charsPerFamily);
-
-  // don't trigger hundreds of concurrent requests (each performing fetch, creating a worker, etc.),
-  // instead go three requests at a time, in a controlled manner, without completely blocking the main thread
-  // and avoiding potential issues such as rate limits
-  const concurrency = 3;
-  const fontFaces = await new PromisePool(iterator, concurrency).all();
-
-  // dedup just in case (i.e. could be the same font faces with 0 glyphs)
-  return Array.from(new Set(fontFaces));
-};
-
-function* fontFacesIterator(
-  families: Array<number>,
-  charsPerFamily: Record<number, Set<string>>,
-): Generator<Promise<void | readonly [number, string]>> {
-  for (const [familyIndex, family] of families.entries()) {
-    const { fontFaces, metadata } = Fonts.registered.get(family) ?? {};
-
-    if (!Array.isArray(fontFaces)) {
-      console.error(
-        `Couldn't find registered fonts for font-family "${family}"`,
-        Fonts.registered,
-      );
-      continue;
-    }
-
-    if (metadata?.local) {
-      // don't inline local fonts
-      continue;
-    }
-
-    for (const [fontFaceIndex, fontFace] of fontFaces.entries()) {
-      yield promiseTry(async () => {
-        try {
-          const characters = getChars(charsPerFamily[family]);
-          const fontFaceCSS = await fontFace.toCSS(characters);
-
-          if (!fontFaceCSS) {
-            return;
-          }
-
-          // giving a buffer of 10K font faces per family
-          const fontFaceOrder = familyIndex * 10_000 + fontFaceIndex;
-          const fontFaceTuple = [fontFaceOrder, fontFaceCSS] as const;
-
-          return fontFaceTuple;
-        } catch (error) {
-          console.error(
-            `Couldn't transform font-face to css for family "${fontFace.fontFace.family}"`,
-            error,
-          );
-        }
-      });
-    }
-  }
-}
-
-const getChars = (characterSet: Set<string>) =>
-  Array.from(characterSet).join("");