1
0
mirror of https://github.com/excalidraw/excalidraw.git synced 2024-11-02 03:25:53 +01:00

Processing fallback fonts in the correct order

This commit is contained in:
Marcel Mraz 2024-08-27 17:49:50 +02:00
parent f40a8d0cab
commit 8ad59687bb
19 changed files with 280 additions and 122 deletions

@ -66,11 +66,11 @@ export const FontPickerList = React.memo(
.filter( .filter(
([_, { metadata }]) => !metadata.serverSide && !metadata.fallback, ([_, { metadata }]) => !metadata.serverSide && !metadata.fallback,
) )
.map(([familyId, { metadata, fonts }]) => { .map(([familyId, { metadata, fontFaces }]) => {
const fontDescriptor = { const fontDescriptor = {
value: familyId, value: familyId,
icon: metadata.icon ?? FontFamilyNormalIcon, icon: metadata.icon ?? FontFamilyNormalIcon,
text: fonts[0].fontFace.family, text: fontFaces[0]?.fontFace?.family ?? "Unknown",
}; };
if (metadata.deprecated) { if (metadata.deprecated) {

@ -114,6 +114,9 @@ export const CLASSES = {
SHAPE_ACTIONS_MENU: "App-menu__left", SHAPE_ACTIONS_MENU: "App-menu__left",
}; };
export const CHINESE_HANDWRITTEN_FALLBACK_FONT = "Xiaolai";
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
/** /**
* // TODO: shouldn't be really `const`, likely neither have integers as values, due to value for the custom fonts, which should likely be some hash. * // TODO: shouldn't be really `const`, likely neither have integers as values, due to value for the custom fonts, which should likely be some hash.
* *
@ -133,7 +136,17 @@ export const FONT_FAMILY = {
"Comic Shanns": 8, "Comic Shanns": 8,
"Liberation Sans": 9, "Liberation Sans": 9,
// from here on fallback fonts only // from here on fallback fonts only
Xiaolai: 1000, [CHINESE_HANDWRITTEN_FALLBACK_FONT]: 1000,
[WINDOWS_EMOJI_FALLBACK_FONT]: 9999,
};
// TODO: perahaps could be specific per-family in the future
export const FONT_FAMILY_FALLBACKS = {
string: `${CHINESE_HANDWRITTEN_FALLBACK_FONT}, ${WINDOWS_EMOJI_FALLBACK_FONT}`,
ordered: [
FONT_FAMILY[CHINESE_HANDWRITTEN_FALLBACK_FONT],
FONT_FAMILY[WINDOWS_EMOJI_FALLBACK_FONT],
],
}; };
export const THEME = { export const THEME = {
@ -157,10 +170,6 @@ export const FRAME_STYLE = {
nameLineHeight: 1.25, nameLineHeight: 1.25,
}; };
// TODO: consider adding a built-in fallback font for emojis
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
export const CHINESE_HANDWRITTEN_FALLBACK_FONT = "Xiaolai";
export const MIN_FONT_SIZE = 1; export const MIN_FONT_SIZE = 1;
export const DEFAULT_FONT_SIZE = 20; export const DEFAULT_FONT_SIZE = 20;
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Excalifont; export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Excalifont;

@ -3,31 +3,30 @@ import { LOCAL_FONT_PROTOCOL } from "./metadata";
import loadWoff2 from "./wasm/woff2.loader"; import loadWoff2 from "./wasm/woff2.loader";
import loadHbSubset from "./wasm/hb-subset.loader"; import loadHbSubset from "./wasm/hb-subset.loader";
export interface Font { export interface IExcalidrawFontFace {
urls: URL[]; urls: URL[];
fontFace: FontFace; fontFace: FontFace;
getContent(codePoints: ReadonlySet<number>): Promise<string>; toCSS(
characters: string,
codePoints: Set<number>,
): Promise<string> | undefined;
} }
export const UNPKG_FALLBACK_URL = `https://unpkg.com/${
import.meta.env.VITE_PKG_NAME
? `${import.meta.env.VITE_PKG_NAME}@${import.meta.env.PKG_VERSION}` // should be provided by vite during package build
: "@excalidraw/excalidraw" // fallback to latest package version (i.e. for app)
}/dist/prod/`;
export interface ExcalidrawFontFace { export class ExcalidrawFontFace implements IExcalidrawFontFace {
uri: string;
descriptors?: FontFaceDescriptors;
};
export class ExcalidrawFont implements Font {
public readonly urls: URL[]; public readonly urls: URL[];
public readonly fontFace: FontFace; public readonly fontFace: FontFace;
private static readonly UNPKG_FALLBACK_URL = `https://unpkg.com/${
import.meta.env.VITE_PKG_NAME
? `${import.meta.env.VITE_PKG_NAME}@${import.meta.env.PKG_VERSION}` // should be provided by vite during package build
: "@excalidraw/excalidraw" // fallback to latest package version (i.e. for app)
}/dist/prod/`;
constructor(family: string, uri: string, descriptors?: FontFaceDescriptors) { constructor(family: string, uri: string, descriptors?: FontFaceDescriptors) {
this.urls = ExcalidrawFont.createUrls(uri); this.urls = ExcalidrawFontFace.createUrls(uri);
const sources = this.urls const sources = this.urls
.map((url) => `url(${url}) ${ExcalidrawFont.getFormat(url)}`) .map((url) => `url(${url}) ${ExcalidrawFontFace.getFormat(url)}`)
.join(", "); .join(", ");
this.fontFace = new FontFace(family, sources, { this.fontFace = new FontFace(family, sources, {
@ -38,6 +37,41 @@ export class ExcalidrawFont implements Font {
}); });
} }
/**
* Generates CSS `@font-face` definition with the (subsetted) font source as a data url for the characters within the unicode range.
*
* Retrieves `undefined` otherwise.
*/
public toCSS(
characters: string,
codePoints: Set<number>,
): Promise<string> | undefined {
const unicodeRangeRegex = this.fontFace.unicodeRange
.split(", ")
.map((range) => {
const [start, end] = range.replace("U+", "").split("-");
if (end) {
return `\\u${start}-\\u${end}`;
}
return `\\u${start}`;
})
.join("");
if (!new RegExp(`[${unicodeRangeRegex}]`).exec(characters)) {
// quick exit, so that this does not count as a pending promsie
return;
}
return new Promise<string>((resolve) => {
this.getContent(codePoints).then((content) => {
resolve(`@font-face {
font-family: ${this.fontFace.family};
src: url(${content});
}`);
});
});
}
/** /**
* Tries to fetch woff2 content, based on the registered urls (from first to last, treated as fallbacks). * Tries to fetch woff2 content, based on the registered urls (from first to last, treated as fallbacks).
* *
@ -45,29 +79,29 @@ export class ExcalidrawFont implements Font {
* *
* @returns base64 with subsetted glyphs based on the passed codepoint, last defined url otherwise * @returns base64 with subsetted glyphs based on the passed codepoint, last defined url otherwise
*/ */
public async getContent(codePoints: ReadonlySet<number>): Promise<string> { private async getContent(codePoints: ReadonlySet<number>): Promise<string> {
let i = 0; let i = 0;
const errorMessages = []; const errorMessages = [];
while (i < this.urls.length) { while (i < this.urls.length) {
const url = this.urls[i]; const url = this.urls[i];
// it's dataurl (server), the font is inlined as base64, no need to fetch
if (url.protocol === "data:") {
const arrayBuffer = Buffer.from(
url.toString().split(",")[1],
"base64",
).buffer;
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
arrayBuffer,
codePoints,
);
return base64;
}
try { try {
// it's dataurl (server), the font is inlined as base64, no need to fetch
if (url.protocol === "data:") {
const arrayBuffer = Buffer.from(
url.toString().split(",")[1],
"base64",
).buffer;
const base64 = await ExcalidrawFontFace.subsetGlyphsByCodePoints(
arrayBuffer,
codePoints,
);
return base64;
}
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
Accept: "font/woff2", Accept: "font/woff2",
@ -76,7 +110,7 @@ export class ExcalidrawFont implements Font {
if (response.ok) { if (response.ok) {
const arrayBuffer = await response.arrayBuffer(); const arrayBuffer = await response.arrayBuffer();
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints( const base64 = await ExcalidrawFontFace.subsetGlyphsByCodePoints(
arrayBuffer, arrayBuffer,
codePoints, codePoints,
); );
@ -128,11 +162,11 @@ export class ExcalidrawFont implements Font {
const subsetSnft = subset(decompressedBinary, codePoints); const subsetSnft = subset(decompressedBinary, codePoints);
const compressedBinary = compress(subsetSnft.buffer); const compressedBinary = compress(subsetSnft.buffer);
return ExcalidrawFont.toBase64(compressedBinary.buffer); return ExcalidrawFontFace.toBase64(compressedBinary.buffer);
} catch (e) { } catch (e) {
console.error("Skipped glyph subsetting", e); console.error("Skipped glyph subsetting", e);
// Fallback to encoding whole font in case of errors // Fallback to encoding whole font in case of errors
return ExcalidrawFont.toBase64(arrayBuffer); return ExcalidrawFontFace.toBase64(arrayBuffer);
} }
} }
@ -178,7 +212,7 @@ export class ExcalidrawFont implements Font {
} }
// fallback url for bundled fonts // fallback url for bundled fonts
urls.push(new URL(assetUrl, UNPKG_FALLBACK_URL)); urls.push(new URL(assetUrl, ExcalidrawFontFace.UNPKG_FALLBACK_URL));
return urls; return urls;
} }

@ -10,12 +10,11 @@ import { isTextElement } from "../element";
import { getFontString } from "../utils"; import { getFontString } from "../utils";
import { FONT_FAMILY } from "../constants"; import { FONT_FAMILY } from "../constants";
import { FONT_METADATA, type FontMetadata } from "./metadata"; import { FONT_METADATA, type FontMetadata } from "./metadata";
import {
ExcalidrawFont,
type ExcalidrawFontFace,
type Font,
} from "./ExcalidrawFont";
import { getContainerElement } from "../element/textElement"; import { getContainerElement } from "../element/textElement";
import {
ExcalidrawFontFace,
type IExcalidrawFontFace,
} from "./ExcalidrawFontFace";
import { CascadiaFontFaces } from "./woff2/Cascadia"; import { CascadiaFontFaces } from "./woff2/Cascadia";
import { ComicFontFaces } from "./woff2/Comic"; import { ComicFontFaces } from "./woff2/Comic";
import { ExcalifontFontFaces } from "./woff2/Excalifont"; import { ExcalifontFontFaces } from "./woff2/Excalifont";
@ -25,6 +24,7 @@ import { LilitaFontFaces } from "./woff2/Lilita";
import { NunitoFontFaces } from "./woff2/Nunito"; import { NunitoFontFaces } from "./woff2/Nunito";
import { VirgilFontFaces } from "./woff2/Virgil"; import { VirgilFontFaces } from "./woff2/Virgil";
import { XiaolaiFontFaces } from "./woff2/Xiaolai"; import { XiaolaiFontFaces } from "./woff2/Xiaolai";
import { EmojiFontFaces } from "./woff2/Emoji";
export class Fonts { export class Fonts {
// it's ok to track fonts across multiple instances only once, so let's use // it's ok to track fonts across multiple instances only once, so let's use
@ -36,7 +36,7 @@ export class Fonts {
number, number,
{ {
metadata: FontMetadata; metadata: FontMetadata;
fonts: Font[]; fontFaces: IExcalidrawFontFace[];
} }
> >
| undefined; | undefined;
@ -148,13 +148,13 @@ export class Fonts {
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>, fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
) { ) {
// 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 { fonts, metadata } of Fonts.registered.values()) { for (const { fontFaces, metadata } of Fonts.registered.values()) {
// skip registering font faces for local fonts (i.e. Helvetica) // skip registering font faces for local fonts (i.e. Helvetica)
if (metadata.local) { if (metadata.local) {
continue; continue;
} }
for (const { fontFace } of fonts) { for (const { fontFace } of fontFaces) {
if (!window.document.fonts.has(fontFace)) { if (!window.document.fonts.has(fontFace)) {
window.document.fonts.add(fontFace); window.document.fonts.add(fontFace);
} }
@ -179,7 +179,7 @@ export class Fonts {
console.error( console.error(
`Failed to load font "${fontString}" from urls "${Fonts.registered `Failed to load font "${fontString}" from urls "${Fonts.registered
.get(fontFamily) .get(fontFamily)
?.fonts.map((x) => x.urls)}"`, ?.fontFaces.map((x) => x.urls)}"`,
e, e,
); );
} }
@ -199,17 +199,17 @@ export class Fonts {
const fonts = { const fonts = {
registered: new Map< registered: new Map<
ValueOf<typeof FONT_FAMILY>, ValueOf<typeof FONT_FAMILY>,
{ metadata: FontMetadata; fonts: Font[] } { metadata: FontMetadata; fontFaces: IExcalidrawFontFace[] }
>(), >(),
}; };
const init = ( const init = (
family: keyof typeof FONT_FAMILY, family: keyof typeof FONT_FAMILY,
...fontFaces: ExcalidrawFontFace[] ...fontFacesDescriptors: ExcalidrawFontFaceDescriptor[]
) => { ) => {
const metadata = FONT_METADATA[FONT_FAMILY[family]]; const metadata = FONT_METADATA[FONT_FAMILY[family]];
register.call(fonts, family, metadata, ...fontFaces); register.call(fonts, family, metadata, ...fontFacesDescriptors);
}; };
init("Cascadia", ...CascadiaFontFaces); init("Cascadia", ...CascadiaFontFaces);
@ -222,7 +222,10 @@ export class Fonts {
init("Lilita One", ...LilitaFontFaces); init("Lilita One", ...LilitaFontFaces);
init("Nunito", ...NunitoFontFaces); init("Nunito", ...NunitoFontFaces);
init("Virgil", ...VirgilFontFaces); init("Virgil", ...VirgilFontFaces);
// fallback font faces
init("Xiaolai", ...XiaolaiFontFaces); init("Xiaolai", ...XiaolaiFontFaces);
init("Segoe UI Emoji", ...EmojiFontFaces);
Fonts._initialized = true; Fonts._initialized = true;
@ -248,7 +251,7 @@ export class Fonts {
* *
* @param family font family * @param family font family
* @param metadata font metadata * @param metadata font metadata
* @param faces font faces * @param fontFacesDecriptors font faces descriptors
*/ */
function register( function register(
this: this:
@ -256,12 +259,12 @@ function register(
| { | {
registered: Map< registered: Map<
ValueOf<typeof FONT_FAMILY>, ValueOf<typeof FONT_FAMILY>,
{ metadata: FontMetadata; fonts: Font[] } { metadata: FontMetadata; fontFaces: IExcalidrawFontFace[] }
>; >;
}, },
family: string, family: string,
metadata: FontMetadata, metadata: FontMetadata,
...faces: ExcalidrawFontFace[] ...fontFacesDecriptors: ExcalidrawFontFaceDescriptor[]
) { ) {
// TODO: likely we will need to abandon number "id" in order to support custom fonts // TODO: likely we will need to abandon number "id" in order to support custom fonts
const familyId = FONT_FAMILY[family as keyof typeof FONT_FAMILY]; const familyId = FONT_FAMILY[family as keyof typeof FONT_FAMILY];
@ -270,8 +273,9 @@ function register(
if (!registeredFamily) { if (!registeredFamily) {
this.registered.set(familyId, { this.registered.set(familyId, {
metadata, metadata,
fonts: faces.map( fontFaces: fontFacesDecriptors.map(
({ uri, descriptors }) => new ExcalidrawFont(family, uri, descriptors), ({ uri, descriptors }) =>
new ExcalidrawFontFace(family, uri, descriptors),
), ),
}); });
} }
@ -309,3 +313,8 @@ export const getLineHeight = (fontFamily: FontFamilyValues) => {
return lineHeight as ExcalidrawTextElement["lineHeight"]; return lineHeight as ExcalidrawTextElement["lineHeight"];
}; };
export interface ExcalidrawFontFaceDescriptor {
uri: string;
descriptors?: FontFaceDescriptors;
}

@ -119,6 +119,17 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
}, },
fallback: true, fallback: true,
}, },
[FONT_FAMILY["Segoe UI Emoji"]]: {
metrics: {
// reusing Excalifont metrics
unitsPerEm: 1000,
ascender: 886,
descender: -374,
lineHeight: 1.25,
},
local: true,
fallback: true,
},
}; };
/** Unicode ranges defined by google fonts */ /** Unicode ranges defined by google fonts */

@ -1,7 +1,7 @@
import CascadiaCodeRegular from "./CascadiaCode-Regular.woff2"; import CascadiaCodeRegular from "./CascadiaCode-Regular.woff2";
import type { ExcalidrawFontFace } from "../../ExcalidrawFont"; import { type ExcalidrawFontFaceDescriptor } from "../..";
export const CascadiaFontFaces: ExcalidrawFontFace[] = [ export const CascadiaFontFaces: ExcalidrawFontFaceDescriptor[] = [
{ {
uri: CascadiaCodeRegular, uri: CascadiaCodeRegular,
}, },

@ -1,7 +1,7 @@
import ComicShannsRegular from "./ComicShanns-Regular.woff2"; import ComicShannsRegular from "./ComicShanns-Regular.woff2";
import type { ExcalidrawFontFace } from "../../ExcalidrawFont"; import { type ExcalidrawFontFaceDescriptor } from "../..";
export const ComicFontFaces: ExcalidrawFontFace[] = [ export const ComicFontFaces: ExcalidrawFontFaceDescriptor[] = [
{ {
uri: ComicShannsRegular, uri: ComicShannsRegular,
}, },

@ -0,0 +1,8 @@
import { LOCAL_FONT_PROTOCOL } from "../../metadata";
import { type ExcalidrawFontFaceDescriptor } from "../..";
export const EmojiFontFaces: ExcalidrawFontFaceDescriptor[] = [
{
uri: LOCAL_FONT_PROTOCOL,
},
];

@ -1,7 +1,7 @@
import Excalifont from "./Excalifont-Regular.woff2"; import Excalifont from "./Excalifont-Regular.woff2";
import type { ExcalidrawFontFace } from "../../ExcalidrawFont"; import { type ExcalidrawFontFaceDescriptor } from "../..";
export const ExcalifontFontFaces: ExcalidrawFontFace[] = [ export const ExcalifontFontFaces: ExcalidrawFontFaceDescriptor[] = [
{ {
uri: Excalifont, uri: Excalifont,
}, },

@ -1,7 +1,7 @@
import { LOCAL_FONT_PROTOCOL } from "../../metadata"; import { LOCAL_FONT_PROTOCOL } from "../../metadata";
import type { ExcalidrawFontFace } from "../../ExcalidrawFont"; import { type ExcalidrawFontFaceDescriptor } from "../..";
export const HelveticaFontFaces: ExcalidrawFontFace[] = [ export const HelveticaFontFaces: ExcalidrawFontFaceDescriptor[] = [
{ {
uri: LOCAL_FONT_PROTOCOL, uri: LOCAL_FONT_PROTOCOL,
}, },

@ -1,7 +1,7 @@
import LiberationSansRegular from "./LiberationSans-Regular.woff2"; import LiberationSansRegular from "./LiberationSans-Regular.woff2";
import type { ExcalidrawFontFace } from "../../ExcalidrawFont"; import { type ExcalidrawFontFaceDescriptor } from "../..";
export const LiberationFontFaces: ExcalidrawFontFace[] = [ export const LiberationFontFaces: ExcalidrawFontFaceDescriptor[] = [
{ {
uri: LiberationSansRegular, uri: LiberationSansRegular,
}, },

@ -2,9 +2,9 @@ import LilitaLatin from "https://fonts.gstatic.com/s/lilitaone/v15/i7dPIFZ9Zz-WB
import LilitaLatinExt from "https://fonts.gstatic.com/s/lilitaone/v15/i7dPIFZ9Zz-WBtRtedDbYE98RXi4EwSsbg.woff2"; import LilitaLatinExt from "https://fonts.gstatic.com/s/lilitaone/v15/i7dPIFZ9Zz-WBtRtedDbYE98RXi4EwSsbg.woff2";
import { GOOGLE_FONTS_RANGES } from "../../metadata"; import { GOOGLE_FONTS_RANGES } from "../../metadata";
import type { ExcalidrawFontFace } from "../../ExcalidrawFont"; import { type ExcalidrawFontFaceDescriptor } from "../..";
export const LilitaFontFaces: ExcalidrawFontFace[] = [ export const LilitaFontFaces: ExcalidrawFontFaceDescriptor[] = [
{ {
uri: LilitaLatinExt, uri: LilitaLatinExt,
descriptors: { unicodeRange: GOOGLE_FONTS_RANGES.LATIN_EXT }, descriptors: { unicodeRange: GOOGLE_FONTS_RANGES.LATIN_EXT },

@ -5,9 +5,9 @@ import CyrilicExt from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiO
import Vietnamese from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTs3j6zbXWjgevT5.woff2"; import Vietnamese from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTs3j6zbXWjgevT5.woff2";
import { GOOGLE_FONTS_RANGES } from "../../metadata"; import { GOOGLE_FONTS_RANGES } from "../../metadata";
import type { ExcalidrawFontFace } from "../../ExcalidrawFont"; import { type ExcalidrawFontFaceDescriptor } from "../..";
export const NunitoFontFaces: ExcalidrawFontFace[] = [ export const NunitoFontFaces: ExcalidrawFontFaceDescriptor[] = [
{ {
uri: CyrilicExt, uri: CyrilicExt,
descriptors: { descriptors: {

@ -1,7 +1,7 @@
import Virgil from "./Virgil-Regular.woff2"; import Virgil from "./Virgil-Regular.woff2";
import type { ExcalidrawFontFace } from "../../ExcalidrawFont"; import { type ExcalidrawFontFaceDescriptor } from "../..";
export const VirgilFontFaces: ExcalidrawFontFace[] = [ export const VirgilFontFaces: ExcalidrawFontFaceDescriptor[] = [
{ {
uri: Virgil, uri: Virgil,
}, },

@ -218,7 +218,7 @@ import _213 from "./31dc7ab58ea136b7dca23037305e023b.woff2";
import _214 from "./d53f5bd45db0e96874d1174341701b59.woff2"; import _214 from "./d53f5bd45db0e96874d1174341701b59.woff2";
import _215 from "./6fe856ceea3ef870142b02ddecc1b006.woff2"; import _215 from "./6fe856ceea3ef870142b02ddecc1b006.woff2";
import type { ExcalidrawFontFace } from "../../ExcalidrawFont"; import { type ExcalidrawFontFaceDescriptor } from "../..";
/* Generated By cn-font-split@5.2.1 https://www.npmjs.com/package/cn-font-split /* Generated By cn-font-split@5.2.1 https://www.npmjs.com/package/cn-font-split
CreateTime: Thu, 22 Aug 2024 10:58:35 GMT; CreateTime: Thu, 22 Aug 2024 10:58:35 GMT;
@ -240,7 +240,7 @@ licenseURL: http://scripts.sil.org/OFL
*/ */
// TODO_CHINESE: consider having import for each (to automate build step) // TODO_CHINESE: consider having import for each (to automate build step)
export const XiaolaiFontFaces: ExcalidrawFontFace[] = [ export const XiaolaiFontFaces: ExcalidrawFontFaceDescriptor[] = [
{ {
uri: _0, uri: _0,
descriptors: { descriptors: {

@ -67,6 +67,7 @@
"canvas-roundrect-polyfill": "0.0.1", "canvas-roundrect-polyfill": "0.0.1",
"clsx": "1.1.1", "clsx": "1.1.1",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"es6-promise-pool": "2.5.0",
"fractional-indexing": "3.2.0", "fractional-indexing": "3.2.0",
"fuzzy": "0.1.3", "fuzzy": "0.1.3",
"image-blob-reduce": "3.0.1", "image-blob-reduce": "3.0.1",

@ -9,7 +9,13 @@ 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 { arrayToMap, distance, getFontString, toBrandedType } from "../utils"; import {
arrayToMap,
distance,
getFontString,
PromisePool,
toBrandedType,
} from "../utils";
import type { AppState, BinaryFiles } from "../types"; import type { AppState, BinaryFiles } from "../types";
import { import {
DEFAULT_EXPORT_PADDING, DEFAULT_EXPORT_PADDING,
@ -18,6 +24,7 @@ import {
SVG_NS, SVG_NS,
THEME, THEME,
THEME_FILTER, THEME_FILTER,
FONT_FAMILY_FALLBACKS,
} from "../constants"; } from "../constants";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { serializeAsJSON } from "../data/json"; import { serializeAsJSON } from "../data/json";
@ -39,7 +46,6 @@ 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 type { Font } from "../fonts/ExcalidrawFont";
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`; const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
@ -439,6 +445,7 @@ const getFontFaces = async (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
): Promise<string[]> => { ): Promise<string[]> => {
const fontFamilies = new Set<number>(); const fontFamilies = new Set<number>();
const characters = new Set<string>();
const codePoints = new Set<number>(); const codePoints = new Set<number>();
for (const element of elements) { for (const element of elements) {
@ -449,52 +456,85 @@ const getFontFaces = async (
fontFamilies.add(element.fontFamily); fontFamilies.add(element.fontFamily);
// gather unique codepoints only when inlining fonts // gather unique codepoints only when inlining fonts
for (const codePoint of Array.from(element.originalText, (u) => for (const char of element.originalText) {
u.codePointAt(0), if (!characters.has(char)) {
)) { characters.add(char);
if (codePoint) { codePoints.add(char.codePointAt(0)!);
codePoints.add(codePoint);
} }
} }
} }
const getSource = (font: Font) => { const uniqueChars = Array.from(characters).join("");
try {
// retrieve font source as dataurl based on the used codepoints
return font.getContent(codePoints);
} catch {
// fallback to font source as a url
return font.urls[0].toString();
}
};
const fontFaces = await Promise.all( // quick check for Han (might match a bit more, but that's fine)
Array.from(fontFamilies).map(async (x) => { if (uniqueChars.match(/\p{Script=Han}/u)) {
const { fonts, metadata } = Fonts.registered.get(x) ?? {}; fontFamilies.add(FONT_FAMILY.Xiaolai);
}
if (!Array.isArray(fonts)) { const iterator = fontFacesIterator(fontFamilies, uniqueChars, codePoints);
console.error(
`Couldn't find registered fonts for font-family "${x}"`,
Fonts.registered,
);
return [];
}
if (metadata?.local) { // don't trigger hundreds/thousands of concurrent requests, instead go 3 requests at a time, in a controlled manner
// don't inline local fonts // in other words, does not block the main thread and avoids related issues, including potential rate limits
return []; const concurrency = 3;
} const fontFaces = await new PromisePool(iterator, concurrency).all();
return Promise.all( // dedup just in case (i.e. could be the same font faces with 0 glyphs)
fonts.map( return Array.from(new Set(fontFaces));
async (font) => `@font-face {
font-family: ${font.fontFace.family};
src: url(${await getSource(font)});
}`,
),
);
}),
);
return fontFaces.flat();
}; };
function* fontFacesIterator(
families: Set<number>,
characters: string,
codePoints: Set<number>,
): Generator<Promise<[number, string]>> {
// the order between the families 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
const reversedFallbacks = Array.from(FONT_FAMILY_FALLBACKS.ordered).reverse();
for (const [familyIndex, family] of Array.from(families).entries()) {
const fallbackIndex = reversedFallbacks.findIndex(
(fallback) => fallback === family,
);
let order: number;
if (fallbackIndex !== -1) {
// making sure the fallback fonts are defined first
order = fallbackIndex;
} else {
// making sure the built-in font faces are always defined after fallback fonts
order = FONT_FAMILY_FALLBACKS.ordered.length + familyIndex;
}
// making a safe buffer in between the families, assuming there won't be more than 10k font faces per family
order *= 10_000;
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()) {
const pendingFontFace = fontFace.toCSS(characters, codePoints);
// so that we don't add undefined to the pool
if (pendingFontFace) {
yield new Promise(async (resolve) => {
const promiseIndex = order + fontFaceIndex;
const fontFaceCSS = await pendingFontFace;
resolve([promiseIndex, fontFaceCSS]);
});
}
}
}
}

@ -1,9 +1,10 @@
import Pool from "es6-promise-pool";
import { COLOR_PALETTE } from "./colors"; import { COLOR_PALETTE } from "./colors";
import type { EVENT } from "./constants"; import type { EVENT } from "./constants";
import { import {
CHINESE_HANDWRITTEN_FALLBACK_FONT,
DEFAULT_VERSION, DEFAULT_VERSION,
FONT_FAMILY, FONT_FAMILY,
FONT_FAMILY_FALLBACKS,
isDarwin, isDarwin,
WINDOWS_EMOJI_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT,
} from "./constants"; } from "./constants";
@ -89,8 +90,8 @@ export const getFontFamilyString = ({
}) => { }) => {
for (const [fontFamilyString, id] of Object.entries(FONT_FAMILY)) { for (const [fontFamilyString, id] of Object.entries(FONT_FAMILY)) {
if (id === fontFamily) { if (id === fontFamily) {
// TODO: we should fallback first to generic family names first, rather than directly to the emoji font // TODO: we should fallback first to generic family names first
return `${fontFamilyString}, ${CHINESE_HANDWRITTEN_FALLBACK_FONT}, ${WINDOWS_EMOJI_FALLBACK_FONT}`; return `${fontFamilyString}, ${FONT_FAMILY_FALLBACKS.string}`;
} }
} }
return WINDOWS_EMOJI_FALLBACK_FONT; return WINDOWS_EMOJI_FALLBACK_FONT;
@ -1167,3 +1168,43 @@ export const promiseTry = async <TValue, TArgs extends unknown[]>(
export const isAnyTrue = (...args: boolean[]): boolean => export const isAnyTrue = (...args: boolean[]): boolean =>
Math.max(...args.map((arg) => (arg ? 1 : 0))) > 0; Math.max(...args.map((arg) => (arg ? 1 : 0))) > 0;
// extending the missing types
// relying on the [Index, T] to keep a correct order
type TPromisePool<T, Index = number> = Pool<[Index, T][]> & {
addEventListener: (
type: "fulfilled",
listener: (event: { data: { result: [Index, T] } }) => void,
) => (event: { data: { result: [Index, T] } }) => void;
removeEventListener: (
type: "fulfilled",
listener: (event: { data: { result: [Index, T] } }) => void,
) => void;
};
export class PromisePool<T> {
private readonly pool: TPromisePool<T>;
private readonly entries: Record<number, T> = {};
constructor(
source: IterableIterator<Promise<[number, T]>>,
concurrency: number,
) {
this.pool = new Pool(
source as unknown as () => void | PromiseLike<[number, T][]>,
concurrency,
) as TPromisePool<T>;
}
public all() {
const listener = this.pool.addEventListener("fulfilled", (event) => {
const [index, value] = event.data.result;
this.entries[index] = value;
});
return this.pool.start().then(() => {
setTimeout(() => this.pool.removeEventListener("fulfilled", listener));
return Object.values(this.entries);
});
}
}

@ -5521,6 +5521,11 @@ es-to-primitive@^1.2.1:
is-date-object "^1.0.1" is-date-object "^1.0.1"
is-symbol "^1.0.2" is-symbol "^1.0.2"
es6-promise-pool@2.5.0:
version "2.5.0"
resolved "https://registry.yarnpkg.com/es6-promise-pool/-/es6-promise-pool-2.5.0.tgz#147c612b36b47f105027f9d2bf54a598a99d9ccb"
integrity sha512-VHErXfzR/6r/+yyzPKeBvO0lgjfC5cbDCQWjWwMZWSb6YU39TGIl51OUmCfWCq4ylMdJSB8zkz2vIuIeIxXApA==
esbuild-plugin-external-global@1.0.1: esbuild-plugin-external-global@1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/esbuild-plugin-external-global/-/esbuild-plugin-external-global-1.0.1.tgz#e3bba0e3a561f61b395bec0984a90ed0de06c4ce" resolved "https://registry.yarnpkg.com/esbuild-plugin-external-global/-/esbuild-plugin-external-global-1.0.1.tgz#e3bba0e3a561f61b395bec0984a90ed0de06c4ce"