|
@@ -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());
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
/**
|