mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
feat: add first-class support for CJK (#8530)
This commit is contained in:
parent
21815fb930
commit
b479f3bd65
288 changed files with 3559 additions and 918 deletions
|
@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue