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:
parent
f40a8d0cab
commit
8ad59687bb
@ -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) {
|
||||
|
@ -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;
|
||||
|
@ -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<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 {
|
||||
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<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).
|
||||
*
|
||||
@ -45,13 +79,14 @@ export class ExcalidrawFont implements Font {
|
||||
*
|
||||
* @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;
|
||||
const errorMessages = [];
|
||||
|
||||
while (i < this.urls.length) {
|
||||
const url = this.urls[i];
|
||||
|
||||
try {
|
||||
// it's dataurl (server), the font is inlined as base64, no need to fetch
|
||||
if (url.protocol === "data:") {
|
||||
const arrayBuffer = Buffer.from(
|
||||
@ -59,7 +94,7 @@ export class ExcalidrawFont implements Font {
|
||||
"base64",
|
||||
).buffer;
|
||||
|
||||
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
|
||||
const base64 = await ExcalidrawFontFace.subsetGlyphsByCodePoints(
|
||||
arrayBuffer,
|
||||
codePoints,
|
||||
);
|
||||
@ -67,7 +102,6 @@ export class ExcalidrawFont implements Font {
|
||||
return base64;
|
||||
}
|
||||
|
||||
try {
|
||||
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;
|
||||
}
|
@ -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<ExcalidrawTextElement["fontFamily"]>,
|
||||
) {
|
||||
// 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<typeof FONT_FAMILY>,
|
||||
{ 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<typeof FONT_FAMILY>,
|
||||
{ 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;
|
||||
}
|
||||
|
@ -119,6 +119,17 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
|
||||
},
|
||||
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 */
|
||||
|
@ -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,
|
||||
},
|
||||
|
@ -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,
|
||||
},
|
||||
|
8
packages/excalidraw/fonts/woff2/Emoji/index.ts
Normal file
8
packages/excalidraw/fonts/woff2/Emoji/index.ts
Normal file
@ -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 type { ExcalidrawFontFace } from "../../ExcalidrawFont";
|
||||
import { type ExcalidrawFontFaceDescriptor } from "../..";
|
||||
|
||||
export const ExcalifontFontFaces: ExcalidrawFontFace[] = [
|
||||
export const ExcalifontFontFaces: ExcalidrawFontFaceDescriptor[] = [
|
||||
{
|
||||
uri: Excalifont,
|
||||
},
|
||||
|
@ -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,
|
||||
},
|
||||
|
@ -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,
|
||||
},
|
||||
|
@ -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 },
|
||||
|
@ -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: {
|
||||
|
@ -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,
|
||||
},
|
||||
|
@ -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: {
|
||||
|
@ -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",
|
||||
|
@ -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 = `<!-- svg-source:excalidraw -->`;
|
||||
|
||||
@ -439,6 +445,7 @@ const getFontFaces = async (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): Promise<string[]> => {
|
||||
const fontFamilies = new Set<number>();
|
||||
const characters = new Set<string>();
|
||||
const codePoints = new Set<number>();
|
||||
|
||||
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("");
|
||||
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
const fontFaces = await Promise.all(
|
||||
Array.from(fontFamilies).map(async (x) => {
|
||||
const { fonts, metadata } = Fonts.registered.get(x) ?? {};
|
||||
const iterator = fontFacesIterator(fontFamilies, uniqueChars, codePoints);
|
||||
|
||||
if (!Array.isArray(fonts)) {
|
||||
// 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();
|
||||
|
||||
// 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<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 "${x}"`,
|
||||
`Couldn't find registered fonts for font-family "${family}"`,
|
||||
Fonts.registered,
|
||||
);
|
||||
return [];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (metadata?.local) {
|
||||
// don't inline local fonts
|
||||
return [];
|
||||
continue;
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
fonts.map(
|
||||
async (font) => `@font-face {
|
||||
font-family: ${font.fontFace.family};
|
||||
src: url(${await getSource(font)});
|
||||
}`,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
for (const [fontFaceIndex, fontFace] of fontFaces.entries()) {
|
||||
const pendingFontFace = fontFace.toCSS(characters, codePoints);
|
||||
|
||||
return fontFaces.flat();
|
||||
};
|
||||
// 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 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 <TValue, TArgs extends unknown[]>(
|
||||
|
||||
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<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-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"
|
||||
|
Loading…
Reference in New Issue
Block a user