mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
fix: load font faces in Safari manually (#8693)
This commit is contained in:
parent
79b181bcdc
commit
03028eaa8c
4 changed files with 269 additions and 207 deletions
|
@ -49,7 +49,7 @@ import {
|
||||||
} from "../appState";
|
} from "../appState";
|
||||||
import type { PastedMixedContent } from "../clipboard";
|
import type { PastedMixedContent } from "../clipboard";
|
||||||
import { copyTextToSystemClipboard, parseClipboard } 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 {
|
import {
|
||||||
APP_NAME,
|
APP_NAME,
|
||||||
CURSOR_TYPE,
|
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
|
// clear the shape and image cache so that any images in initialData
|
||||||
// can be loaded fresh
|
// can be loaded fresh
|
||||||
this.clearImageShapeCache();
|
this.clearImageShapeCache();
|
||||||
// FontFaceSet loadingdone event we listen on may not always
|
|
||||||
// fire (looking at you Safari), so on init we manually load all
|
// manually loading the font faces seems faster even in browsers that do fire the loadingdone event
|
||||||
// fonts and rerender scene text elements once done. This also
|
this.fonts.loadSceneFonts().then((fontFaces) => {
|
||||||
// seems faster even in browsers that do fire the loadingdone event.
|
this.fonts.onLoaded(fontFaces);
|
||||||
this.fonts.loadSceneFonts();
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
private isMobileBreakpoint = (width: number, height: number) => {
|
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
|
// rerender text elements on font load to fix #637 && #1553
|
||||||
addEventListener(document.fonts, "loadingdone", (event) => {
|
addEventListener(document.fonts, "loadingdone", (event) => {
|
||||||
const loadedFontFaces = (event as FontFaceSetLoadEvent).fontfaces;
|
const fontFaces = (event as FontFaceSetLoadEvent).fontfaces;
|
||||||
this.fonts.onLoaded(loadedFontFaces);
|
this.fonts.onLoaded(fontFaces);
|
||||||
}),
|
}),
|
||||||
// Safari-only desktop pinch zoom
|
// Safari-only desktop pinch zoom
|
||||||
addEventListener(
|
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) {
|
if (opts.files) {
|
||||||
this.addMissingFiles(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 { AppProps, AppState } from "./types";
|
||||||
import type { ExcalidrawElement, FontFamilyValues } from "./element/types";
|
import type { ExcalidrawElement, FontFamilyValues } from "./element/types";
|
||||||
import { COLOR_PALETTE } from "./colors";
|
import { COLOR_PALETTE } from "./colors";
|
||||||
|
|
||||||
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||||
export const isWindows = /^Win/.test(navigator.platform);
|
export const isWindows = /^Win/.test(navigator.platform);
|
||||||
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
|
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
|
||||||
|
|
|
@ -3,11 +3,17 @@ import {
|
||||||
FONT_FAMILY_FALLBACKS,
|
FONT_FAMILY_FALLBACKS,
|
||||||
CJK_HAND_DRAWN_FALLBACK_FONT,
|
CJK_HAND_DRAWN_FALLBACK_FONT,
|
||||||
WINDOWS_EMOJI_FALLBACK_FONT,
|
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||||
|
isSafari,
|
||||||
|
getFontFamilyFallbacks,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { isTextElement } from "../element";
|
import { isTextElement } from "../element";
|
||||||
import { charWidth, getContainerElement } from "../element/textElement";
|
import {
|
||||||
|
charWidth,
|
||||||
|
containsCJK,
|
||||||
|
getContainerElement,
|
||||||
|
} from "../element/textElement";
|
||||||
import { ShapeCache } from "../scene/ShapeCache";
|
import { ShapeCache } from "../scene/ShapeCache";
|
||||||
import { getFontString } from "../utils";
|
import { getFontString, PromisePool, promiseTry } from "../utils";
|
||||||
import { ExcalidrawFontFace } from "./ExcalidrawFontFace";
|
import { ExcalidrawFontFace } from "./ExcalidrawFontFace";
|
||||||
|
|
||||||
import { CascadiaFontFaces } from "./Cascadia";
|
import { CascadiaFontFaces } from "./Cascadia";
|
||||||
|
@ -73,6 +79,13 @@ export class Fonts {
|
||||||
this.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
|
* 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
|
* 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
|
* Invalidates text elements and rerenders scene, provided that at least one
|
||||||
* of the supplied fontFaces has not already been processed.
|
* 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
|
// 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
|
// subset of the font properties (though it should be enough), so it
|
||||||
// can technically bail on a false positive.
|
// 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.
|
* 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[]> => {
|
public loadSceneFonts = async (): Promise<FontFace[]> => {
|
||||||
const sceneFamilies = this.getSceneFamilies();
|
const sceneFamilies = this.getSceneFamilies();
|
||||||
const loaded = await Fonts.loadFontFaces(sceneFamilies);
|
const charsPerFamily = isSafari
|
||||||
this.onLoaded(loaded);
|
? Fonts.getCharsPerFamily(this.scene.getNonDeletedElements())
|
||||||
return loaded;
|
: 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[],
|
elements: readonly ExcalidrawElement[],
|
||||||
): Promise<FontFace[]> => {
|
) {
|
||||||
const fontFamilies = Fonts.getElementsFamilies(elements);
|
const families = Fonts.getUniqueFamilies(elements);
|
||||||
return await Fonts.loadFontFaces(fontFamilies);
|
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(
|
private static async loadFontFaces(
|
||||||
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
|
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
|
||||||
|
charsPerFamily?: Record<number, Set<string>>,
|
||||||
) {
|
) {
|
||||||
// add all registered font faces into the `document.fonts` (if not added already)
|
// add all registered font faces into the `document.fonts` (if not added already)
|
||||||
for (const { fontFaces, metadata } of Fonts.registered.values()) {
|
for (const { fontFaces, metadata } of Fonts.registered.values()) {
|
||||||
|
@ -170,35 +242,136 @@ export class Fonts {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadedFontFaces = await Promise.all(
|
// loading 10 font faces at a time, in a controlled manner
|
||||||
fontFamilies.map(async (fontFamily) => {
|
const iterator = Fonts.fontFacesLoader(fontFamilies, charsPerFamily);
|
||||||
const fontString = getFontString({
|
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,
|
fontFamily,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
});
|
});
|
||||||
|
|
||||||
// WARN: without "text" param it does not have to mean that all font faces are loaded, instead it could be just one!
|
// 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)) {
|
// 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 {
|
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
|
// 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
|
// 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) {
|
} catch (e) {
|
||||||
// don't let it all fail if just one font fails to load
|
// don't let it all fail if just one font fails to load
|
||||||
console.error(
|
console.error(
|
||||||
`Failed to load font "${fontString}" from urls "${Fonts.registered
|
`Failed to load font "${font}" from urls "${Fonts.registered
|
||||||
.get(fontFamily)
|
.get(fontFamily)
|
||||||
?.fontFaces.map((x) => x.urls)}"`,
|
?.fontFaces.map((x) => x.urls)}"`,
|
||||||
e,
|
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.
|
* Get all the unique font families for the given elements.
|
||||||
*
|
|
||||||
* @param family font family
|
|
||||||
* @param metadata font metadata
|
|
||||||
* @param fontFacesDecriptors font faces descriptors
|
|
||||||
*/
|
*/
|
||||||
private static register(
|
private static getUniqueFamilies(
|
||||||
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(
|
|
||||||
elements: ReadonlyArray<ExcalidrawElement>,
|
elements: ReadonlyArray<ExcalidrawElement>,
|
||||||
): Array<ExcalidrawTextElement["fontFamily"]> {
|
): Array<ExcalidrawTextElement["fontFamily"]> {
|
||||||
return Array.from(
|
return Array.from(
|
||||||
|
@ -310,6 +435,51 @@ export class Fonts {
|
||||||
}, new Set<number>()),
|
}, 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 type { Bounds } from "../element/bounds";
|
||||||
import { getCommonBounds, getElementAbsoluteCoords } from "../element/bounds";
|
import { getCommonBounds, getElementAbsoluteCoords } from "../element/bounds";
|
||||||
import { renderSceneToSvg } from "../renderer/staticSvgScene";
|
import { renderSceneToSvg } from "../renderer/staticSvgScene";
|
||||||
import {
|
import { arrayToMap, distance, getFontString, toBrandedType } from "../utils";
|
||||||
arrayToMap,
|
|
||||||
distance,
|
|
||||||
getFontString,
|
|
||||||
PromisePool,
|
|
||||||
promiseTry,
|
|
||||||
toBrandedType,
|
|
||||||
} from "../utils";
|
|
||||||
import type { AppState, BinaryFiles } from "../types";
|
import type { AppState, BinaryFiles } from "../types";
|
||||||
import {
|
import {
|
||||||
DEFAULT_EXPORT_PADDING,
|
DEFAULT_EXPORT_PADDING,
|
||||||
|
@ -25,9 +18,6 @@ import {
|
||||||
SVG_NS,
|
SVG_NS,
|
||||||
THEME,
|
THEME,
|
||||||
THEME_FILTER,
|
THEME_FILTER,
|
||||||
FONT_FAMILY_FALLBACKS,
|
|
||||||
getFontFamilyFallbacks,
|
|
||||||
CJK_HAND_DRAWN_FALLBACK_FONT,
|
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { serializeAsJSON } from "../data/json";
|
import { serializeAsJSON } from "../data/json";
|
||||||
|
@ -44,12 +34,11 @@ import {
|
||||||
import { newTextElement } from "../element";
|
import { newTextElement } from "../element";
|
||||||
import { type Mutable } from "../utility-types";
|
import { type Mutable } from "../utility-types";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import { isFrameLikeElement, isTextElement } from "../element/typeChecks";
|
import { isFrameLikeElement } from "../element/typeChecks";
|
||||||
import type { RenderableElementsMap } from "./types";
|
import type { RenderableElementsMap } from "./types";
|
||||||
import { syncInvalidIndices } from "../fractionalIndex";
|
import { syncInvalidIndices } from "../fractionalIndex";
|
||||||
import { renderStaticScene } from "../renderer/staticScene";
|
import { renderStaticScene } from "../renderer/staticScene";
|
||||||
import { Fonts } from "../fonts";
|
import { Fonts } from "../fonts";
|
||||||
import { containsCJK } from "../element/textElement";
|
|
||||||
|
|
||||||
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
|
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
|
||||||
|
|
||||||
|
@ -375,7 +364,10 @@ export const exportToSvg = async (
|
||||||
</clipPath>`;
|
</clipPath>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fontFaces = opts?.skipInliningFonts ? [] : await getFontFaces(elements);
|
const fontFaces = !opts?.skipInliningFonts
|
||||||
|
? await Fonts.generateFontFaceDeclarations(elements)
|
||||||
|
: [];
|
||||||
|
|
||||||
const delimiter = "\n "; // 6 spaces
|
const delimiter = "\n "; // 6 spaces
|
||||||
|
|
||||||
svgRoot.innerHTML = `
|
svgRoot.innerHTML = `
|
||||||
|
@ -454,111 +446,3 @@ export const getExportSize = (
|
||||||
|
|
||||||
return [width, height];
|
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("");
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue