Skip to content

Commit 1616a72

Browse files
committed
perf: replace opentype.js font metric parsing
1 parent 47b853b commit 1616a72

7 files changed

Lines changed: 172 additions & 91 deletions

File tree

RefactorPlan.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ validated, and what remains uncertain.
3535
| Use inflate-only Pako runtime | Complete | Replaced the full Pako distribution with its API-compatible inflate-only build; all existing Excalidraw and locale payloads retain their format and decompression path |
3636
| Replace bundled YAML runtime | Implemented; awaiting manual validation | `mergeMarkdownFiles()` now uses Obsidian's public YAML APIs, and `yaml` is no longer a direct production dependency |
3737
| Compress per-window React payload | Complete | React, ReactDOM, and the JSX shim are inflated before React participates in plugin bootstrap; the decompressed source remains available for popout-window package creation, and manual testing found no startup or runtime regression |
38+
| Replace bundled OpenType metric reader | Complete | A bounds-checked SFNT reader now extracts only the TTF/OTF metrics consumed by Excalidraw; WOFF/WOFF2 fallback behavior is unchanged, `opentype.js` is no longer bundled, and manual testing found no font regression |
3839
| Audit and consolidate duplicate logic | In progress | Consolidated `updateFrontmatterInString()`, `arrayToMap()`, `wrapTextAtCharLength()`, `getLinkParts()`/`LinkParts`, `getBinaryFileFromDataURL()`, `svgToBase64()`, `getFontDataURL()`, `cropCanvas()`, `getImageSize()`, `promiseTry()`, `isVersionNewerThanOther()`, `repositionElementsToCursor()`, the internal `cloneElement()`, and `getBoundTextElementId()`; continue one independently testable helper family at a time |
3940
| Remaining view phases | In progress | Manually validate the extension renderer checkpoint before choosing between the higher-risk scene-file loader and a mechanical package-aware React-root extraction |
4041

@@ -79,6 +80,8 @@ validated, and what remains uncertain.
7980
| 2026-08-09 | Replaced the bundled YAML runtime with Obsidian's public YAML APIs | Updated the sole runtime consumer, `mergeMarkdownFiles()`, to use `parseYaml()` and `stringifyYaml()` from `obsidian`, added TSDoc for its precedence and array-merge contract, and removed `yaml` as a direct production dependency. Existing target frontmatter remains text-preserved. Obsidian serialization may represent nulls as empty values and keep long scalars on one line; these forms parse to the same values as the previous output | Repository search confirms no source imports from `yaml`; targeted ESLint and `npm run lib` passed; production builds before and after dependency cleanup passed with the existing 33 circular-dependency warnings; the CRLF-aware whitespace check passed. `main.js` decreased 104,707 bytes, from 5,078,533 to 4,973,826 bytes, leaving 269,054 bytes below 5 MiB. Manual validation should prioritize template/target array merging and missing keys through **Convert note to Excalidraw** and `ExcalidrawAutomate.create()`, then null, long-text, date, alias, multiline, quoted-value, desktop, and mobile cases |
8081
| 2026-08-09 | Compressed the per-window React runtime payload | Deflated the minified React, ReactDOM, and JSX-shim source at build time. In the emitted bootstrap, inflate-only Pako and the dependency-free `unpackBase64Deflate()` helper are initialized first; only then is `REACT_PACKAGES` inflated and evaluated. The decompressed string remains alive for `PackageManager.getPackage()` to evaluate in each popout window. Removed the now-unused direct `jsesc` development dependency. This ordering specifically addresses the prior attempt that failed before React decompression could run | `npm run build` passed with the existing 33 circular-dependency warnings; `node --check dist/main.js` passed; emitted-order inspection confirms the inflater precedes React decompression; an isolated bootstrap smoke test initialized React 18.3.1 and ReactDOM, then successfully evaluated the retained package source a second time to simulate a popout. `main.js` decreased 78,540 bytes, from 4,973,826 to 4,895,286 bytes, leaving 347,594 bytes below 5 MiB. Manual validation is mandatory because the historical failure occurred during Obsidian startup: test desktop and mobile cold starts first, then main-window rendering, a new and restored popout, moving a leaf between windows, plugin disable/re-enable, and the extension-renderer paths |
8182
| 2026-08-09 | Closed the compressed React validation checkpoint | Manual testing found no startup or runtime regressions with the bootstrap-safe compressed React payload | User confirmed the implementation works; the checkpoint is ready to commit |
83+
| 2026-08-09 | Replaced `opentype.js` with a focused SFNT metric reader | Added documented, bounds-checked parsing of `head.unitsPerEm`, `hhea.ascender`, and `hhea.descender`, preserving exact raw values and the established line-height calculation. The already-read vault buffer is reused instead of decoding the generated data URL. Glyph outlines, shaping, rendering, and embedding remain owned by the browser and Excalidraw. Removed unused font-family-name parsing plus the `opentype.js` runtime and type dependencies. WOFF/WOFF2 continue to use the same fallback metrics as before | The new reader produced exact metric parity with `opentype.js` across all 21 installed TTF/OTF fixtures, including a CFF OTF, and safely rejected three malformed/truncated fixtures. Repository search found no remaining runtime dependency references. Targeted ESLint passed for the new reader and `FontManager`; `node --check dist/main.js` and production builds passed with the existing 33 circular-dependency warnings; `npm run madge` remains unavailable because `madge` is not installed. `main.js` decreased 186,132 bytes, from 4,895,286 to 4,709,154 bytes, leaving 533,726 bytes below 5 MiB. Manual validation should first compare existing and newly created local-font text using representative TTF and OTF files on desktop, including wrapping, baselines, bound text, reload, SVG/PNG/PDF export, and a popout; then repeat core text creation/reload on mobile and smoke-test WOFF/WOFF2. The highest-risk regression is different layout for an unusual TTF/OTF whose metrics fall back because its table structure is malformed or unsupported |
84+
| 2026-08-09 | Closed the focused SFNT reader validation checkpoint | Manual testing found no regressions in local-font loading or behavior after removing `opentype.js` | User confirmed the implementation works well; the checkpoint is ready to commit |
8285

