aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/fonts/Fonts.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/excalidraw/fonts/Fonts.ts')
-rw-r--r--packages/excalidraw/fonts/Fonts.ts491
1 files changed, 491 insertions, 0 deletions
diff --git a/packages/excalidraw/fonts/Fonts.ts b/packages/excalidraw/fonts/Fonts.ts
new file mode 100644
index 0000000..4b8ba78
--- /dev/null
+++ b/packages/excalidraw/fonts/Fonts.ts
@@ -0,0 +1,491 @@
+import {
+ FONT_FAMILY,
+ FONT_FAMILY_FALLBACKS,
+ CJK_HAND_DRAWN_FALLBACK_FONT,
+ WINDOWS_EMOJI_FALLBACK_FONT,
+ getFontFamilyFallbacks,
+} from "../constants";
+import { isTextElement } from "../element";
+import { 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";
+import { charWidth } from "../element/textMeasurements";
+
+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<string>();
+
+ 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<FontFace[]> => {
+ 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<FontFace[]> => {
+ 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<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()) {
+ // 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<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 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<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 = 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<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];
+
+ 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<ExcalidrawElement>,
+ ): Array<ExcalidrawTextElement["fontFamily"]> {
+ return Array.from(
+ elements.reduce((families, element) => {
+ if (isTextElement(element)) {
+ families.add(element.fontFamily);
+ }
+ return families;
+ }, 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());
+ }
+}
+
+/**
+ * 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;
+}