diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 6b6022794d..3cb187ab87 100644 --- a/packages/excalidraw/components/App.tsx +++ b/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 { // 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 { ), // 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 { } }); + // 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); } diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index 341316d12a..6bd8f1e99f 100644 --- a/packages/excalidraw/constants.ts +++ b/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); diff --git a/packages/excalidraw/fonts/Fonts.ts b/packages/excalidraw/fonts/Fonts.ts index 46b0f63c3d..31b5ad000d 100644 --- a/packages/excalidraw/fonts/Fonts.ts +++ b/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 => { 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 => { + 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 => { - 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, + charsPerFamily?: Record>, ) { // 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, + charsPerFamily?: Record>, + ): Generator> { + 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, - { 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, + charsPerFamily: Record>, + ): Generator> { + 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, + { 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, ): Array { return Array.from( @@ -310,6 +435,51 @@ export class Fonts { }, new Set()), ); } + + /** + * Get all the unique characters per font family for the given scene. + */ + private static getCharsPerFamily( + elements: ReadonlyArray, + ): Record> { + const charsPerFamily: Record> = {}; + + 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>, + family: number, + ) { + return charsPerFamily[family] + ? Array.from(charsPerFamily[family]).join("") + : ""; + } + + /** + * Get all registered font families. + */ + private static getAllFamilies() { + return Array.from(Fonts.registered.keys()); + } } /** diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index 43e737be56..c4ab1b8657 100644 --- a/packages/excalidraw/scene/export.ts +++ b/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 = ``; @@ -375,7 +364,10 @@ export const exportToSvg = async ( `; } - 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 => { - const fontFamilies = new Set(); - const charsPerFamily: Record> = {}; - - 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, - charsPerFamily: Record>, -): Generator> { - 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) => - Array.from(characterSet).join("");