8386
## Executive recommendation
8487

@@ -149,8 +152,9 @@ bundle exactly 25,380 bytes to 5,078,533 bytes. Current headroom is 164,347
149152
bytes (about 160 KiB or 3.13%). Replacing the bundled YAML runtime then reduced
150153
the bundle another 104,707 bytes to 4,973,826 bytes. Current headroom is
151154
269,054 bytes (about 263 KiB or 5.13%). Compressing the React package source
152-
then reduced the bundle another 78,540 bytes to 4,895,286 bytes. Current
153-
headroom is 347,594 bytes (about 339 KiB or 6.63%).
155+
then reduced the bundle another 78,540 bytes to 4,895,286 bytes. Replacing
156+
`opentype.js` with the focused SFNT reader reduced it another 186,132 bytes to
157+
4,709,154 bytes. Current headroom is 533,726 bytes (about 521 KiB or 10.18%).
154158

155159
| Injected section | Approximate production characters | Share of `main.js` |
156160
| --- | ---: | ---: |
@@ -189,12 +193,12 @@ Recommended order:
189193
unreachable even when the user manually requests all notes. Those entries
190194
occupy about 18 KB of source string data. Preserve their history outside
191195
the runtime import graph.
192-
5. Replace `opentype.js` only as a separately tested feature change. It is
193-
imported at one site solely to read `unitsPerEm`, ascender, descender, and
194-
English family name for custom TTF/OTF fonts. Its measured standalone
195-
production cost is about 169 KB. A focused SFNT table reader could recover
196-
most of that, but must cover real TTF/OTF collections and name encodings;
197-
this has more functional risk than the preceding items.
196+
5. Implemented; awaiting manual validation: replaced `opentype.js` with a
197+
focused SFNT reader for the three metrics actually consumed by Excalidraw.
198+
English family-name parsing was removed because it had no consumer. The
199+
reader matched all 21 available TTF/OTF fixtures exactly and retains the
200+
existing fallback for invalid files and all WOFF/WOFF2 fonts. The production
201+
bundle decreased 186,132 bytes.
198202

199203
Secondary static-payload candidates, after the above checkpoints:
200204

package-lock.json

Lines changed: 0 additions & 37 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@
3838
"lz-string": "^1.5.0",
3939
"monkey-around": "^2.3.0",
4040
"nanoid": "^5.1.16",
41-
"opentype.js": "^1.3.4",
4241
"pako": "^2.1.0",
4342
"points-on-path": "^0.2.1",
4443
"polybooljs": "^1.2.0",
@@ -68,7 +67,6 @@
6867
"@types/chroma-js": "^3.1.1",
6968
"@types/js-beautify": "^1.14.0",
7069
"@types/node": "^22.19.19",
71-
"@types/opentype.js": "^1.3.8",
7270
"@types/react": "^18.2.45",
7371
"@types/react-dom": "^18.2.18",
7472
"@zsviczian/excalidraw-extras-api": "^0.0.13",

src/core/managers/FontManager.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import {
77
} from "src/constants/constants";
88
import { t } from "src/lang/helpers";
99
import { getCJKDataURLs } from "src/utils/CJKLoader";
10-
import { getFontDataURL, getFontMetrics } from "src/utils/utils";
10+
import { getFontDataURL } from "src/utils/utils";
11+
import { getFontMetrics } from "src/utils/fontMetrics";
1112
import type ExcalidrawPlugin from "src/core/main";
1213

