diff --git a/packages/excalidraw/components/FontPicker/FontPickerList.tsx b/packages/excalidraw/components/FontPicker/FontPickerList.tsx index 8210c6fed..aa787d2ac 100644 --- a/packages/excalidraw/components/FontPicker/FontPickerList.tsx +++ b/packages/excalidraw/components/FontPicker/FontPickerList.tsx @@ -66,11 +66,11 @@ export const FontPickerList = React.memo( .filter( ([_, { metadata }]) => !metadata.serverSide && !metadata.fallback, ) - .map(([familyId, { metadata, fonts }]) => { + .map(([familyId, { metadata, fontFaces }]) => { const fontDescriptor = { value: familyId, icon: metadata.icon ?? FontFamilyNormalIcon, - text: fonts[0].fontFace.family, + text: fontFaces[0]?.fontFace?.family ?? "Unknown", }; if (metadata.deprecated) { diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index 461035974..cef1824b5 100644 --- a/packages/excalidraw/constants.ts +++ b/packages/excalidraw/constants.ts @@ -114,6 +114,9 @@ export const CLASSES = { 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. * @@ -133,7 +136,17 @@ export const FONT_FAMILY = { "Comic Shanns": 8, "Liberation Sans": 9, // 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 = { @@ -157,10 +170,6 @@ export const FRAME_STYLE = { 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 DEFAULT_FONT_SIZE = 20; export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Excalifont; diff --git a/packages/excalidraw/fonts/ExcalidrawFont.ts b/packages/excalidraw/fonts/ExcalidrawFontFace.ts similarity index 69% rename from packages/excalidraw/fonts/ExcalidrawFont.ts rename to packages/excalidraw/fonts/ExcalidrawFontFace.ts index 71e25afae..90abce2f5 100644 --- a/packages/excalidraw/fonts/ExcalidrawFont.ts +++ b/packages/excalidraw/fonts/ExcalidrawFontFace.ts @@ -3,31 +3,30 @@ import { LOCAL_FONT_PROTOCOL } from "./metadata"; import loadWoff2 from "./wasm/woff2.loader"; import loadHbSubset from "./wasm/hb-subset.loader"; -export interface Font { +export interface IExcalidrawFontFace { urls: URL[]; fontFace: FontFace; - getContent(codePoints: ReadonlySet): Promise; + toCSS( + characters: string, + codePoints: Set, + ): Promise | 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 { - uri: string; - descriptors?: FontFaceDescriptors; -}; - -export class ExcalidrawFont implements Font { +export class ExcalidrawFontFace implements IExcalidrawFontFace { public readonly urls: URL[]; 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) { - this.urls = ExcalidrawFont.createUrls(uri); + this.urls = ExcalidrawFontFace.createUrls(uri); const sources = this.urls - .map((url) => `url(${url}) ${ExcalidrawFont.getFormat(url)}`) + .map((url) => `url(${url}) ${ExcalidrawFontFace.getFormat(url)}`) .join(", "); 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, + ): Promise | 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((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). * @@ -45,29 +79,29 @@ export class ExcalidrawFont implements Font { * * @returns base64 with subsetted glyphs based on the passed codepoint, last defined url otherwise */ - public async getContent(codePoints: ReadonlySet): Promise { + private async getContent(codePoints: ReadonlySet): Promise { let i = 0; const errorMessages = []; while (i < this.urls.length) { 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 { + // 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, { headers: { Accept: "font/woff2", @@ -76,7 +110,7 @@ export class ExcalidrawFont implements Font { if (response.ok) { const arrayBuffer = await response.arrayBuffer(); - const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints( + const base64 = await ExcalidrawFontFace.subsetGlyphsByCodePoints( arrayBuffer, codePoints, ); @@ -128,11 +162,11 @@ export class ExcalidrawFont implements Font { const subsetSnft = subset(decompressedBinary, codePoints); const compressedBinary = compress(subsetSnft.buffer); - return ExcalidrawFont.toBase64(compressedBinary.buffer); + return ExcalidrawFontFace.toBase64(compressedBinary.buffer); } catch (e) { console.error("Skipped glyph subsetting", e); // 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 - urls.push(new URL(assetUrl, UNPKG_FALLBACK_URL)); + urls.push(new URL(assetUrl, ExcalidrawFontFace.UNPKG_FALLBACK_URL)); return urls; } diff --git a/packages/excalidraw/fonts/index.ts b/packages/excalidraw/fonts/index.ts index 163b22f39..7e6c96e1a 100644 --- a/packages/excalidraw/fonts/index.ts +++ b/packages/excalidraw/fonts/index.ts @@ -10,12 +10,11 @@ import { isTextElement } from "../element"; import { getFontString } from "../utils"; import { FONT_FAMILY } from "../constants"; import { FONT_METADATA, type FontMetadata } from "./metadata"; -import { - ExcalidrawFont, - type ExcalidrawFontFace, - type Font, -} from "./ExcalidrawFont"; import { getContainerElement } from "../element/textElement"; +import { + ExcalidrawFontFace, + type IExcalidrawFontFace, +} from "./ExcalidrawFontFace"; import { CascadiaFontFaces } from "./woff2/Cascadia"; import { ComicFontFaces } from "./woff2/Comic"; import { ExcalifontFontFaces } from "./woff2/Excalifont"; @@ -25,6 +24,7 @@ import { LilitaFontFaces } from "./woff2/Lilita"; import { NunitoFontFaces } from "./woff2/Nunito"; import { VirgilFontFaces } from "./woff2/Virgil"; import { XiaolaiFontFaces } from "./woff2/Xiaolai"; +import { EmojiFontFaces } from "./woff2/Emoji"; export class Fonts { // it's ok to track fonts across multiple instances only once, so let's use @@ -36,7 +36,7 @@ export class Fonts { number, { metadata: FontMetadata; - fonts: Font[]; + fontFaces: IExcalidrawFontFace[]; } > | undefined; @@ -148,13 +148,13 @@ export class Fonts { fontFamilies: Array, ) { // 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) if (metadata.local) { continue; } - for (const { fontFace } of fonts) { + for (const { fontFace } of fontFaces) { if (!window.document.fonts.has(fontFace)) { window.document.fonts.add(fontFace); } @@ -179,7 +179,7 @@ export class Fonts { console.error( `Failed to load font "${fontString}" from urls "${Fonts.registered .get(fontFamily) - ?.fonts.map((x) => x.urls)}"`, + ?.fontFaces.map((x) => x.urls)}"`, e, ); } @@ -199,17 +199,17 @@ export class Fonts { const fonts = { registered: new Map< ValueOf, - { metadata: FontMetadata; fonts: Font[] } + { metadata: FontMetadata; fontFaces: IExcalidrawFontFace[] } >(), }; const init = ( family: keyof typeof FONT_FAMILY, - ...fontFaces: ExcalidrawFontFace[] + ...fontFacesDescriptors: ExcalidrawFontFaceDescriptor[] ) => { const metadata = FONT_METADATA[FONT_FAMILY[family]]; - register.call(fonts, family, metadata, ...fontFaces); + register.call(fonts, family, metadata, ...fontFacesDescriptors); }; init("Cascadia", ...CascadiaFontFaces); @@ -222,7 +222,10 @@ export class Fonts { init("Lilita One", ...LilitaFontFaces); init("Nunito", ...NunitoFontFaces); init("Virgil", ...VirgilFontFaces); + + // fallback font faces init("Xiaolai", ...XiaolaiFontFaces); + init("Segoe UI Emoji", ...EmojiFontFaces); Fonts._initialized = true; @@ -248,7 +251,7 @@ export class Fonts { * * @param family font family * @param metadata font metadata - * @param faces font faces + * @param fontFacesDecriptors font faces descriptors */ function register( this: @@ -256,12 +259,12 @@ function register( | { registered: Map< ValueOf, - { metadata: FontMetadata; fonts: Font[] } + { metadata: FontMetadata; fontFaces: IExcalidrawFontFace[] } >; }, family: string, metadata: FontMetadata, - ...faces: ExcalidrawFontFace[] + ...fontFacesDecriptors: ExcalidrawFontFaceDescriptor[] ) { // 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]; @@ -270,8 +273,9 @@ function register( if (!registeredFamily) { this.registered.set(familyId, { metadata, - fonts: faces.map( - ({ uri, descriptors }) => new ExcalidrawFont(family, uri, descriptors), + fontFaces: fontFacesDecriptors.map( + ({ uri, descriptors }) => + new ExcalidrawFontFace(family, uri, descriptors), ), }); } @@ -309,3 +313,8 @@ export const getLineHeight = (fontFamily: FontFamilyValues) => { return lineHeight as ExcalidrawTextElement["lineHeight"]; }; + +export interface ExcalidrawFontFaceDescriptor { + uri: string; + descriptors?: FontFaceDescriptors; +} diff --git a/packages/excalidraw/fonts/metadata.ts b/packages/excalidraw/fonts/metadata.ts index f86963fd8..a576d21ea 100644 --- a/packages/excalidraw/fonts/metadata.ts +++ b/packages/excalidraw/fonts/metadata.ts @@ -119,6 +119,17 @@ export const FONT_METADATA: Record = { }, 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 */ diff --git a/packages/excalidraw/fonts/woff2/Cascadia/index.ts b/packages/excalidraw/fonts/woff2/Cascadia/index.ts index 84987e147..65baee52a 100644 --- a/packages/excalidraw/fonts/woff2/Cascadia/index.ts +++ b/packages/excalidraw/fonts/woff2/Cascadia/index.ts @@ -1,7 +1,7 @@ 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, }, diff --git a/packages/excalidraw/fonts/woff2/Comic/index.ts b/packages/excalidraw/fonts/woff2/Comic/index.ts index 3ab345219..51b1f2c9e 100644 --- a/packages/excalidraw/fonts/woff2/Comic/index.ts +++ b/packages/excalidraw/fonts/woff2/Comic/index.ts @@ -1,7 +1,7 @@ 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, }, diff --git a/packages/excalidraw/fonts/woff2/Emoji/index.ts b/packages/excalidraw/fonts/woff2/Emoji/index.ts new file mode 100644 index 000000000..491676d5c --- /dev/null +++ b/packages/excalidraw/fonts/woff2/Emoji/index.ts @@ -0,0 +1,8 @@ +import { LOCAL_FONT_PROTOCOL } from "../../metadata"; +import { type ExcalidrawFontFaceDescriptor } from "../.."; + +export const EmojiFontFaces: ExcalidrawFontFaceDescriptor[] = [ + { + uri: LOCAL_FONT_PROTOCOL, + }, +]; diff --git a/packages/excalidraw/fonts/woff2/Excalifont/index.ts b/packages/excalidraw/fonts/woff2/Excalifont/index.ts index 654004c16..21514971f 100644 --- a/packages/excalidraw/fonts/woff2/Excalifont/index.ts +++ b/packages/excalidraw/fonts/woff2/Excalifont/index.ts @@ -1,7 +1,7 @@ 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, }, diff --git a/packages/excalidraw/fonts/woff2/Helvetica/index.ts b/packages/excalidraw/fonts/woff2/Helvetica/index.ts index 146ec55f8..7204b304a 100644 --- a/packages/excalidraw/fonts/woff2/Helvetica/index.ts +++ b/packages/excalidraw/fonts/woff2/Helvetica/index.ts @@ -1,7 +1,7 @@ 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, }, diff --git a/packages/excalidraw/fonts/woff2/Liberation/index.ts b/packages/excalidraw/fonts/woff2/Liberation/index.ts index 0ba424719..df6d3dbf3 100644 --- a/packages/excalidraw/fonts/woff2/Liberation/index.ts +++ b/packages/excalidraw/fonts/woff2/Liberation/index.ts @@ -1,7 +1,7 @@ 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, }, diff --git a/packages/excalidraw/fonts/woff2/Lilita/index.ts b/packages/excalidraw/fonts/woff2/Lilita/index.ts index 7a02514d2..887fc223a 100644 --- a/packages/excalidraw/fonts/woff2/Lilita/index.ts +++ b/packages/excalidraw/fonts/woff2/Lilita/index.ts @@ -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 { GOOGLE_FONTS_RANGES } from "../../metadata"; -import type { ExcalidrawFontFace } from "../../ExcalidrawFont"; +import { type ExcalidrawFontFaceDescriptor } from "../.."; -export const LilitaFontFaces: ExcalidrawFontFace[] = [ +export const LilitaFontFaces: ExcalidrawFontFaceDescriptor[] = [ { uri: LilitaLatinExt, descriptors: { unicodeRange: GOOGLE_FONTS_RANGES.LATIN_EXT }, diff --git a/packages/excalidraw/fonts/woff2/Nunito/index.ts b/packages/excalidraw/fonts/woff2/Nunito/index.ts index 86442015b..1704bd9ed 100644 --- a/packages/excalidraw/fonts/woff2/Nunito/index.ts +++ b/packages/excalidraw/fonts/woff2/Nunito/index.ts @@ -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 { GOOGLE_FONTS_RANGES } from "../../metadata"; -import type { ExcalidrawFontFace } from "../../ExcalidrawFont"; +import { type ExcalidrawFontFaceDescriptor } from "../.."; -export const NunitoFontFaces: ExcalidrawFontFace[] = [ +export const NunitoFontFaces: ExcalidrawFontFaceDescriptor[] = [ { uri: CyrilicExt, descriptors: { diff --git a/packages/excalidraw/fonts/woff2/Virgil/index.ts b/packages/excalidraw/fonts/woff2/Virgil/index.ts index b86abc5f2..62c32162e 100644 --- a/packages/excalidraw/fonts/woff2/Virgil/index.ts +++ b/packages/excalidraw/fonts/woff2/Virgil/index.ts @@ -1,7 +1,7 @@ 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, }, diff --git a/packages/excalidraw/fonts/woff2/Xiaolai/index.ts b/packages/excalidraw/fonts/woff2/Xiaolai/index.ts index adabb458c..e26c33374 100644 --- a/packages/excalidraw/fonts/woff2/Xiaolai/index.ts +++ b/packages/excalidraw/fonts/woff2/Xiaolai/index.ts @@ -218,7 +218,7 @@ import _213 from "./31dc7ab58ea136b7dca23037305e023b.woff2"; import _214 from "./d53f5bd45db0e96874d1174341701b59.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 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) -export const XiaolaiFontFaces: ExcalidrawFontFace[] = [ +export const XiaolaiFontFaces: ExcalidrawFontFaceDescriptor[] = [ { uri: _0, descriptors: { diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index 5d556c04f..0f6f6b8a1 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -67,6 +67,7 @@ "canvas-roundrect-polyfill": "0.0.1", "clsx": "1.1.1", "cross-env": "7.0.3", + "es6-promise-pool": "2.5.0", "fractional-indexing": "3.2.0", "fuzzy": "0.1.3", "image-blob-reduce": "3.0.1", diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index b120d0cc9..51f04a058 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -9,7 +9,13 @@ import type { import type { Bounds } from "../element/bounds"; import { getCommonBounds, getElementAbsoluteCoords } from "../element/bounds"; 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 { DEFAULT_EXPORT_PADDING, @@ -18,6 +24,7 @@ import { SVG_NS, THEME, THEME_FILTER, + FONT_FAMILY_FALLBACKS, } from "../constants"; import { getDefaultAppState } from "../appState"; import { serializeAsJSON } from "../data/json"; @@ -39,7 +46,6 @@ import type { RenderableElementsMap } from "./types"; import { syncInvalidIndices } from "../fractionalIndex"; import { renderStaticScene } from "../renderer/staticScene"; import { Fonts } from "../fonts"; -import type { Font } from "../fonts/ExcalidrawFont"; const SVG_EXPORT_TAG = ``; @@ -439,6 +445,7 @@ const getFontFaces = async ( elements: readonly ExcalidrawElement[], ): Promise => { const fontFamilies = new Set(); + const characters = new Set(); const codePoints = new Set(); for (const element of elements) { @@ -449,52 +456,85 @@ const getFontFaces = async ( fontFamilies.add(element.fontFamily); // gather unique codepoints only when inlining fonts - for (const codePoint of Array.from(element.originalText, (u) => - u.codePointAt(0), - )) { - if (codePoint) { - codePoints.add(codePoint); + for (const char of element.originalText) { + if (!characters.has(char)) { + characters.add(char); + codePoints.add(char.codePointAt(0)!); } } } - const getSource = (font: Font) => { - 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 uniqueChars = Array.from(characters).join(""); - const fontFaces = await Promise.all( - Array.from(fontFamilies).map(async (x) => { - const { fonts, metadata } = Fonts.registered.get(x) ?? {}; + // quick check for Han (might match a bit more, but that's fine) + if (uniqueChars.match(/\p{Script=Han}/u)) { + fontFamilies.add(FONT_FAMILY.Xiaolai); + } - if (!Array.isArray(fonts)) { - console.error( - `Couldn't find registered fonts for font-family "${x}"`, - Fonts.registered, - ); - return []; - } + const iterator = fontFacesIterator(fontFamilies, uniqueChars, codePoints); - if (metadata?.local) { - // don't inline local fonts - return []; - } + // don't trigger hundreds/thousands of concurrent requests, instead go 3 requests at a time, in a controlled manner + // in other words, does not block the main thread and avoids related issues, including potential rate limits + const concurrency = 3; + const fontFaces = await new PromisePool(iterator, concurrency).all(); - return Promise.all( - fonts.map( - async (font) => `@font-face { - font-family: ${font.fontFace.family}; - src: url(${await getSource(font)}); - }`, - ), - ); - }), - ); - - return fontFaces.flat(); + // dedup just in case (i.e. could be the same font faces with 0 glyphs) + return Array.from(new Set(fontFaces)); }; + +function* fontFacesIterator( + families: Set, + characters: string, + codePoints: Set, +): Generator> { + // 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]); + }); + } + } + } +} diff --git a/packages/excalidraw/utils.ts b/packages/excalidraw/utils.ts index 0f4b6009a..15d7a9b05 100644 --- a/packages/excalidraw/utils.ts +++ b/packages/excalidraw/utils.ts @@ -1,9 +1,10 @@ +import Pool from "es6-promise-pool"; import { COLOR_PALETTE } from "./colors"; import type { EVENT } from "./constants"; import { - CHINESE_HANDWRITTEN_FALLBACK_FONT, DEFAULT_VERSION, FONT_FAMILY, + FONT_FAMILY_FALLBACKS, isDarwin, WINDOWS_EMOJI_FALLBACK_FONT, } from "./constants"; @@ -89,8 +90,8 @@ export const getFontFamilyString = ({ }) => { for (const [fontFamilyString, id] of Object.entries(FONT_FAMILY)) { if (id === fontFamily) { - // TODO: we should fallback first to generic family names first, rather than directly to the emoji font - return `${fontFamilyString}, ${CHINESE_HANDWRITTEN_FALLBACK_FONT}, ${WINDOWS_EMOJI_FALLBACK_FONT}`; + // TODO: we should fallback first to generic family names first + return `${fontFamilyString}, ${FONT_FAMILY_FALLBACKS.string}`; } } return WINDOWS_EMOJI_FALLBACK_FONT; @@ -1167,3 +1168,43 @@ export const promiseTry = async ( export const isAnyTrue = (...args: boolean[]): boolean => 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 = 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 { + private readonly pool: TPromisePool; + private readonly entries: Record = {}; + + constructor( + source: IterableIterator>, + concurrency: number, + ) { + this.pool = new Pool( + source as unknown as () => void | PromiseLike<[number, T][]>, + concurrency, + ) as TPromisePool; + } + + 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); + }); + } +} diff --git a/yarn.lock b/yarn.lock index 639135bae..7009ac262 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5521,6 +5521,11 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" 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: version "1.0.1" resolved "https://registry.yarnpkg.com/esbuild-plugin-external-global/-/esbuild-plugin-external-global-1.0.1.tgz#e3bba0e3a561f61b395bec0984a90ed0de06c4ce"