mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-11-04 04:44:31 +01:00 
			
		
		
		
	fix: load font faces in Safari manually (#8693)
This commit is contained in:
		@@ -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);
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -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);
 | 
			
		||||
 
 | 
			
		||||
@@ -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,35 +242,136 @@ 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);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
        // 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)) {
 | 
			
		||||
  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)
 | 
			
		||||
          : "";
 | 
			
		||||
 | 
			
		||||
      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();
 | 
			
		||||
      }),
 | 
			
		||||
    );
 | 
			
		||||
  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) ?? {};
 | 
			
		||||
 | 
			
		||||
    return loadedFontFaces.flat().filter(Boolean) as FontFace[];
 | 
			
		||||
      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 = Fonts.getCharacters(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,
 | 
			
		||||
            );
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Register a new font.
 | 
			
		||||
   *
 | 
			
		||||
   * @param family font family
 | 
			
		||||
   * @param metadata font metadata
 | 
			
		||||
   * @param fontFacesDecriptors font faces descriptors
 | 
			
		||||
   */
 | 
			
		||||
  private static register(
 | 
			
		||||
    this:
 | 
			
		||||
      | Fonts
 | 
			
		||||
      | {
 | 
			
		||||
          registered: Map<
 | 
			
		||||
            number,
 | 
			
		||||
            { metadata: FontMetadata; fontFaces: ExcalidrawFontFace[] }
 | 
			
		||||
          >;
 | 
			
		||||
        },
 | 
			
		||||
    family: string,
 | 
			
		||||
    metadata: FontMetadata,
 | 
			
		||||
    ...fontFacesDecriptors: ExcalidrawFontFaceDescriptor[]
 | 
			
		||||
  ) {
 | 
			
		||||
    // TODO: likely we will need to abandon number value in order to support custom fonts
 | 
			
		||||
    const fontFamily =
 | 
			
		||||
      FONT_FAMILY[family as keyof typeof FONT_FAMILY] ??
 | 
			
		||||
      FONT_FAMILY_FALLBACKS[family as keyof typeof FONT_FAMILY_FALLBACKS];
 | 
			
		||||
 | 
			
		||||
    const registeredFamily = this.registered.get(fontFamily);
 | 
			
		||||
 | 
			
		||||
    if (!registeredFamily) {
 | 
			
		||||
      this.registered.set(fontFamily, {
 | 
			
		||||
        metadata,
 | 
			
		||||
        fontFaces: fontFacesDecriptors.map(
 | 
			
		||||
          ({ uri, descriptors }) =>
 | 
			
		||||
            new ExcalidrawFontFace(family, uri, descriptors),
 | 
			
		||||
        ),
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return this.registered;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
@@ -248,57 +421,9 @@ export class Fonts {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Register a new font.
 | 
			
		||||
   *
 | 
			
		||||
   * @param family font family
 | 
			
		||||
   * @param metadata font metadata
 | 
			
		||||
   * @param fontFacesDecriptors font faces descriptors
 | 
			
		||||
   * Get all the unique font families for the given elements.
 | 
			
		||||
   */
 | 
			
		||||
  private static register(
 | 
			
		||||
    this:
 | 
			
		||||
      | Fonts
 | 
			
		||||
      | {
 | 
			
		||||
          registered: Map<
 | 
			
		||||
            number,
 | 
			
		||||
            { metadata: FontMetadata; fontFaces: ExcalidrawFontFace[] }
 | 
			
		||||
          >;
 | 
			
		||||
        },
 | 
			
		||||
    family: string,
 | 
			
		||||
    metadata: FontMetadata,
 | 
			
		||||
    ...fontFacesDecriptors: ExcalidrawFontFaceDescriptor[]
 | 
			
		||||
  ) {
 | 
			
		||||
    // TODO: likely we will need to abandon number value in order to support custom fonts
 | 
			
		||||
    const fontFamily =
 | 
			
		||||
      FONT_FAMILY[family as keyof typeof FONT_FAMILY] ??
 | 
			
		||||
      FONT_FAMILY_FALLBACKS[family as keyof typeof FONT_FAMILY_FALLBACKS];
 | 
			
		||||
 | 
			
		||||
    const registeredFamily = this.registered.get(fontFamily);
 | 
			
		||||
 | 
			
		||||
    if (!registeredFamily) {
 | 
			
		||||
      this.registered.set(fontFamily, {
 | 
			
		||||
        metadata,
 | 
			
		||||
        fontFaces: fontFacesDecriptors.map(
 | 
			
		||||
          ({ uri, descriptors }) =>
 | 
			
		||||
            new ExcalidrawFontFace(family, uri, descriptors),
 | 
			
		||||
        ),
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return this.registered;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Gets all the font families for the given scene.
 | 
			
		||||
   */
 | 
			
		||||
  public getSceneFamilies = () => {
 | 
			
		||||
    return Fonts.getElementsFamilies(this.scene.getNonDeletedElements());
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private static getAllFamilies() {
 | 
			
		||||
    return Array.from(Fonts.registered.keys());
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private static getElementsFamilies(
 | 
			
		||||
  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());
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 
 | 
			
		||||
@@ -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("");
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user