1314
declare const mainDocument: Document;
@@ -115,17 +116,16 @@ export class FontManager {
115116
this.plugin.settings.experimantalFourthFont,
116117
"",
117118
);
118-
let fontMetrics = file.extension.startsWith("woff")
119+
let fontMetrics = file.extension.startsWith("woff") || !font.arrayBuffer
119120
? undefined
120-
: await getFontMetrics(fourthFontDataURL, "Local Font");
121+
: getFontMetrics(font.arrayBuffer);
121122

122123
if (!fontMetrics) {
123124
fontMetrics = {
124125
unitsPerEm: 1000,
125126
ascender: 750,
126127
descender: -250,
127128
lineHeight: 1.2,
128-
fontName: "Local Font",
129129
};
130130
}
131131
this.getPackageMap().forEach(({ excalidrawLib }) => {

src/utils/embeddedAssetUtils.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ export { getEmbeddedFilenameParts } from "./embeddedFilenameParts";
1313
* @param fontFileName - Vault link path or font filename to resolve.
1414
* @param sourcePath - Vault path from which the font link is resolved.
1515
* @param name - Optional font-family name overriding the file basename.
16-
* @returns The font definition, resolved family name, and encoded data URL.
17-
* Empty strings are returned when the font cannot be resolved.
16+
* @returns The font definition, resolved family name, encoded data URL, and
17+
* original buffer. Empty strings and a null buffer are returned when the font
18+
* cannot be resolved.
1819
* @remarks
1920
* MIME types and CSS format names intentionally preserve the established
2021
* extension mapping used by plugin startup and embedded SVG font loading.
@@ -24,13 +25,19 @@ export async function getFontDataURL(
2425
fontFileName: string,
2526
sourcePath: string,
2627
name?: string,
27-
): Promise<{ fontDef: string; fontName: string; dataURL: string }> {
28+
): Promise<{
29+
fontDef: string;
30+
fontName: string;
31+
dataURL: string;
32+
arrayBuffer: ArrayBuffer | null;
33+
}> {
2834
let fontDef = "";
2935
let fontName = "";
3036
let dataURL = "";
37+
let arrayBuffer: ArrayBuffer | null = null;
3138
const f = app.metadataCache.getFirstLinkpathDest(fontFileName, sourcePath);
3239
if (f) {
33-
const ab = await app.vault.readBinary(f);
40+
arrayBuffer = await app.vault.readBinary(f);
3441
let mimeType = "";
3542
let format = "";
3643

@@ -55,12 +62,12 @@ export async function getFontDataURL(
5562
mimeType = "application/octet-stream";
5663
}
5764
fontName = name ?? f.basename;
58-
dataURL = await getDataURL(ab, mimeType);
65+
dataURL = await getDataURL(arrayBuffer, mimeType);
5966
const split = dataURL.split(";base64,", 2);
6067
dataURL = `${split[0]};charset=utf-8;base64,${split[1]}`;
6168
fontDef = ` @font-face {font-family: "${fontName}";src: url("${dataURL}") format("${format}")}`;
6269
}
63-
return { fontDef, fontName, dataURL };
70+
return { fontDef, fontName, dataURL, arrayBuffer };
6471
}
6572

6673
/**

src/utils/fontMetrics.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* Reads only the SFNT metadata required to register a local Excalidraw font.
3+
*
4+
* @packageDocumentation
5+
*/
6+
7+
const SFNT_HEADER_SIZE = 12;
8+
const TABLE_RECORD_SIZE = 16;
9+
const TABLE_TAG_SIZE = 4;
10+
const TABLE_OFFSET_POSITION = 8;
11+
const TABLE_LENGTH_POSITION = 12;
12+
13+
const HEAD_UNITS_PER_EM_POSITION = 18;
14+
const HEAD_REQUIRED_LENGTH = HEAD_UNITS_PER_EM_POSITION + 2;
15+
const HHEA_ASCENDER_POSITION = 4;
16+
const HHEA_DESCENDER_POSITION = 6;
17+
const HHEA_REQUIRED_LENGTH = HHEA_DESCENDER_POSITION + 2;
18+
19+
const SUPPORTED_SFNT_SIGNATURES = new Set([
20+
0x00010000, // TrueType outlines
21+
0x4f54544f, // OTTO: OpenType with CFF/CFF2 outlines
22+
0x74727565, // true: legacy Apple TrueType
23+
0x74797031, // typ1: legacy OpenType wrapper
24+
]);
25+
26+
/** Font metrics registered for Excalidraw's local font family. */
27+
export interface FontMetrics {
28+
/** Raw `head.unitsPerEm` value. The narrow cast preserves uncommon values. */
29+
unitsPerEm: 1000 | 1024 | 2048;
30+
/** Raw `hhea.ascender` value. */
31+
ascender: number;
32+
/** Raw `hhea.descender` value. */
33+
descender: number;
34+
/** Unitless line height derived using the plugin's established formula. */
35+
lineHeight: number;
36+
}
37+
38+
type SfntTable = {
39+
offset: number;
40+
length: number;
41+
};
42+
43+
const readTableTag = (view: DataView, offset: number): string => {
44+
let tag = "";
45+
for (let index = 0; index < TABLE_TAG_SIZE; index++) {
46+
tag += String.fromCharCode(view.getUint8(offset + index));
47+
}
48+
return tag;
49+
};
50+
51+
const isRangeAvailable = (
52+
byteLength: number,
53+
offset: number,
54+
length: number,
55+
): boolean =>
56+
Number.isSafeInteger(offset) &&
57+
Number.isSafeInteger(length) &&
58+
offset >= 0 &&
59+
length >= 0 &&
60+
offset <= byteLength - length;
61+
62+
const findRequiredTables = (
63+
view: DataView,
64+
): { head: SfntTable; hhea: SfntTable } | null => {
65+
if (view.byteLength < SFNT_HEADER_SIZE) {
66+
return null;
67+
}
68+
if (!SUPPORTED_SFNT_SIGNATURES.has(view.getUint32(0))) {
69+
return null;
70+
}
71+
72+
const tableCount = view.getUint16(4);
73+
const directoryLength = tableCount * TABLE_RECORD_SIZE;
74+
if (!isRangeAvailable(view.byteLength, SFNT_HEADER_SIZE, directoryLength)) {
75+
return null;
76+
}
77+
78+
let head: SfntTable | null = null;
79+
let hhea: SfntTable | null = null;
80+
for (let index = 0; index < tableCount && (!head || !hhea); index++) {
81+
const recordOffset = SFNT_HEADER_SIZE + index * TABLE_RECORD_SIZE;
82+
const tag = readTableTag(view, recordOffset);
83+
if (tag !== "head" && tag !== "hhea") {
84+
continue;
85+
}
86+
const table = {
87+
offset: view.getUint32(recordOffset + TABLE_OFFSET_POSITION),
88+
length: view.getUint32(recordOffset + TABLE_LENGTH_POSITION),
89+
};
90+
if (!isRangeAvailable(view.byteLength, table.offset, table.length)) {
91+
return null;
92+
}
93+
if (tag === "head") {
94+
head = table;
95+
} else {
96+
hhea = table;
97+
}
98+
}
99+
return head && hhea ? { head, hhea } : null;
100+
};
101+
102+
/**
103+
* Reads the vertical metrics needed by Excalidraw from a TTF or OTF buffer.
104+
*
105+
* @param fontData - Complete SFNT font data from a `.ttf` or `.otf` file.
106+
* @returns Parsed metrics, or `null` when the font is unsupported or malformed.
107+
* @remarks
108+
* Only the SFNT table directory plus `head.unitsPerEm`, `hhea.ascender`, and
109+
* `hhea.descender` are read. Glyph outlines, names, and layout tables are left
110+
* to the browser and Excalidraw, as they were never consumed by the plugin.
111+
*/
112+
export function getFontMetrics(fontData: ArrayBuffer): FontMetrics | null {
113+
const view = new DataView(fontData);
114+
const tables = findRequiredTables(view);
115+
if (
116+
!tables ||
117+
tables.head.length < HEAD_REQUIRED_LENGTH ||
118+
tables.hhea.length < HHEA_REQUIRED_LENGTH
119+
) {
120+
return null;
121+
}
122+
123+
const unitsPerEm = view.getUint16(
124+
tables.head.offset + HEAD_UNITS_PER_EM_POSITION,
125+
);
126+
const ascender = view.getInt16(
127+
tables.hhea.offset + HHEA_ASCENDER_POSITION,
128+
);
129+
const descender = view.getInt16(
130+
tables.hhea.offset + HHEA_DESCENDER_POSITION,
131+
);
132+
if (unitsPerEm === 0) {
133+
return null;
134+
}
135+
136+
return {
137+
// Excalidraw's type lists common values only; retain the font's exact value.
138+
unitsPerEm: unitsPerEm as FontMetrics["unitsPerEm"],
139+
ascender,
140+
descender,
141+
lineHeight: (ascender - descender) / unitsPerEm,
142+
};
143+
}

0 commit comments

Comments
 (0)