Glyph subsetting for SVG export

This commit is contained in:
Marcel Mraz 2024-08-08 18:31:50 +02:00
parent dd1370381d
commit 9c91cf93dd
6 changed files with 136 additions and 62 deletions

View File

@ -850,7 +850,7 @@ export const actionChangeFontFamily = register({
ExcalidrawTextElement,
ExcalidrawElement | null
>();
let uniqueGlyphs = new Set<string>();
let uniqueChars = new Set<string>();
let skipFontFaceCheck = false;
const fontsCache = Array.from(Fonts.loadedFontsCache.values());
@ -898,8 +898,8 @@ export const actionChangeFontFamily = register({
}
if (!skipFontFaceCheck) {
uniqueGlyphs = new Set([
...uniqueGlyphs,
uniqueChars = new Set([
...uniqueChars,
...Array.from(newElement.originalText),
]);
}
@ -919,12 +919,9 @@ export const actionChangeFontFamily = register({
const fontString = `10px ${getFontFamilyString({
fontFamily: nextFontFamily,
})}`;
const glyphs = Array.from(uniqueGlyphs.values()).join();
const chars = Array.from(uniqueChars.values()).join();
if (
skipFontFaceCheck ||
window.document.fonts.check(fontString, glyphs)
) {
if (skipFontFaceCheck || window.document.fonts.check(fontString, chars)) {
// we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded
for (const [element, container] of elementContainerMapping) {
// trigger synchronous redraw
@ -936,8 +933,8 @@ export const actionChangeFontFamily = register({
);
}
} else {
// otherwise try to load all font faces for the given glyphs and redraw elements once our font faces loaded
window.document.fonts.load(fontString, glyphs).then((fontFaces) => {
// otherwise try to load all font faces for the given chars and redraw elements once our font faces loaded
window.document.fonts.load(fontString, chars).then((fontFaces) => {
for (const [element, container] of elementContainerMapping) {
// use latest element state to ensure we don't have closure over an old instance in order to avoid possible race conditions (i.e. font faces load out-of-order while rapidly switching fonts)
const latestElement = app.scene.getElement(element.id);

View File

@ -1,10 +1,9 @@
import { stringToBase64, toByteString } from "../data/encode";
import { LOCAL_FONT_PROTOCOL } from "./metadata";
export interface Font {
urls: URL[];
fontFace: FontFace;
getContent(): Promise<string>;
getContent(codePoints: ReadonlySet<number>): Promise<string>;
}
export const UNPKG_PROD_URL = `https://unpkg.com/${
import.meta.env.VITE_PKG_NAME
@ -12,6 +11,10 @@ export const UNPKG_PROD_URL = `https://unpkg.com/${
: "@excalidraw/excalidraw" // fallback to latest package version (i.e. for app)
}/dist/prod/`;
/** caches for lazy loaded chunks, reused across concurrent calls and separate editor instances */
let fontEditorCache: Promise<typeof import("fonteditor-core")> | null = null;
let brotliCache: Promise<typeof import("fonteditor-core").woff2> | null = null;
export class ExcalidrawFont implements Font {
public readonly urls: URL[];
public readonly fontFace: FontFace;
@ -33,20 +36,31 @@ export class ExcalidrawFont implements Font {
/**
* Tries to fetch woff2 content, based on the registered urls.
* Returns last defined url in case of errors.
*
* Note: uses browser APIs for base64 encoding - use dataurl outside the browser environment.
* NOTE: assumes usage of `dataurl` outside the browser environment
*
* @returns base64 with subsetted glyphs based on the passed codepoint, last defined url otherwise
*/
public async getContent(): Promise<string> {
public async getContent(codePoints: ReadonlySet<number>): Promise<string> {
let i = 0;
const errorMessages = [];
while (i < this.urls.length) {
const url = this.urls[i];
// it's dataurl, the font is inlined as base64, no need to fetch
if (url.protocol === "data:") {
// it's dataurl, the font is inlined as base64, no need to fetch
return url.toString();
const arrayBuffer = Buffer.from(
url.toString().split(",")[1],
"base64",
).buffer;
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
arrayBuffer,
codePoints,
);
return base64;
}
try {
@ -57,13 +71,12 @@ export class ExcalidrawFont implements Font {
});
if (response.ok) {
const mimeType = await response.headers.get("Content-Type");
const buffer = await response.arrayBuffer();
return `data:${mimeType};base64,${await stringToBase64(
await toByteString(buffer),
true,
)}`;
const arrayBuffer = await response.arrayBuffer();
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
arrayBuffer,
codePoints,
);
return base64;
}
// response not ok, try to continue
@ -89,6 +102,45 @@ export class ExcalidrawFont implements Font {
return this.urls.length ? this.urls[this.urls.length - 1].toString() : "";
}
/**
* Converts a font data as arraybuffer into a dataurl (base64) with subsetted glyphs based on the specified `codePoints`.
*
* NOTE: only glyphs are subsetted, other metadata as GPOS tables stay, consider filtering those as well in the future
*
* @param arrayBuffer font data buffer, preferrably in the woff2 format, though others should work as well
* @param codePoints codepoints used to subset the glyphs
*
* @returns font with subsetted glyphs converted into a dataurl
*/
private static async subsetGlyphsByCodePoints(
arrayBuffer: ArrayBuffer,
codePoints: ReadonlySet<number>,
): Promise<string> {
// checks for the cache first to avoid triggering the import multiple times in case of concurrent calls
if (!fontEditorCache) {
fontEditorCache = import("fonteditor-core");
}
const { Font, woff2 } = await fontEditorCache;
// checks for the cache first to avoid triggering the init multiple times in case of concurrent calls
if (!brotliCache) {
brotliCache = woff2.init("/wasm/woff2.wasm");
}
await brotliCache;
const font = Font.create(arrayBuffer, {
type: "woff2",
kerning: true,
hinting: true,
// subset the glyhs based on the specified codepoints!
subset: [...codePoints],
});
return font.toBase64({ type: "woff2", hinting: true });
}
private static createUrls(uri: string): URL[] {
if (uri.startsWith(LOCAL_FONT_PROTOCOL)) {
// no url for local fonts

View File

@ -67,6 +67,7 @@
"canvas-roundrect-polyfill": "0.0.1",
"clsx": "1.1.1",
"cross-env": "7.0.3",
"fonteditor-core": "2.4.1",
"fractional-indexing": "3.2.0",
"fuzzy": "0.1.3",
"image-blob-reduce": "3.0.1",

View File

@ -354,50 +354,14 @@ export const exportToSvg = async (
</clipPath>`;
}
const fontFamilies = elements.reduce((acc, element) => {
if (isTextElement(element)) {
acc.add(element.fontFamily);
}
return acc;
}, new Set<number>());
const fontFaces = opts?.skipInliningFonts
? []
: 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 font.getContent()});
}`,
),
);
}),
);
const fontFaces = opts?.skipInliningFonts ? [] : await getFontFaces(elements);
svgRoot.innerHTML = `
${SVG_EXPORT_TAG}
${metadata}
<defs>
<style class="style-fonts">
${fontFaces.flat().filter(Boolean).join("\n")}
${fontFaces.join("\n")}
</style>
${exportingFrameClipPath}
</defs>
@ -468,3 +432,56 @@ export const getExportSize = (
return [width, height];
};
const getFontFaces = async (
elements: readonly ExcalidrawElement[],
): Promise<string[]> => {
const fontFamilies = new Set<number>();
const codePoints = new Set<number>();
for (const element of elements) {
if (!isTextElement(element)) {
continue;
}
fontFamilies.add(element.fontFamily);
for (const codePoint of Array.from(element.originalText, (u) =>
u.codePointAt(0),
)) {
if (codePoint) {
codePoints.add(codePoint);
}
}
}
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 font.getContent(codePoints)});
}`,
),
);
}),
);
return fontFaces.flat();
};

BIN
public/wasm/woff2.wasm Normal file

Binary file not shown.

View File

@ -6194,6 +6194,13 @@ fonteditor-core@2.4.0:
dependencies:
"@xmldom/xmldom" "^0.8.3"
fonteditor-core@2.4.1:
version "2.4.1"
resolved "https://registry.yarnpkg.com/fonteditor-core/-/fonteditor-core-2.4.1.tgz#ff4b3cd04b50f98026bedad353d0ef6692464bc9"
integrity sha512-nKDDt6kBQGq665tQO5tCRQUClJG/2MAF9YT1eKHl+I4NasdSb6DgXrv/gMjNxjo9NyaVEv9KU9VZxLHMstN1wg==
dependencies:
"@xmldom/xmldom" "^0.8.3"
for-each@^0.3.3:
version "0.3.3"
resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e"