feat: add first-class support for CJK (#8530)

This commit is contained in:
Marcel Mraz 2024-10-17 21:14:17 +03:00 committed by GitHub
parent 21815fb930
commit b479f3bd65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
288 changed files with 3559 additions and 918 deletions

View file

@ -9,7 +9,14 @@ 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,
promiseTry,
toBrandedType,
} from "../utils";
import type { AppState, BinaryFiles } from "../types";
import {
DEFAULT_EXPORT_PADDING,
@ -18,6 +25,9 @@ import {
SVG_NS,
THEME,
THEME_FILTER,
FONT_FAMILY_FALLBACKS,
getFontFamilyFallbacks,
CJK_HAND_DRAWN_FALLBACK_FONT,
} from "../constants";
import { getDefaultAppState } from "../appState";
import { serializeAsJSON } from "../data/json";
@ -39,7 +49,7 @@ import type { RenderableElementsMap } from "./types";
import { syncInvalidIndices } from "../fractionalIndex";
import { renderStaticScene } from "../renderer/staticScene";
import { Fonts } from "../fonts";
import type { Font } from "../fonts/ExcalidrawFont";
import { containsCJK } from "../element/textElement";
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
@ -175,7 +185,7 @@ export const exportToCanvas = async (
return { canvas, scale: appState.exportScale };
},
loadFonts: () => Promise<void> = async () => {
await Fonts.loadFontsForElements(elements);
await Fonts.loadElementsFonts(elements);
},
) => {
// load font faces before continuing, by default leverages browsers' [FontFace API](https://developer.mozilla.org/en-US/docs/Web/API/FontFace)
@ -367,13 +377,13 @@ export const exportToSvg = async (
}
const fontFaces = opts?.skipInliningFonts ? [] : await getFontFaces(elements);
const delimiter = "\n "; // 6 spaces
svgRoot.innerHTML = `
${SVG_EXPORT_TAG}
${metadata}
<defs>
<style class="style-fonts">
${fontFaces.join("\n")}
<style class="style-fonts">${delimiter}${fontFaces.join(delimiter)}
</style>
${exportingFrameClipPath}
</defs>
@ -449,6 +459,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) {
@ -459,52 +470,88 @@ 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 orderedFamilies = Array.from(fontFamilies);
const uniqueChars = Array.from(characters).join("");
const uniqueCodePoints = Array.from(codePoints);
const fontFaces = await Promise.all(
Array.from(fontFamilies).map(async (x) => {
const { fonts, metadata } = Fonts.registered.get(x) ?? {};
if (!Array.isArray(fonts)) {
console.error(
`Couldn't find registered fonts for font-family "${x}"`,
Fonts.registered,
);
return [];
}
if (metadata?.local) {
// don't inline local fonts
return [];
}
return Promise.all(
fonts.map(
async (font) => `@font-face {
font-family: ${font.fontFace.family};
src: url(${await getSource(font)});
}`,
),
);
}),
const containsCJKFallback = orderedFamilies.find((x) =>
getFontFamilyFallbacks(x).includes(CJK_HAND_DRAWN_FALLBACK_FONT),
);
return fontFaces.flat();
// quick check for Han might match a bit more, which is fine
if (containsCJKFallback && containsCJK(uniqueChars)) {
// 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,
uniqueChars,
uniqueCodePoints,
);
// 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>,
characters: string,
codePoints: Array<number>,
): 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 fontFaceCSS = await fontFace.toCSS(characters, codePoints);
if (!fontFaceCSS) {
return;
}
const fontFaceOrder = familyIndex + 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,
);
}
});
}
}
}