Glyph subsetting for SVG export

mrazator/glyph-subsetting
Marcel Mraz 6 months ago
parent dd1370381d
commit 9c91cf93dd

@ -850,7 +850,7 @@ export const actionChangeFontFamily = register({
ExcalidrawTextElement, ExcalidrawTextElement,
ExcalidrawElement | null ExcalidrawElement | null
>(); >();
let uniqueGlyphs = new Set<string>(); let uniqueChars = new Set<string>();
let skipFontFaceCheck = false; let skipFontFaceCheck = false;
const fontsCache = Array.from(Fonts.loadedFontsCache.values()); const fontsCache = Array.from(Fonts.loadedFontsCache.values());
@ -898,8 +898,8 @@ export const actionChangeFontFamily = register({
} }
if (!skipFontFaceCheck) { if (!skipFontFaceCheck) {
uniqueGlyphs = new Set([ uniqueChars = new Set([
...uniqueGlyphs, ...uniqueChars,
...Array.from(newElement.originalText), ...Array.from(newElement.originalText),
]); ]);
} }
@ -919,12 +919,9 @@ export const actionChangeFontFamily = register({
const fontString = `10px ${getFontFamilyString({ const fontString = `10px ${getFontFamilyString({
fontFamily: nextFontFamily, fontFamily: nextFontFamily,
})}`; })}`;
const glyphs = Array.from(uniqueGlyphs.values()).join(); const chars = Array.from(uniqueChars.values()).join();
if ( if (skipFontFaceCheck || window.document.fonts.check(fontString, chars)) {
skipFontFaceCheck ||
window.document.fonts.check(fontString, glyphs)
) {
// 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 // 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) { for (const [element, container] of elementContainerMapping) {
// trigger synchronous redraw // trigger synchronous redraw
@ -936,8 +933,8 @@ export const actionChangeFontFamily = register({
); );
} }
} else { } else {
// otherwise try to load all font faces for the given glyphs and redraw elements once our font faces loaded // otherwise try to load all font faces for the given chars and redraw elements once our font faces loaded
window.document.fonts.load(fontString, glyphs).then((fontFaces) => { window.document.fonts.load(fontString, chars).then((fontFaces) => {
for (const [element, container] of elementContainerMapping) { 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) // 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); const latestElement = app.scene.getElement(element.id);

@ -1,10 +1,9 @@
import { stringToBase64, toByteString } from "../data/encode";
import { LOCAL_FONT_PROTOCOL } from "./metadata"; import { LOCAL_FONT_PROTOCOL } from "./metadata";
export interface Font { export interface Font {
urls: URL[]; urls: URL[];
fontFace: FontFace; fontFace: FontFace;
getContent(): Promise<string>; getContent(codePoints: ReadonlySet<number>): Promise<string>;
} }
export const UNPKG_PROD_URL = `https://unpkg.com/${ export const UNPKG_PROD_URL = `https://unpkg.com/${
import.meta.env.VITE_PKG_NAME 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) : "@excalidraw/excalidraw" // fallback to latest package version (i.e. for app)
}/dist/prod/`; }/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 { export class ExcalidrawFont implements Font {
public readonly urls: URL[]; public readonly urls: URL[];
public readonly fontFace: FontFace; public readonly fontFace: FontFace;
@ -33,20 +36,31 @@ export class ExcalidrawFont implements Font {
/** /**
* Tries to fetch woff2 content, based on the registered urls. * 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; let i = 0;
const errorMessages = []; const errorMessages = [];
while (i < this.urls.length) { while (i < this.urls.length) {
const url = this.urls[i]; const url = this.urls[i];
// it's dataurl, the font is inlined as base64, no need to fetch
if (url.protocol === "data:") { if (url.protocol === "data:") {
// it's dataurl, the font is inlined as base64, no need to fetch const arrayBuffer = Buffer.from(
return url.toString(); url.toString().split(",")[1],
"base64",
).buffer;
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
arrayBuffer,
codePoints,
);
return base64;
} }
try { try {
@ -57,13 +71,12 @@ export class ExcalidrawFont implements Font {
}); });
if (response.ok) { if (response.ok) {
const mimeType = await response.headers.get("Content-Type"); const arrayBuffer = await response.arrayBuffer();
const buffer = await response.arrayBuffer(); const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
arrayBuffer,
return `data:${mimeType};base64,${await stringToBase64( codePoints,
await toByteString(buffer), );
true, return base64;
)}`;
} }
// response not ok, try to continue // 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() : ""; 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[] { private static createUrls(uri: string): URL[] {
if (uri.startsWith(LOCAL_FONT_PROTOCOL)) { if (uri.startsWith(LOCAL_FONT_PROTOCOL)) {
// no url for local fonts // no url for local fonts

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

@ -354,50 +354,14 @@ export const exportToSvg = async (
</clipPath>`; </clipPath>`;
} }
const fontFamilies = elements.reduce((acc, element) => { const fontFaces = opts?.skipInliningFonts ? [] : await getFontFaces(elements);
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()});
}`,
),
);
}),
);
svgRoot.innerHTML = ` svgRoot.innerHTML = `
${SVG_EXPORT_TAG} ${SVG_EXPORT_TAG}
${metadata} ${metadata}
<defs> <defs>
<style class="style-fonts"> <style class="style-fonts">
${fontFaces.flat().filter(Boolean).join("\n")} ${fontFaces.join("\n")}
</style> </style>
${exportingFrameClipPath} ${exportingFrameClipPath}
</defs> </defs>
@ -468,3 +432,56 @@ export const getExportSize = (
return [width, height]; 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();
};

Binary file not shown.

@ -6194,6 +6194,13 @@ fonteditor-core@2.4.0:
dependencies: dependencies:
"@xmldom/xmldom" "^0.8.3" "@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: for-each@^0.3.3:
version "0.3.3" version "0.3.3"
resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e"

Loading…
Cancel
Save