import { FONT_FAMILY, FONT_FAMILY_FALLBACKS, CJK_HAND_DRAWN_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT, getFontFamilyFallbacks, } from "../constants"; import { isTextElement } from "../element"; import { charWidth, getContainerElement } from "../element/textElement"; import { containsCJK } from "../element/textWrapping"; import { ShapeCache } from "../scene/ShapeCache"; import { getFontString, PromisePool, promiseTry } from "../utils"; import { ExcalidrawFontFace } from "./ExcalidrawFontFace"; import { CascadiaFontFaces } from "./Cascadia"; import { ComicShannsFontFaces } from "./ComicShanns"; import { EmojiFontFaces } from "./Emoji"; import { ExcalifontFontFaces } from "./Excalifont"; import { HelveticaFontFaces } from "./Helvetica"; import { LiberationFontFaces } from "./Liberation"; import { LilitaFontFaces } from "./Lilita"; import { NunitoFontFaces } from "./Nunito"; import { VirgilFontFaces } from "./Virgil"; import { XiaolaiFontFaces } from "./Xiaolai"; import { FONT_METADATA, type FontMetadata } from "./FontMetadata"; import type { ExcalidrawElement, ExcalidrawTextElement, FontFamilyValues, } from "../element/types"; import type Scene from "../scene/Scene"; import type { ValueOf } from "../utility-types"; export class Fonts { // it's ok to track fonts across multiple instances only once, so let's use // a static member to reduce memory footprint public static readonly loadedFontsCache = new Set(); private static _registered: | Map< number, { metadata: FontMetadata; fontFaces: ExcalidrawFontFace[]; } > | undefined; private static _initialized: boolean = false; public static get registered() { // lazy load the font registration if (!Fonts._registered) { Fonts._registered = Fonts.init(); } else if (!Fonts._initialized) { // case when host app register fonts before they are lazy loaded // don't override whatever has been previously registered Fonts._registered = new Map([ ...Fonts.init().entries(), ...Fonts._registered.entries(), ]); } return Fonts._registered; } public get registered() { return Fonts.registered; } private readonly scene: Scene; constructor(scene: Scene) { 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 * their shapes and rerender. See #637. * * 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[]): 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. let shouldBail = true; for (const fontFace of fontFaces) { const sig = `${fontFace.family}-${fontFace.style}-${fontFace.weight}-${fontFace.unicodeRange}`; // make sure to update our cache with all the loaded font faces if (!Fonts.loadedFontsCache.has(sig)) { Fonts.loadedFontsCache.add(sig); shouldBail = false; } } if (shouldBail) { return; } let didUpdate = false; const elementsMap = this.scene.getNonDeletedElementsMap(); for (const element of this.scene.getNonDeletedElements()) { if (isTextElement(element)) { didUpdate = true; ShapeCache.delete(element); // clear the width cache, so that we don't perform subsequent wrapping based on the stale fallback font metrics charWidth.clearCache(getFontString(element)); const container = getContainerElement(element, elementsMap); if (container) { ShapeCache.delete(container); } } } if (didUpdate) { this.scene.triggerUpdate(); } }; /** * Load font faces for a given scene and trigger scene update. */ public loadSceneFonts = async (): Promise => { const sceneFamilies = this.getSceneFamilies(); const charsPerFamily = Fonts.getCharsPerFamily( this.scene.getNonDeletedElements(), ); return Fonts.loadFontFaces(sceneFamilies, charsPerFamily); }; /** * Load font faces for passed elements - use when the scene is unavailable (i.e. export). */ public static loadElementsFonts = async ( elements: readonly ExcalidrawElement[], ): Promise => { const fontFamilies = Fonts.getUniqueFamilies(elements); const charsPerFamily = Fonts.getCharsPerFamily(elements); return Fonts.loadFontFaces(fontFamilies, charsPerFamily); }; /** * Generate CSS @font-face declarations for the given elements. */ public static async generateFontFaceDeclarations( elements: readonly ExcalidrawElement[], ) { 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()) { // skip registering font faces for local fonts (i.e. Helvetica) if (metadata.local) { continue; } for (const { fontFace } of fontFaces) { if (!window.document.fonts.has(fontFace)) { window.document.fonts.add(fontFace); } } } // 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 as it could be just one irrelevant font face! // instead, we are always checking chars used in the family, so that no required font faces remain unloaded const text = 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 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 "${font}" from urls "${Fonts.registered .get(fontFamily) ?.fontFaces.map((x) => x.urls)}"`, e, ); } }); } } } 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; } 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; } /** * 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]; 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; } /** * Get all the unique font families for the given elements. */ private static getUniqueFamilies( elements: ReadonlyArray, ): Array { return Array.from( elements.reduce((families, element) => { if (isTextElement(element)) { families.add(element.fontFamily); } return families; }, 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()); } } /** * Calculates vertical offset for a text with alphabetic baseline. */ export const getVerticalOffset = ( fontFamily: ExcalidrawTextElement["fontFamily"], fontSize: ExcalidrawTextElement["fontSize"], lineHeightPx: number, ) => { const { unitsPerEm, ascender, descender } = Fonts.registered.get(fontFamily)?.metadata.metrics || FONT_METADATA[FONT_FAMILY.Virgil].metrics; const fontSizeEm = fontSize / unitsPerEm; const lineGap = (lineHeightPx - fontSizeEm * ascender + fontSizeEm * descender) / 2; const verticalOffset = fontSizeEm * ascender + lineGap; return verticalOffset; }; /** * Gets line height forr a selected family. */ export const getLineHeight = (fontFamily: FontFamilyValues) => { const { lineHeight } = Fonts.registered.get(fontFamily)?.metadata.metrics || FONT_METADATA[FONT_FAMILY.Excalifont].metrics; return lineHeight as ExcalidrawTextElement["lineHeight"]; }; export interface ExcalidrawFontFaceDescriptor { uri: string; descriptors?: FontFaceDescriptors; }