Skip to content

Commit

Permalink
feat: subset font glyphs for SVG export (excalidraw#8384)
Browse files Browse the repository at this point in the history
Co-authored-by: dwelle <[email protected]>
  • Loading branch information
Mrazator and dwelle authored Aug 30, 2024
1 parent 16cae4f commit ee30225
Show file tree
Hide file tree
Showing 19 changed files with 4,741 additions and 91 deletions.
23 changes: 0 additions & 23 deletions excalidraw-app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -128,29 +128,6 @@
<script>
window.EXCALIDRAW_ASSET_PATH = window.origin;
</script>

<!-- in DEV we need to preload from the local server and without the hash -->
<link
rel="preload"
href="../packages/excalidraw/fonts/assets/Excalifont-Regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="../packages/excalidraw/fonts/assets/Virgil-Regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="../packages/excalidraw/fonts/assets/ComicShanns-Regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<% } %>

<!-- For Nunito only preload the latin range, which should be good enough for now -->
Expand Down
15 changes: 13 additions & 2 deletions excalidraw-app/vite.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ export default defineConfig({
},

workbox: {
// Don't push fonts and locales to app precache
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js"],
// Don't push fonts, locales and wasm to app precache
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js", "**/*.wasm-*.js"],
runtimeCaching: [
{
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
Expand Down Expand Up @@ -108,6 +108,17 @@ export default defineConfig({
},
},
},
{
urlPattern: new RegExp(".wasm-.+.js"),
handler: "CacheFirst",
options: {
cacheName: "wasm",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
},
},
},
],
},
manifest: {
Expand Down
17 changes: 7 additions & 10 deletions packages/excalidraw/actions/actionProperties.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -898,8 +898,8 @@ export const actionChangeFontFamily = register({
}

if (!skipFontFaceCheck) {
uniqueGlyphs = new Set([
...uniqueGlyphs,
uniqueChars = new Set([
...uniqueChars,
...Array.from(newElement.originalText),
]);
}
Expand All @@ -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
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion packages/excalidraw/analytics.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// place here categories that you want to track. We want to track just a
// small subset of categories at a given time.
const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette"]);
const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette", "export"]);

export const trackEvent = (
category: string,
Expand Down
1 change: 1 addition & 0 deletions packages/excalidraw/components/PublishLibrary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ const SingleLibraryItem = ({
exportBackground: true,
},
files: null,
skipInliningFonts: true,
});
node.innerHTML = svg.outerHTML;
})();
Expand Down
88 changes: 71 additions & 17 deletions packages/excalidraw/fonts/ExcalidrawFont.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { stringToBase64, toByteString } from "../data/encode";
import { LOCAL_FONT_PROTOCOL } from "./metadata";
import loadWoff2 from "./wasm/woff2.loader";
import loadHbSubset from "./wasm/hb-subset.loader";

export interface Font {
urls: URL[];
fontFace: FontFace;
getContent(): Promise<string>;
getContent(codePoints: ReadonlySet<number>): Promise<string>;
}
export const UNPKG_PROD_URL = `https://unpkg.com/${
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)
Expand All @@ -32,21 +34,32 @@ export class ExcalidrawFont implements Font {
}

/**
* Tries to fetch woff2 content, based on the registered urls.
* Returns last defined url in case of errors.
* Tries to fetch woff2 content, based on the registered urls (from first to last, treated as fallbacks).
*
* 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 (server), 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 {
Expand All @@ -57,13 +70,13 @@ export class ExcalidrawFont implements Font {
});

if (response.ok) {
const mimeType = await response.headers.get("Content-Type");
const buffer = await response.arrayBuffer();
const arrayBuffer = await response.arrayBuffer();
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
arrayBuffer,
codePoints,
);

return `data:${mimeType};base64,${await stringToBase64(
await toByteString(buffer),
true,
)}`;
return base64;
}

// response not ok, try to continue
Expand All @@ -89,6 +102,48 @@ export class ExcalidrawFont implements Font {
return this.urls.length ? this.urls[this.urls.length - 1].toString() : "";
}

/**
* Tries to subset glyphs in a font based on the used codepoints, returning the font as daturl.
*
* @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 (all glyphs in case of errors) converted into a dataurl
*/
private static async subsetGlyphsByCodePoints(
arrayBuffer: ArrayBuffer,
codePoints: ReadonlySet<number>,
): Promise<string> {
try {
// lazy loaded wasm modules to avoid multiple initializations in case of concurrent triggers
const { compress, decompress } = await loadWoff2();
const { subset } = await loadHbSubset();

const decompressedBinary = decompress(arrayBuffer).buffer;
const subsetSnft = subset(decompressedBinary, codePoints);
const compressedBinary = compress(subsetSnft.buffer);

return ExcalidrawFont.toBase64(compressedBinary.buffer);
} catch (e) {
console.error("Skipped glyph subsetting", e);
// Fallback to encoding whole font in case of errors
return ExcalidrawFont.toBase64(arrayBuffer);
}
}

private static async toBase64(arrayBuffer: ArrayBuffer) {
let base64: string;

if (typeof Buffer !== "undefined") {
// node + server-side
base64 = Buffer.from(arrayBuffer).toString("base64");
} else {
base64 = await stringToBase64(await toByteString(arrayBuffer), true);
}

return `data:font/woff2;base64,${base64}`;
}

private static createUrls(uri: string): URL[] {
if (uri.startsWith(LOCAL_FONT_PROTOCOL)) {
// no url for local fonts
Expand Down Expand Up @@ -118,15 +173,14 @@ export class ExcalidrawFont implements Font {
}

// fallback url for bundled fonts
urls.push(new URL(assetUrl, UNPKG_PROD_URL));
urls.push(new URL(assetUrl, UNPKG_FALLBACK_URL));

return urls;
}

private static getFormat(url: URL) {
try {
const pathname = new URL(url).pathname;
const parts = pathname.split(".");
const parts = new URL(url).pathname.split(".");

if (parts.length === 1) {
return "";
Expand Down
Loading

0 comments on commit ee30225

Please sign in to comment.