From 39964d4d70a70564a947935405f8fd0ee315fbfc Mon Sep 17 00:00:00 2001 From: zsviczian Date: Sun, 9 Aug 2026 15:27:34 +0200 Subject: [PATCH 1/4] build: use inflate-only pako runtime --- RefactorPlan.md | 103 ++++++++++++++++++++++++++++++++++++++++++++++ rollup.config.mjs | 6 ++- 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/RefactorPlan.md b/RefactorPlan.md index 2c7445eb..d3294f80 100644 --- a/RefactorPlan.md +++ b/RefactorPlan.md @@ -31,6 +31,8 @@ validated, and what remains uncertain. | Extract view link navigation | Implemented; awaiting manual validation | `ViewLinkNavigationManager` now owns element-link resolution, hook dispatch, link prompting, modifier-aware navigation, and special image-link handling while all view methods remain compatibility delegates | | Retire obsolete Draw.io integration | Implemented; awaiting manual validation | Removed special Diagram-plugin routing and retired the Create DrawIO file script from the maintained library and generated reference catalogs; user-installed vault scripts remain untouched | | Extract Excalidraw extension rendering | Implemented; awaiting manual validation | `ViewExcalidrawExtensionRenderer` now owns text-to-diagram, diagram-to-code, welcome screen, custom main menu, and embeddable rendering while the view retains its existing render delegates and package-managed React runtime | +| Audit production bundle size | Complete | Ranked packaging, dependency, dead-data, and static-payload reductions; translation extraction remains a last resort | +| 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 | | 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 | | 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 | @@ -70,6 +72,8 @@ validated, and what remains uncertain. | 2026-08-09 | Extracted link navigation from `ExcalidrawView` | Added documented `ViewLinkNavigationManager` to own tooltip removal, link-source resolution, public hook dispatch, modifier handling, link prompting, new-file behavior, embedded and Markdown image links, LaTeX/Mermaid/Draw.io branches, fullscreen exit, and pane navigation. Retained `removeLinkTooltip()`, `handleLinkHookCall()`, the private selection resolver, `processLinkText()`, `linkClick()`, and `handleLinkClick()` on the view as delegates so every existing caller and hook surface remains intact. Supplied cyclic runtime dependencies and four private view operations explicitly; the manager imports the concrete view only as a type. Reused canonical `SelectedElementWithLink` and `SelectedImage` interfaces from `excalidrawViewTypes.ts` and removed their local duplicates | Final `npm run build`, `npm run lib`, manager-targeted ESLint, repository call-site searches, import-graph analysis, and `git diff --check` passed. The build retained the 33-warning circular-dependency baseline; static analysis found zero runtime import paths from the manager back to `ExcalidrawView`. The full-view lint retains its pre-existing type backlog but no new unused import. `ExcalidrawView.ts` decreased by 442 lines from 8,564 to 8,122; the manager contains 649 documented lines; bundle size increased by 2,478 bytes to 5,100,849 bytes and remains below 5 MiB. Manual link-routing validation remains pending, with modifier semantics and special image-link branches the highest-risk areas | | 2026-08-09 | Retired Draw.io/Diagram plugin customization | Removed detection of `drawio-obsidian`, Draw.io SVG inspection, and routing to the external `diagram-edit` view from link navigation. Deleted the Create DrawIO file Automate script and icon from the maintained script library, its directory metadata and install catalog entries, and its generated script-library and AI-reference copies. Added a 2.27.0 release note. Historical release notes remain unchanged as an accurate record, and existing script files in user vaults are intentionally not deleted | `npm run build`, `npm run lib`, manager-targeted ESLint, JSON parsing, repository residue searches, and `git diff --check` passed. Runtime and maintained catalog searches find no Draw.io plugin IDs, view types, or SVG markers; only the new retirement note, this action history, and historical release notes retain the name. Circular-dependency warnings remain at 33; `ViewLinkNavigationManager.ts` decreased by 25 lines from 649 to 624; the final bundle is 5,100,535 bytes, 314 bytes below the preceding checkpoint and still below 5 MiB. Manual validation should confirm ordinary SVG links now follow normal Obsidian navigation and that the script no longer appears in the downloadable catalog | | 2026-08-09 | Extracted Excalidraw extension rendering from `ExcalidrawView` | Added documented `ViewExcalidrawExtensionRenderer` to own `ttdDialog()`, `diagramToCode()`, `ttdDialogTrigger()`, `renderWelcomeScreen()`, `renderCustomActionsMenu()`, and `renderEmbeddable()`. Kept the six private view methods and all Excalidraw root call sites as delegates. The renderer uses `view.packages.react` and `view.packages.excalidrawLib`; back-edge-prone runtime modules and private dialog actions are constructor-injected, and the concrete view import is type-only. `renderEmbeddableMenu()`, `renderToolsPanel()`, and `renderTopRightUI()` remain in the view because they own refs, live menu instances, or view lifecycle state | Production builds passed after every code edit, `npm run lib` passed, renderer-targeted ESLint and `git diff --check` passed, and full-view lint showed only the established type backlog after extraction-specific unused imports were removed. The build retained the 33-warning circular-dependency baseline. `ExcalidrawView.ts` decreased by 358 lines from 8,122 to 7,764; the renderer contains 479 documented lines; the bundle increased by 1,746 bytes to 5,102,281 bytes and remains below 5 MiB. Manual testing should prioritize a popout window, then the main-window welcome/menu/embeddable paths, then ExcaliAI text-to-diagram and diagram-to-code; repeat core rendering on mobile, with popout React isolation the highest-risk regression | +| 2026-08-09 | Audited production `main.js` size | The fresh 5,103,913-byte bundle has 138,967 bytes of headroom below 5 MiB. The first recommended batch is packaging-only: use Pako's inflate-only build (about 25 KB gross saving) and store the per-window React payload deflated (about 78 KB gross saving). The next source-level candidate is replacing the bundled `yaml` parser used only by `mergeMarkdownFiles()` with Obsidian's external `parseYaml()`/`stringifyYaml()` APIs after compatibility tests. Larger later candidates are a focused TTF/OTF metadata reader instead of `opentype.js`, build-time compaction of CJK metadata and static help/startup payloads, and pruning release-note entries that the current ten-item display cap makes unreachable. Translation extraction and changes to the embedded Excalidraw runtime remain last-resort work | Generated a Rollup module-composition report, measured injected payloads and standalone dependency costs, searched all imports/callers, ran `npm run code:unused` with no unused-variable findings, verified that only `pako.inflate()` is called and that `pako_inflate.min.js` decodes the current payload format, and confirmed that 13 of 23 release-note entries are unreachable under the current `.slice(0, 10)` behavior. The documentation-only production build passed with the existing 33 circular-dependency warnings; no runtime source was changed | +| 2026-08-09 | Replaced full Pako with its inflate-only distribution | Changed only the Rollup build input from `pako.min.js` to `pako_inflate.min.js`. The existing CommonJS wrapper, `pako.inflate()` call, global `unpackBase64Deflate()` compatibility surface, compressed payload format, and per-window package architecture remain unchanged. Obsidian YAML replacement is the next checkpoint. React compression is deferred because a previous attempt prevented `main.js` from completing bootstrap before the inflater could run | `npm run build` passed with the existing 33 circular-dependency warnings; `node --check dist/main.js` passed; the inflate-only and full builds produced byte-for-byte identical output for all five emitted Excalidraw and locale payloads; `git diff --check` passed. `main.js` decreased exactly 25,380 bytes, from 5,103,913 to 5,078,533 bytes, leaving 164,347 bytes below 5 MiB. Manual validation should prioritize cold startup and locale switching on mobile, then a desktop popout; a decompression failure during initialization is the highest-impact risk | ## Executive recommendation @@ -128,6 +132,105 @@ must be checked after every refactor. Source-file splitting will improve source architecture but will not create runtime chunks because Rollup uses `inlineDynamicImports: true` and deliberately emits one CommonJS `main.js`. +### Bundle-size reduction audit for the 2.27.0 checkpoint + +After the view extractions, a fresh production bundle is 5,103,913 bytes. The +5 MiB ceiling is 5,242,880 bytes, leaving 138,967 bytes (about 136 KiB or +2.65%) of headroom. The large injected sections explain why ordinary source +cleanup has limited impact: + +The first implemented reduction, switching to inflate-only Pako, reduced the +bundle exactly 25,380 bytes to 5,078,533 bytes. Current headroom is 164,347 +bytes (about 160 KiB or 3.13%). + +| Injected section | Approximate production characters | Share of `main.js` | +| --- | ---: | ---: | +| Deflated/base64 Excalidraw runtime | 2,786,756 | 54.6% | +| Minified Rollup application bundle | 1,941,603 | 38.1% | +| Four compressed non-English locales | 188,668 | 3.7% | +| Uncompressed per-window React/ReactDOM payload | 138,628 | 2.7% | +| Inflate-only Pako runtime | 21,479 | 0.4% | + +The recent structural extractions increased the application section slightly, +but they did not duplicate React or Excalidraw. The best first savings are +therefore packaging and dependency substitutions rather than reversing the +new module boundaries. + +Recommended order: + +1. Completed: replaced `pako.min.js` with `pako_inflate.min.js`. Repository-wide + search found only `pako.inflate()` at runtime, and the smaller distribution + decodes the same zlib payloads. The source files are 46,859 and 21,479 bytes + respectively; the production bundle decreased exactly 25,380 bytes. +2. Next checkpoint: replace the `yaml` package used only by + `mergeMarkdownFiles()` with + Obsidian's external `parseYaml()` and `stringifyYaml()` functions. A + standalone production bundle of the currently used YAML exports is about + 97 KB. The code change is small, but serializer compatibility must be tested + before adoption because quoting, multiline values, scalar coercion, dates, + aliases, and formatting may differ. +3. Deferred: deflate the React/ReactDOM/JSX-shim source at build time. A prior + attempt caused `main.js` bootstrap to fail before the inflater ran because + React participates elsewhere in the bundled module initialization. Revisit + this only after isolating the exact bootstrap dependency and proving the + approach in Obsidian, not merely with a syntax check. The theoretical gross + saving remains about 78 KB, but the prior runtime failure makes it a + higher-risk optimization than the YAML substitution. +4. Remove or archive release-note entries that cannot be rendered. The dialog + always slices the assembled notes to ten entries; `Messages.ts` currently + has 23 top-level entries, so `2.24.1` and the 12 older entries are + unreachable even when the user manually requests all notes. Those entries + occupy about 18 KB of source string data. Preserve their history outside + the runtime import graph. +5. Replace `opentype.js` only as a separately tested feature change. It is + imported at one site solely to read `unitsPerEm`, ascender, descender, and + English family name for custom TTF/OTF fonts. Its measured standalone + production cost is about 169 KB. A focused SFNT table reader could recover + most of that, but must cover real TTF/OTF collections and name encodings; + this has more functional risk than the preceding items. + +Secondary static-payload candidates, after the above checkpoints: + +- `CJKLoader.ts` contains about 122 KB of generated font filenames and Unicode + ranges. Build-time serialization plus the already available inflater can + compact this substantially without moving the data to Excalidraw Extras. +- `SuggesterInfo.ts` contains about 80 KB of static ExcalidrawAutomate help + metadata. Lazy inflation could reduce its stored representation, but its + synchronous public help and suggester paths must retain the same object + shape and timing. +- The startup-script template is stored as 18,912 base64 characters; deflating + the decoded template produces about 5,096 base64 characters. This is a + modest, localized later win. +- The empty-drawing placeholder is about 23 KB of static scene data and the + embedded Excalidraw Mastery settings logo is about 20 KB. Compacting them or + changing the image format is possible, but both are user-visible and should + follow the non-visual reductions. +- `chroma-js` is used only for applying SVG color alpha while ColorMaster is + already bundled. Consolidation may remove another dependency, but named + colors and exact output syntax must be compared before assuming the two + implementations are interchangeable. + +Do not prioritize the following: + +- Moving translations to Excalidraw Extras. The four compressed locales total + about 189 KB, but the safer first two packaging changes alone should recover + roughly 103 KB, and the dependency substitutions offer more headroom. +- Removing `polybooljs`, `lz-string`, or compatibility facades solely because + they appear large. They participate in the public ExcalidrawAutomate or + persisted-scene surface. +- Replacing Popper or CodeMirror CSS parsing without a dedicated behavioral + project. Their collision handling and editor behavior carry more risk than + their likely saving justifies. +- Changing the embedded Excalidraw runtime or its base64 codec as an early + step. It dominates bundle size, but it is also the most startup-sensitive + and popout-sensitive payload. + +Every bundle reduction must record both gross source expectation and actual +`dist/main.js` delta. Packaging changes require startup tests in the main +window and a popout on desktop, plus a mobile cold start. Font, YAML, SVG, and +static-data changes require the feature-specific tests described above rather +than relying on build success alone. + ### `main.ts` The plugin class is doing three different jobs: diff --git a/rollup.config.mjs b/rollup.config.mjs index 66f51cbb..748318b8 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -104,7 +104,9 @@ const reactdom_pkg = isLib ? "" : minifyCode(isProd ? fs.readFileSync("./node_modules/react-dom/umd/react-dom.production.min.js", "utf8") : fs.readFileSync("./node_modules/react-dom/umd/react-dom.development.js", "utf8")); -const pako_pkg = isLib ? "" : fs.readFileSync("./node_modules/pako/dist/pako.min.js", "utf8"); +// Runtime payloads are only decompressed; including Pako's deflate implementation +// would add unused code to the size-constrained Obsidian plugin bundle. +const pako_pkg = isLib ? "" : fs.readFileSync("./node_modules/pako/dist/pako_inflate.min.js", "utf8"); if (!isLib) { const excalidraw_styles = isProd @@ -292,4 +294,4 @@ if (process.env.NODE_ENV === "lib") { config.push(BUILD_CONFIG); } -export default config; \ No newline at end of file +export default config; From 2da017b5d3b11c9aa93de19201d23e6fe474e58d Mon Sep 17 00:00:00 2001 From: zsviczian Date: Sun, 9 Aug 2026 15:43:37 +0200 Subject: [PATCH 2/4] refactor: use Obsidian YAML APIs --- RefactorPlan.md | 18 ++++++++++-------- package-lock.json | 4 ++-- package.json | 5 ++--- src/utils/obsidianUtils.ts | 24 ++++++++++++++++++------ 4 files changed, 32 insertions(+), 19 deletions(-) diff --git a/RefactorPlan.md b/RefactorPlan.md index d3294f80..d0cf27c9 100644 --- a/RefactorPlan.md +++ b/RefactorPlan.md @@ -33,6 +33,7 @@ validated, and what remains uncertain. | Extract Excalidraw extension rendering | Implemented; awaiting manual validation | `ViewExcalidrawExtensionRenderer` now owns text-to-diagram, diagram-to-code, welcome screen, custom main menu, and embeddable rendering while the view retains its existing render delegates and package-managed React runtime | | Audit production bundle size | Complete | Ranked packaging, dependency, dead-data, and static-payload reductions; translation extraction remains a last resort | | 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 | +| 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 | | 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 | | 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 | @@ -74,6 +75,7 @@ validated, and what remains uncertain. | 2026-08-09 | Extracted Excalidraw extension rendering from `ExcalidrawView` | Added documented `ViewExcalidrawExtensionRenderer` to own `ttdDialog()`, `diagramToCode()`, `ttdDialogTrigger()`, `renderWelcomeScreen()`, `renderCustomActionsMenu()`, and `renderEmbeddable()`. Kept the six private view methods and all Excalidraw root call sites as delegates. The renderer uses `view.packages.react` and `view.packages.excalidrawLib`; back-edge-prone runtime modules and private dialog actions are constructor-injected, and the concrete view import is type-only. `renderEmbeddableMenu()`, `renderToolsPanel()`, and `renderTopRightUI()` remain in the view because they own refs, live menu instances, or view lifecycle state | Production builds passed after every code edit, `npm run lib` passed, renderer-targeted ESLint and `git diff --check` passed, and full-view lint showed only the established type backlog after extraction-specific unused imports were removed. The build retained the 33-warning circular-dependency baseline. `ExcalidrawView.ts` decreased by 358 lines from 8,122 to 7,764; the renderer contains 479 documented lines; the bundle increased by 1,746 bytes to 5,102,281 bytes and remains below 5 MiB. Manual testing should prioritize a popout window, then the main-window welcome/menu/embeddable paths, then ExcaliAI text-to-diagram and diagram-to-code; repeat core rendering on mobile, with popout React isolation the highest-risk regression | | 2026-08-09 | Audited production `main.js` size | The fresh 5,103,913-byte bundle has 138,967 bytes of headroom below 5 MiB. The first recommended batch is packaging-only: use Pako's inflate-only build (about 25 KB gross saving) and store the per-window React payload deflated (about 78 KB gross saving). The next source-level candidate is replacing the bundled `yaml` parser used only by `mergeMarkdownFiles()` with Obsidian's external `parseYaml()`/`stringifyYaml()` APIs after compatibility tests. Larger later candidates are a focused TTF/OTF metadata reader instead of `opentype.js`, build-time compaction of CJK metadata and static help/startup payloads, and pruning release-note entries that the current ten-item display cap makes unreachable. Translation extraction and changes to the embedded Excalidraw runtime remain last-resort work | Generated a Rollup module-composition report, measured injected payloads and standalone dependency costs, searched all imports/callers, ran `npm run code:unused` with no unused-variable findings, verified that only `pako.inflate()` is called and that `pako_inflate.min.js` decodes the current payload format, and confirmed that 13 of 23 release-note entries are unreachable under the current `.slice(0, 10)` behavior. The documentation-only production build passed with the existing 33 circular-dependency warnings; no runtime source was changed | | 2026-08-09 | Replaced full Pako with its inflate-only distribution | Changed only the Rollup build input from `pako.min.js` to `pako_inflate.min.js`. The existing CommonJS wrapper, `pako.inflate()` call, global `unpackBase64Deflate()` compatibility surface, compressed payload format, and per-window package architecture remain unchanged. Obsidian YAML replacement is the next checkpoint. React compression is deferred because a previous attempt prevented `main.js` from completing bootstrap before the inflater could run | `npm run build` passed with the existing 33 circular-dependency warnings; `node --check dist/main.js` passed; the inflate-only and full builds produced byte-for-byte identical output for all five emitted Excalidraw and locale payloads; `git diff --check` passed. `main.js` decreased exactly 25,380 bytes, from 5,103,913 to 5,078,533 bytes, leaving 164,347 bytes below 5 MiB. Manual validation should prioritize cold startup and locale switching on mobile, then a desktop popout; a decompression failure during initialization is the highest-impact risk | +| 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 | ## Executive recommendation @@ -141,7 +143,9 @@ cleanup has limited impact: The first implemented reduction, switching to inflate-only Pako, reduced the bundle exactly 25,380 bytes to 5,078,533 bytes. Current headroom is 164,347 -bytes (about 160 KiB or 3.13%). +bytes (about 160 KiB or 3.13%). Replacing the bundled YAML runtime then reduced +the bundle another 104,707 bytes to 4,973,826 bytes. Current headroom is +269,054 bytes (about 263 KiB or 5.13%). | Injected section | Approximate production characters | Share of `main.js` | | --- | ---: | ---: | @@ -162,13 +166,11 @@ Recommended order: search found only `pako.inflate()` at runtime, and the smaller distribution decodes the same zlib payloads. The source files are 46,859 and 21,479 bytes respectively; the production bundle decreased exactly 25,380 bytes. -2. Next checkpoint: replace the `yaml` package used only by - `mergeMarkdownFiles()` with - Obsidian's external `parseYaml()` and `stringifyYaml()` functions. A - standalone production bundle of the currently used YAML exports is about - 97 KB. The code change is small, but serializer compatibility must be tested - before adoption because quoting, multiline values, scalar coercion, dates, - aliases, and formatting may differ. +2. Implemented; awaiting manual validation: replaced the `yaml` package used + only by `mergeMarkdownFiles()` with Obsidian's external `parseYaml()` and + `stringifyYaml()` functions. The production bundle decreased 104,707 bytes. + Parsing remains equivalent; Obsidian's frontmatter serializer can format + nulls and long lines differently while preserving their parsed values. 3. Deferred: deflate the React/ReactDOM/JSX-shim source at build time. A prior attempt caused `main.js` bootstrap to fail before the inflater ran because React participates elsewhere in the bundled module initialization. Revisit diff --git a/package-lock.json b/package-lock.json index 6fb7e7ee..409b8068 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,8 +27,7 @@ "polybooljs": "^1.2.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "roughjs": "^4.5.2", - "yaml": "^2.9.0" + "roughjs": "^4.5.2" }, "devDependencies": { "@babel/core": "^7.22.9", @@ -13283,6 +13282,7 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/package.json b/package.json index 54c96515..89e1a8e6 100644 --- a/package.json +++ b/package.json @@ -44,8 +44,7 @@ "polybooljs": "^1.2.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "roughjs": "^4.5.2", - "yaml": "^2.9.0" + "roughjs": "^4.5.2" }, "devDependencies": { "@babel/core": "^7.22.9", @@ -111,4 +110,4 @@ "node": ">=22.0.0", "npm": ">=10.0.0" } -} \ No newline at end of file +} diff --git a/src/utils/obsidianUtils.ts b/src/utils/obsidianUtils.ts index f56a5aa6..e58a00f1 100644 --- a/src/utils/obsidianUtils.ts +++ b/src/utils/obsidianUtils.ts @@ -5,6 +5,8 @@ import { MarkdownView, OpenViewState, parseFrontMatterEntry, + parseYaml, + stringifyYaml, TextFileView, TFile, View, @@ -21,7 +23,6 @@ import { FRONTMATTER_KEYS, VIEW_TYPE_EXCALIDRAW, } from "src/constants/constants"; -import { parse, stringify } from "yaml"; import type ExcalidrawView from "src/view/ExcalidrawView"; import { setStyle } from "./styleUtils"; import { isInstanceOfHTMLBodyElement } from "./typechecks"; @@ -378,6 +379,17 @@ export const openLeaf = ({ return { leaf, promise }; }; +/** + * Merges an Excalidraw Markdown template into a target Markdown document. + * + * Existing target frontmatter values take precedence. Arrays present in both + * documents are merged in target-first order, while template-only keys are + * appended using Obsidian's YAML parser and serializer. + * + * @param template - Template Markdown containing frontmatter and optional body. + * @param target - Target Markdown whose existing frontmatter is preserved. + * @returns The merged Markdown document. + */ export function mergeMarkdownFiles(template: string, target: string): string { // Template frontmatter const templateFrontmatterEnd = template.indexOf("---", 4); @@ -385,7 +397,7 @@ export function mergeMarkdownFiles(template: string, target: string): string { .substring(4, templateFrontmatterEnd) .trim(); const templateContent = template.substring(templateFrontmatterEnd + 3); - const templateFrontmatterObj = (parse(templateFrontmatterRaw) || + const templateFrontmatterObj = (parseYaml(templateFrontmatterRaw) || {}) as FrontMatterCache; const hasTargetFM = @@ -397,7 +409,7 @@ export function mergeMarkdownFiles(template: string, target: string): string { .replace(/\s+$/, ""); // keep as-is const targetContent = target.substring(targetFrontmatterEnd + 3); - const targetFrontmatterObj: FrontMatterCache = (parse( + const targetFrontmatterObj: FrontMatterCache = (parseYaml( targetFrontmatterRaw, ) || {}) as FrontMatterCache; @@ -414,7 +426,7 @@ export function mergeMarkdownFiles(template: string, target: string): string { const tplArr = templateFrontmatterObj[k] as unknown[]; const merged = [...tArr, ...tplArr.filter((v) => !tArr.includes(v))]; // Produce YAML for just this key - const mergedYaml = stringify({ [k]: merged }).trimEnd(); + const mergedYaml = stringifyYaml({ [k]: merged }).trimEnd(); targetFrontmatterRaw = replaceYamlKeyBlock(targetFrontmatterRaw, k, mergedYaml) ?? targetFrontmatterRaw; @@ -430,14 +442,14 @@ export function mergeMarkdownFiles(template: string, target: string): string { } const appended = Object.keys(newKeys).length - ? `${targetFrontmatterRaw}\n${stringify(newKeys).trimEnd()}` + ? `${targetFrontmatterRaw}\n${stringifyYaml(newKeys).trimEnd()}` : targetFrontmatterRaw; return `---\n${appended}\n---\n${targetContent}\n\n${templateContent.trim()}\n`; } // No frontmatter in target: use template FM + target content + template content const targetContent = target.trim(); - const templateFMYaml = stringify(templateFrontmatterObj).trimEnd(); + const templateFMYaml = stringifyYaml(templateFrontmatterObj).trimEnd(); return `---\n${templateFMYaml}\n---\n${targetContent}\n\n${templateContent.trim()}\n`; } From 47b853b3b36f460afc539c1be677327386ce140c Mon Sep 17 00:00:00 2001 From: zsviczian Date: Sun, 9 Aug 2026 15:49:39 +0200 Subject: [PATCH 3/4] build: compress per-window React runtime --- RefactorPlan.md | 21 +++++++++++++-------- package-lock.json | 1 - package.json | 1 - rollup.config.mjs | 10 +++++----- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/RefactorPlan.md b/RefactorPlan.md index d0cf27c9..aa53e6bc 100644 --- a/RefactorPlan.md +++ b/RefactorPlan.md @@ -34,6 +34,7 @@ validated, and what remains uncertain. | Audit production bundle size | Complete | Ranked packaging, dependency, dead-data, and static-payload reductions; translation extraction remains a last resort | | 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 | | 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 | +| 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 | | 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 | | 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 | @@ -76,6 +77,8 @@ validated, and what remains uncertain. | 2026-08-09 | Audited production `main.js` size | The fresh 5,103,913-byte bundle has 138,967 bytes of headroom below 5 MiB. The first recommended batch is packaging-only: use Pako's inflate-only build (about 25 KB gross saving) and store the per-window React payload deflated (about 78 KB gross saving). The next source-level candidate is replacing the bundled `yaml` parser used only by `mergeMarkdownFiles()` with Obsidian's external `parseYaml()`/`stringifyYaml()` APIs after compatibility tests. Larger later candidates are a focused TTF/OTF metadata reader instead of `opentype.js`, build-time compaction of CJK metadata and static help/startup payloads, and pruning release-note entries that the current ten-item display cap makes unreachable. Translation extraction and changes to the embedded Excalidraw runtime remain last-resort work | Generated a Rollup module-composition report, measured injected payloads and standalone dependency costs, searched all imports/callers, ran `npm run code:unused` with no unused-variable findings, verified that only `pako.inflate()` is called and that `pako_inflate.min.js` decodes the current payload format, and confirmed that 13 of 23 release-note entries are unreachable under the current `.slice(0, 10)` behavior. The documentation-only production build passed with the existing 33 circular-dependency warnings; no runtime source was changed | | 2026-08-09 | Replaced full Pako with its inflate-only distribution | Changed only the Rollup build input from `pako.min.js` to `pako_inflate.min.js`. The existing CommonJS wrapper, `pako.inflate()` call, global `unpackBase64Deflate()` compatibility surface, compressed payload format, and per-window package architecture remain unchanged. Obsidian YAML replacement is the next checkpoint. React compression is deferred because a previous attempt prevented `main.js` from completing bootstrap before the inflater could run | `npm run build` passed with the existing 33 circular-dependency warnings; `node --check dist/main.js` passed; the inflate-only and full builds produced byte-for-byte identical output for all five emitted Excalidraw and locale payloads; `git diff --check` passed. `main.js` decreased exactly 25,380 bytes, from 5,103,913 to 5,078,533 bytes, leaving 164,347 bytes below 5 MiB. Manual validation should prioritize cold startup and locale switching on mobile, then a desktop popout; a decompression failure during initialization is the highest-impact risk | | 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 | +| 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 | +| 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 | ## Executive recommendation @@ -145,7 +148,9 @@ The first implemented reduction, switching to inflate-only Pako, reduced the bundle exactly 25,380 bytes to 5,078,533 bytes. Current headroom is 164,347 bytes (about 160 KiB or 3.13%). Replacing the bundled YAML runtime then reduced the bundle another 104,707 bytes to 4,973,826 bytes. Current headroom is -269,054 bytes (about 263 KiB or 5.13%). +269,054 bytes (about 263 KiB or 5.13%). Compressing the React package source +then reduced the bundle another 78,540 bytes to 4,895,286 bytes. Current +headroom is 347,594 bytes (about 339 KiB or 6.63%). | Injected section | Approximate production characters | Share of `main.js` | | --- | ---: | ---: | @@ -171,13 +176,13 @@ Recommended order: `stringifyYaml()` functions. The production bundle decreased 104,707 bytes. Parsing remains equivalent; Obsidian's frontmatter serializer can format nulls and long lines differently while preserving their parsed values. -3. Deferred: deflate the React/ReactDOM/JSX-shim source at build time. A prior - attempt caused `main.js` bootstrap to fail before the inflater ran because - React participates elsewhere in the bundled module initialization. Revisit - this only after isolating the exact bootstrap dependency and proving the - approach in Obsidian, not merely with a syntax check. The theoretical gross - saving remains about 78 KB, but the prior runtime failure makes it a - higher-risk optimization than the YAML substitution. +3. Implemented; awaiting manual validation: deflated the + React/ReactDOM/JSX-shim source at build time. Unlike the prior failed + attempt, the emitted bootstrap initializes Pako and the inflater before + decompressing or evaluating React. The production bundle decreased 78,540 + bytes, and the decompressed package source remains available for popouts. + This still requires real Obsidian startup testing because that is where the + earlier approach failed. 4. Remove or archive release-note entries that cannot be rendered. The dialog always slices the assembled notes to ten entries; `Messages.ts` currently has 23 top-level entries, so `2.24.1` and the 12 older entries are diff --git a/package-lock.json b/package-lock.json index 409b8068..aebba5ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,6 @@ "eslint-plugin-obsidianmd": "^0.4.0", "eslint-plugin-prettier": "^5.0.0", "jiti": "^2.6.1", - "jsesc": "^3.0.2", "obsidian": "1.8.7", "postcss": "^8.5.25", "prettier": "^3.0.1", diff --git a/package.json b/package.json index 89e1a8e6..8019c8a6 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,6 @@ "eslint-plugin-obsidianmd": "^0.4.0", "eslint-plugin-prettier": "^5.0.0", "jiti": "^2.6.1", - "jsesc": "^3.0.2", "obsidian": "1.8.7", "postcss": "^8.5.25", "prettier": "^3.0.1", diff --git a/rollup.config.mjs b/rollup.config.mjs index 748318b8..e99581f9 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -10,7 +10,6 @@ import fs from 'fs'; import path from 'path'; import postprocess from '@zsviczian/rollup-plugin-postprocess'; import cssnano from 'cssnano'; -import jsesc from 'jsesc'; import { minify } from 'uglify-js'; import json from '@rollup/plugin-json'; import { parseEnv } from 'node:util'; @@ -103,6 +102,9 @@ const react_pkg = isLib ? "" : minifyCode(isProd const reactdom_pkg = isLib ? "" : minifyCode(isProd ? fs.readFileSync("./node_modules/react-dom/umd/react-dom.production.min.js", "utf8") : fs.readFileSync("./node_modules/react-dom/umd/react-dom.development.js", "utf8")); +const reactPackagesCompressed = isLib + ? "" + : compressDeflateBase64(react_pkg + reactdom_pkg + jsxRuntimeShim); // Runtime payloads are only decompressed; including Pako's deflate implementation // would add unused code to the size-constrained Obsidian plugin bundle. @@ -142,10 +144,7 @@ const packageString = isLib ' ' + pako_pkg + '\n' + ' return module.exports;\n' + '})();\n' + - '\nlet REACT_PACKAGES = `' + - jsesc(react_pkg + reactdom_pkg + jsxRuntimeShim, { quotes: 'backtick' }) + - '`;\n' + - // NEW: Fast, mobile-compatible runtime decompression + // Define the dependency-free inflater before React participates in bootstrap. 'const unpackBase64Deflate = (b64) => {\n' + ' const binStr = atob(b64);\n' + ' const len = binStr.length;\n' + @@ -154,6 +153,7 @@ const packageString = isLib ' return new TextDecoder().decode(pako.inflate(bytes));\n' + '};\n' + 'window.unpackBase64Deflate = unpackBase64Deflate;\n' + + 'let REACT_PACKAGES = unpackBase64Deflate("' + reactPackagesCompressed + '");\n' + 'const unpackExcalidraw = () => unpackBase64Deflate("' + compressDeflateBase64(excalidraw_pkg) + '");\n' + 'let {react, reactDOM } = new Function(`${REACT_PACKAGES}; return {react: React, reactDOM: ReactDOM};`)();\n' + 'let excalidrawLib = {};\n' + From 1616a7234406b673d88b8c4dab26e29782e18dee Mon Sep 17 00:00:00 2001 From: zsviczian Date: Sun, 9 Aug 2026 17:27:30 +0200 Subject: [PATCH 4/4] perf: replace opentype.js font metric parsing --- RefactorPlan.md | 20 +++-- package-lock.json | 37 -------- package.json | 2 - src/core/managers/FontManager.ts | 8 +- src/utils/embeddedAssetUtils.ts | 19 ++-- src/utils/fontMetrics.ts | 143 +++++++++++++++++++++++++++++++ src/utils/utils.ts | 34 -------- 7 files changed, 172 insertions(+), 91 deletions(-) create mode 100644 src/utils/fontMetrics.ts diff --git a/RefactorPlan.md b/RefactorPlan.md index aa53e6bc..c308c048 100644 --- a/RefactorPlan.md +++ b/RefactorPlan.md @@ -35,6 +35,7 @@ validated, and what remains uncertain. | 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 | | 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 | | 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 | +| 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 | | 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 | | 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 | @@ -79,6 +80,8 @@ validated, and what remains uncertain. | 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 | | 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 | | 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 | +| 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 | +| 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 | ## Executive recommendation @@ -149,8 +152,9 @@ bundle exactly 25,380 bytes to 5,078,533 bytes. Current headroom is 164,347 bytes (about 160 KiB or 3.13%). Replacing the bundled YAML runtime then reduced the bundle another 104,707 bytes to 4,973,826 bytes. Current headroom is 269,054 bytes (about 263 KiB or 5.13%). Compressing the React package source -then reduced the bundle another 78,540 bytes to 4,895,286 bytes. Current -headroom is 347,594 bytes (about 339 KiB or 6.63%). +then reduced the bundle another 78,540 bytes to 4,895,286 bytes. Replacing +`opentype.js` with the focused SFNT reader reduced it another 186,132 bytes to +4,709,154 bytes. Current headroom is 533,726 bytes (about 521 KiB or 10.18%). | Injected section | Approximate production characters | Share of `main.js` | | --- | ---: | ---: | @@ -189,12 +193,12 @@ Recommended order: unreachable even when the user manually requests all notes. Those entries occupy about 18 KB of source string data. Preserve their history outside the runtime import graph. -5. Replace `opentype.js` only as a separately tested feature change. It is - imported at one site solely to read `unitsPerEm`, ascender, descender, and - English family name for custom TTF/OTF fonts. Its measured standalone - production cost is about 169 KB. A focused SFNT table reader could recover - most of that, but must cover real TTF/OTF collections and name encodings; - this has more functional risk than the preceding items. +5. Implemented; awaiting manual validation: replaced `opentype.js` with a + focused SFNT reader for the three metrics actually consumed by Excalidraw. + English family-name parsing was removed because it had no consumer. The + reader matched all 21 available TTF/OTF fixtures exactly and retains the + existing fallback for invalid files and all WOFF/WOFF2 fonts. The production + bundle decreased 186,132 bytes. Secondary static-payload candidates, after the above checkpoints: diff --git a/package-lock.json b/package-lock.json index aebba5ea..323e478d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,6 @@ "lz-string": "^1.5.0", "monkey-around": "^2.3.0", "nanoid": "^5.1.16", - "opentype.js": "^1.3.4", "pako": "^2.1.0", "points-on-path": "^0.2.1", "polybooljs": "^1.2.0", @@ -51,7 +50,6 @@ "@types/chroma-js": "^3.1.1", "@types/js-beautify": "^1.14.0", "@types/node": "^22.19.19", - "@types/opentype.js": "^1.3.8", "@types/react": "^18.2.45", "@types/react-dom": "^18.2.18", "@zsviczian/excalidraw-extras-api": "^0.0.13", @@ -4889,13 +4887,6 @@ "undici-types": "~6.21.0" } }, - "node_modules/@types/opentype.js": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@types/opentype.js/-/opentype.js-1.3.10.tgz", - "integrity": "sha512-F67EFyk6j02okHz5JCgata3ZRAcZi9GLnzmkHw/rzJq3OCc8/ZVdoKrxMTYjcQP6IYHGBz2cav1cpzkOkPiPCQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -10480,22 +10471,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/opentype.js": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-1.3.4.tgz", - "integrity": "sha512-d2JE9RP/6uagpQAVtJoF0pJJA/fgai89Cc50Yp0EJHk+eLp6QQ7gBoblsnubRULNY132I0J1QKMJ+JTbMqz4sw==", - "license": "MIT", - "dependencies": { - "string.prototype.codepointat": "^0.2.1", - "tiny-inflate": "^1.0.3" - }, - "bin": { - "ot": "bin/ot" - }, - "engines": { - "node": ">= 8.0.0" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -12290,12 +12265,6 @@ "node": ">= 0.4" } }, - "node_modules/string.prototype.codepointat": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz", - "integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==", - "license": "MIT" - }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -12573,12 +12542,6 @@ "dev": true, "license": "MIT" }, - "node_modules/tiny-inflate": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", - "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", - "license": "MIT" - }, "node_modules/tinyexec": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", diff --git a/package.json b/package.json index 8019c8a6..6354c505 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,6 @@ "lz-string": "^1.5.0", "monkey-around": "^2.3.0", "nanoid": "^5.1.16", - "opentype.js": "^1.3.4", "pako": "^2.1.0", "points-on-path": "^0.2.1", "polybooljs": "^1.2.0", @@ -68,7 +67,6 @@ "@types/chroma-js": "^3.1.1", "@types/js-beautify": "^1.14.0", "@types/node": "^22.19.19", - "@types/opentype.js": "^1.3.8", "@types/react": "^18.2.45", "@types/react-dom": "^18.2.18", "@zsviczian/excalidraw-extras-api": "^0.0.13", diff --git a/src/core/managers/FontManager.ts b/src/core/managers/FontManager.ts index f8d78bb5..fce593ba 100644 --- a/src/core/managers/FontManager.ts +++ b/src/core/managers/FontManager.ts @@ -7,7 +7,8 @@ import { } from "src/constants/constants"; import { t } from "src/lang/helpers"; import { getCJKDataURLs } from "src/utils/CJKLoader"; -import { getFontDataURL, getFontMetrics } from "src/utils/utils"; +import { getFontDataURL } from "src/utils/utils"; +import { getFontMetrics } from "src/utils/fontMetrics"; import type ExcalidrawPlugin from "src/core/main"; declare const mainDocument: Document; @@ -115,9 +116,9 @@ export class FontManager { this.plugin.settings.experimantalFourthFont, "", ); - let fontMetrics = file.extension.startsWith("woff") + let fontMetrics = file.extension.startsWith("woff") || !font.arrayBuffer ? undefined - : await getFontMetrics(fourthFontDataURL, "Local Font"); + : getFontMetrics(font.arrayBuffer); if (!fontMetrics) { fontMetrics = { @@ -125,7 +126,6 @@ export class FontManager { ascender: 750, descender: -250, lineHeight: 1.2, - fontName: "Local Font", }; } this.getPackageMap().forEach(({ excalidrawLib }) => { diff --git a/src/utils/embeddedAssetUtils.ts b/src/utils/embeddedAssetUtils.ts index 9a314678..2a33078d 100644 --- a/src/utils/embeddedAssetUtils.ts +++ b/src/utils/embeddedAssetUtils.ts @@ -13,8 +13,9 @@ export { getEmbeddedFilenameParts } from "./embeddedFilenameParts"; * @param fontFileName - Vault link path or font filename to resolve. * @param sourcePath - Vault path from which the font link is resolved. * @param name - Optional font-family name overriding the file basename. - * @returns The font definition, resolved family name, and encoded data URL. - * Empty strings are returned when the font cannot be resolved. + * @returns The font definition, resolved family name, encoded data URL, and + * original buffer. Empty strings and a null buffer are returned when the font + * cannot be resolved. * @remarks * MIME types and CSS format names intentionally preserve the established * extension mapping used by plugin startup and embedded SVG font loading. @@ -24,13 +25,19 @@ export async function getFontDataURL( fontFileName: string, sourcePath: string, name?: string, -): Promise<{ fontDef: string; fontName: string; dataURL: string }> { +): Promise<{ + fontDef: string; + fontName: string; + dataURL: string; + arrayBuffer: ArrayBuffer | null; +}> { let fontDef = ""; let fontName = ""; let dataURL = ""; + let arrayBuffer: ArrayBuffer | null = null; const f = app.metadataCache.getFirstLinkpathDest(fontFileName, sourcePath); if (f) { - const ab = await app.vault.readBinary(f); + arrayBuffer = await app.vault.readBinary(f); let mimeType = ""; let format = ""; @@ -55,12 +62,12 @@ export async function getFontDataURL( mimeType = "application/octet-stream"; } fontName = name ?? f.basename; - dataURL = await getDataURL(ab, mimeType); + dataURL = await getDataURL(arrayBuffer, mimeType); const split = dataURL.split(";base64,", 2); dataURL = `${split[0]};charset=utf-8;base64,${split[1]}`; fontDef = ` @font-face {font-family: "${fontName}";src: url("${dataURL}") format("${format}")}`; } - return { fontDef, fontName, dataURL }; + return { fontDef, fontName, dataURL, arrayBuffer }; } /** diff --git a/src/utils/fontMetrics.ts b/src/utils/fontMetrics.ts new file mode 100644 index 00000000..959c11cb --- /dev/null +++ b/src/utils/fontMetrics.ts @@ -0,0 +1,143 @@ +/** + * Reads only the SFNT metadata required to register a local Excalidraw font. + * + * @packageDocumentation + */ + +const SFNT_HEADER_SIZE = 12; +const TABLE_RECORD_SIZE = 16; +const TABLE_TAG_SIZE = 4; +const TABLE_OFFSET_POSITION = 8; +const TABLE_LENGTH_POSITION = 12; + +const HEAD_UNITS_PER_EM_POSITION = 18; +const HEAD_REQUIRED_LENGTH = HEAD_UNITS_PER_EM_POSITION + 2; +const HHEA_ASCENDER_POSITION = 4; +const HHEA_DESCENDER_POSITION = 6; +const HHEA_REQUIRED_LENGTH = HHEA_DESCENDER_POSITION + 2; + +const SUPPORTED_SFNT_SIGNATURES = new Set([ + 0x00010000, // TrueType outlines + 0x4f54544f, // OTTO: OpenType with CFF/CFF2 outlines + 0x74727565, // true: legacy Apple TrueType + 0x74797031, // typ1: legacy OpenType wrapper +]); + +/** Font metrics registered for Excalidraw's local font family. */ +export interface FontMetrics { + /** Raw `head.unitsPerEm` value. The narrow cast preserves uncommon values. */ + unitsPerEm: 1000 | 1024 | 2048; + /** Raw `hhea.ascender` value. */ + ascender: number; + /** Raw `hhea.descender` value. */ + descender: number; + /** Unitless line height derived using the plugin's established formula. */ + lineHeight: number; +} + +type SfntTable = { + offset: number; + length: number; +}; + +const readTableTag = (view: DataView, offset: number): string => { + let tag = ""; + for (let index = 0; index < TABLE_TAG_SIZE; index++) { + tag += String.fromCharCode(view.getUint8(offset + index)); + } + return tag; +}; + +const isRangeAvailable = ( + byteLength: number, + offset: number, + length: number, +): boolean => + Number.isSafeInteger(offset) && + Number.isSafeInteger(length) && + offset >= 0 && + length >= 0 && + offset <= byteLength - length; + +const findRequiredTables = ( + view: DataView, +): { head: SfntTable; hhea: SfntTable } | null => { + if (view.byteLength < SFNT_HEADER_SIZE) { + return null; + } + if (!SUPPORTED_SFNT_SIGNATURES.has(view.getUint32(0))) { + return null; + } + + const tableCount = view.getUint16(4); + const directoryLength = tableCount * TABLE_RECORD_SIZE; + if (!isRangeAvailable(view.byteLength, SFNT_HEADER_SIZE, directoryLength)) { + return null; + } + + let head: SfntTable | null = null; + let hhea: SfntTable | null = null; + for (let index = 0; index < tableCount && (!head || !hhea); index++) { + const recordOffset = SFNT_HEADER_SIZE + index * TABLE_RECORD_SIZE; + const tag = readTableTag(view, recordOffset); + if (tag !== "head" && tag !== "hhea") { + continue; + } + const table = { + offset: view.getUint32(recordOffset + TABLE_OFFSET_POSITION), + length: view.getUint32(recordOffset + TABLE_LENGTH_POSITION), + }; + if (!isRangeAvailable(view.byteLength, table.offset, table.length)) { + return null; + } + if (tag === "head") { + head = table; + } else { + hhea = table; + } + } + return head && hhea ? { head, hhea } : null; +}; + +/** + * Reads the vertical metrics needed by Excalidraw from a TTF or OTF buffer. + * + * @param fontData - Complete SFNT font data from a `.ttf` or `.otf` file. + * @returns Parsed metrics, or `null` when the font is unsupported or malformed. + * @remarks + * Only the SFNT table directory plus `head.unitsPerEm`, `hhea.ascender`, and + * `hhea.descender` are read. Glyph outlines, names, and layout tables are left + * to the browser and Excalidraw, as they were never consumed by the plugin. + */ +export function getFontMetrics(fontData: ArrayBuffer): FontMetrics | null { + const view = new DataView(fontData); + const tables = findRequiredTables(view); + if ( + !tables || + tables.head.length < HEAD_REQUIRED_LENGTH || + tables.hhea.length < HHEA_REQUIRED_LENGTH + ) { + return null; + } + + const unitsPerEm = view.getUint16( + tables.head.offset + HEAD_UNITS_PER_EM_POSITION, + ); + const ascender = view.getInt16( + tables.hhea.offset + HHEA_ASCENDER_POSITION, + ); + const descender = view.getInt16( + tables.hhea.offset + HHEA_DESCENDER_POSITION, + ); + if (unitsPerEm === 0) { + return null; + } + + return { + // Excalidraw's type lists common values only; retain the font's exact value. + unitsPerEm: unitsPerEm as FontMetrics["unitsPerEm"], + ascender, + descender, + lineHeight: (ascender - descender) / unitsPerEm, + }; +} diff --git a/src/utils/utils.ts b/src/utils/utils.ts index dad6cdae..28b62b45 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -39,7 +39,6 @@ import { arrayToMap } from "./collectionUtils"; import { isVersionNewerThanOther } from "./versionUtils"; import { updateElementLinksToObsidianLinks } from "./excalidrawAutomateUtils"; import { CropImage } from "../shared/CropImage"; -import opentype from "opentype.js"; import Pool from "es6-promise-pool"; import { t } from "src/lang/helpers"; import { log } from "./debugHelper"; @@ -1030,39 +1029,6 @@ export function addYouTubeThumbnail( }); } -export interface FontMetrics { - unitsPerEm: 1000 | 1024 | 2048; - ascender: number; - descender: number; - lineHeight: number; - fontName: string; -} - -export async function getFontMetrics( - fontUrl: string, - name: string, -): Promise { - try { - const font = await opentype.load(fontUrl); - const unitsPerEm = font.unitsPerEm as 1000 | 1024 | 2048; - const ascender = font.ascender; - const descender = font.descender; - const lineHeight = (ascender - descender) / unitsPerEm; - const fontName = font.names.fontFamily.en ?? name; - - return { - unitsPerEm, - ascender, - descender, - lineHeight, - fontName, - }; - } catch (error) { - console.error("Error loading font:", error); - return null; - } -} - // extending the missing types // relying on the [Index, T] to keep a correct order type TPromisePool = Pool<[Index, T][]> & {