27 KiB
Typesetting and text: what margin and margin-docs actually share
Scope: the Typst PDF pipeline, fontdb loading, NSSpellChecker, Apple Writing Tools, harper-core
grammar, and the frontend that drives all of it. Four apps were checked. margin-calendar has none
of this: no typst, no fontdb, no harper, no NSSpellChecker in
/Users/pj/Workspace/projects/python/margin-caledar/src-tauri/Cargo.toml. margin-mail has exactly
one fingerprint, a fontdb family list. So this is a two-app problem between margin and margin-docs,
with one eight-line cameo from mail.
Method for the percentages below: comments and blank lines stripped, then a longest-common- subsequence over the remaining lines. "Identical in order" means the same line, same order, both files.
The headline
Nothing in either Rust tree is a copy that could be lifted as-is. Every pair started as a copy and then one side moved. margin-docs is ahead on all six Rust files and on four of the six frontend ones, and in three places margin is not merely behind but carries a bug that margin-docs already found, wrote a paragraph about, and fixed. The duplication that is worth extracting is small and boring; the duplication that is expensive is the divergence, and a shared crate is the only thing that would have stopped it.
Both Cargo.lock files pin the same graph: harper-core 2.5.0, typst 0.14.2, fontdb 0.23.0, burn-cuda 0.19.1, cubecl-cpu 0.8.1, 964 packages in margin and 962 in margin-docs. Two copies of a 960-package dependency graph resolved to the same versions by hand.
PDF export
margin/src-tauri/src/pdf.rs is 129 lines, 116 of code.
margin-editor/src-tauri/src/pdf.rs is 455 lines, 275 of code.
Identical in order: 37 lines, 31% of the smaller file.
Truly byte-identical, verified with diff:
- Diagnostic formatting.
margin/src-tauri/src/pdf.rs:114-129andmargin-editor/src-tauri/src/pdf.rs:440-455are the same 16 lines, character for character. Only the function name differs (format_source_diagnosticsagainstformat_diagnostics). Severity to string, message, hints indented two spaces, joined by newline. - The compile call shape.
Warned { output, warnings } = engine.compile()thentypst_pdf::pdf(&document, &Default::default()): margin pdf.rs:78-81, docs pdf.rs:272-281. - The bundled-family dispatch. Same four ids (
eb-garamond,lora,source-serif,fraunces) mapping to the same eightinclude_bytes!of../../public/fonts/*-VF.ttf: margin pdf.rs:22-35, docs pdf.rs:52-82. Same list, different shape (&[&[u8]]statics in both, arranged differently).
Everything else has diverged, and the direction is one-way.
Font strategy is where the drift costs the user a PDF. margin loads the variable files for
Literata and Hanken and hands them to Typst (pdf.rs:9-20, 57-62). margin-docs cut nine static
instances into margin-editor/src-tauri/fonts/ and loads those (pdf.rs:32-42), with the reason
written at pdf.rs:24-31: Typst does not support a variable axis, warns that it does not, and lays
out at the default instance regardless of the weight asked for. So in margin every heading, every
bold run and every callout label exports at weight 400. margin-docs' PDFs have the hierarchy the
author sees on screen; margin's do not. margin-docs even documents the residual caveat for the
other four families (pdf.rs:44-51), which margin has never noticed. This is the single largest
quality gap found anywhere in this audit, it is 1.6M of static cuts plus a PROVENANCE.md, and it
exists in one repo only.
Present in margin-docs, absent from margin:
- A vendored mitex 0.2.5 served on
/mitex/as static source files, with a wasm binary (pdf.rs:84-98), a stand-inlib.typfor a second attempt (pdf.rs:105-113), and the two-pass compile that uses it (pdf.rs:344-379). A formula mitex cannot parse degrades to literal source instead of killing the export. - Placeholder images, one per format Typst picks from an extension (pdf.rs:115-144), so an unreadable image is a gap rather than a failed export.
- A root-guarded filesystem read for images with no inline bytes (pdf.rs:146-163, calling
fs::resolve_in_roots). margin'sImageInput.datais a mandatory base64 string (pdf.rs:37-41) and there is no file path in the protocol at all, so margin has no exposure here but also cannot export an image it does not already hold in memory. - The font preamble, written in Rust because only Rust knows which families were found (pdf.rs:165-194). Note pdf.rs:176-181: Typst sets every equation in "New Computer Modern Math" with fallback off, so on a machine without it one formula is a hard compile error. margin has no math and no monospace preamble at all.
- Warning aggregation with counts and a
kind(pdf.rs:196-244 plusPdfWarningin dto.rs:217-222). margin emits one joined string on the samepdf-warningsevent (pdf.rs:100-102). pdf_writewith the guard that refuses to overwrite a markdown or text file (pdf.rs:411-437).
The escaping boundary is on the frontend in both, covered below.
Font loading
margin/src-tauri/src/fonts.rs is 47 lines, 43 of code.
margin-editor/src-tauri/src/fonts.rs is 182 lines, 113 of code.
Identical in order: 17 lines, 39% of the smaller.
The three-way duplication is here and it is trivially extractable. Listing every installed family is the same eight lines in three crates:
margin/src-tauri/src/fonts.rs:11-18(list_system_fonts)margin-editor/src-tauri/src/fonts.rs:160-167(fonts_list_system), byte-identical to margin'smargin-mail/src-tauri/src/settings.rs:180-188(system_fonts), the same algorithm withsystem_db()inlined andnamesrenamed tofamilies
All three do db.faces(), families.first(), clone the name, sort, dedup. All three are
#[tauri::command(async)]. mail is on fontdb 0.24 (margin-mail/src-tauri/Cargo.toml:70), the
other two on 0.23. Nothing in this function changed between those versions.
The per-family loader is the second shared piece: the same four (Weight, Style) pairs, the same
Query { families: &[Family::Name(family)], weight, style, ..Default }, the same
with_face_data(id, |data, _| data.to_vec()). margin fonts.rs:21-46, docs fonts.rs:69-95.
Two drifts, both in margin-docs' favour:
- Deduplication key. margin dedups by
fontdb::ID(fonts.rs:30, 39), which is per face. docs dedups by source file path (fonts.rs:55-61, 88).with_face_datahands back the whole file, so a.ttcholding regular, italic, bold and bold-italic is read and shipped to Typst four times by margin and once by docs. On a system family that is a collection, margin sends four copies of the same multi-megabyte blob across the compile. - The whole
Fallbacksmachinery (fonts.rs:19-42 for the family lists, 97-143 for the collection) has no counterpart in margin. margin names no monospace and no math family, so a code block or a formula in a margin export gets whatever Typst defaults to.
One correction worth carrying into any shared crate: the comment at
margin-editor/src-tauri/src/fonts.rs:84-86 says fontdb "answers a query with its closest match
rather than with nothing, so a family that is not installed comes back as some unrelated face".
That is not what fontdb 0.23 does. Database::query
(~/.cargo/registry/src/index.crates.io-*/fontdb-0.23.0/src/lib.rs:661-679) filters candidates by
exact family-name equality and returns None when the list is empty. The installed() guard at
fonts.rs:88 therefore cannot fire, and since it compares case-insensitively while the query compares
exactly, it is strictly weaker than the filter that already ran. It is live and useful at
fonts.rs:139, where it is asked about db.faces() directly. Harmless dead code, but the comment
would mislead whoever writes the shared version.
Spellcheck
margin/src-tauri/src/macspell.rs is 73 lines, 68 of code.
margin-editor/src-tauri/src/macspell.rs is 162 lines, 82 of code.
Identical in order: 49 lines, 72% of the smaller. This is the closest pair in either Rust tree.
utf16_to_codepoint is byte-identical, 12 lines: margin macspell.rs:14-25, docs macspell.rs:54-65.
check is the same function with three changes. Both build the NSString, ask
checkString_range_types_options_inSpellDocumentWithTag_orthography_wordCount for
Spelling | Link, skip non-spelling results, map both ends of the NSRange through the table, cut
the word out of the caller's own chars, and take at most five guesses.
- margin filters against an in-process custom word set (macspell.rs:55-57) because it keeps its own
dictionary file. docs has no custom set: learning writes to the system
(
macspell.rs:143-162,learnandunlearn, absent from margin entirely). - docs names its constants (
MAX_SUGGESTIONS,NO_DOCUMENTat macspell.rs:40-45); margin inlines5and0. - The end-offset clamp. margin:
map[(range.location + range.length).min(len)](macspell.rs:53). docs:map[range.location.saturating_add(range.length).min(len)](macspell.rs:107). The add happens before the clamp in margin, so an NSRange carryingNSNotFoundas its location panics in debug and wraps in release. Small, real, and already fixed once.
margin returns a local MacIssue (macspell.rs:7-12); docs returns the shared SpellIssue from
dto.rs:179-185. Same four fields.
docs also has spell.rs (95 lines, 28 of code), a platform shim giving the frontend four commands
on every target with a no_checker module for non-macOS. margin has no equivalent; its
non-macOS path is #[cfg] branches inside proofing.rs (proofing.rs:91-96, 105-166, 232-238) plus
a bundled 550K SCOWL Hunspell dictionary under margin/src-tauri/resources/dictionaries/en/.
Apple Writing Tools
margin/src-tauri/src/writingtools.rs is 81 lines, 68 of code.
margin-editor/src-tauri/src/writingtools.rs is 146 lines, 83 of code.
Identical in order: 42 lines, 61% of the smaller.
Byte-identical, verified: submenu_named and edit_menu together, 21 lines. margin
writingtools.rs:10-30 against docs writingtools.rs:23-43. writing_tools_menu is the same
one-liner in both (margin:32-34, docs:45-47). That is the entire AppKit menu-walking layer, and it
is the cleanest candidate for extraction in the whole audit: it takes a MainThreadMarker and a
title and it knows nothing about either app.
The divergence is a deliberate disagreement, and it is documented. margin puts Shift+Option+F and
Shift+Option+R on Apple's own Proofread and Rewrite rows (writingtools.rs:8, 36-49). margin-docs
refuses to, and writingtools.rs:85-91 says why: AppKit performs a key equivalent by firing the menu
item directly, so a chord on the system's row reaches Writing Tools without passing the selection
guard in src/editor/writing.ts, and that guard is refusing selections that corrupt the file. docs
puts the chords on its own Edit rows instead. margin has no such guard, so it is not currently
wrong for margin, but the reasoning is the sibling's and margin has never seen it.
docs also adds an availability probe: a SUBMENU_SEEN atomic set at setup (writingtools.rs:13-14,
92-95) and a writing_available command (106-120), so the frontend can say the machine has no
Apple Intelligence instead of offering a button that does nothing. margin's perform returns ()
and silently no-ops when the row is missing (writingtools.rs:51-61); docs' returns
Result<(), String> with three distinct messages (58-73).
One manifest oddity: margin/src-tauri/Cargo.toml:57 asks for the NSWritingToolsCoordinator
feature of objc2-app-kit and nothing in margin uses it. margin-docs names exactly that in a comment
at margin-editor/src-tauri/Cargo.toml:67.
Grammar
margin has one module doing spelling, grammar and the custom dictionary:
margin/src-tauri/src/proofing.rs, 275 lines, 239 of code. margin-docs splits the same work three
ways: grammar.rs (128 lines, 58 of code), spell.rs (95 lines, 28), macspell.rs.
Comparing the harper parts only, proofing.rs against grammar.rs: 33 lines identical in order,
56% of the smaller.
build_harper is the same five lines of code in both, margin proofing.rs:98-103 and docs
grammar.rs:41-47: FstDictionary::curated(), LintGroup::new_curated(dict.clone(), Dialect::American), set_rule_enabled("SpellCheck", false). Same reasoning in both, that the
system checker does spelling better.
collect_grammar is the same walk: Document::new_plain_english(text, dict), iterate lints, slice
existing out of chars, map Suggestion::ReplaceWith / InsertAfter / Remove the same three
ways, truncate to five. margin proofing.rs:168-194, docs grammar.rs:74-127.
Two differences, both docs ahead:
- Span clamping. margin clamps start and end against
chars.len()independently (proofing.rs:171-172). docs clamps end first, then start against end (grammar.rs:84-85), with the comment saying why: a foreign engine walking user prose that hands back an inverted span makeschars[start..end]a panic in margin and a no-op in docs. - docs drops lints whose span is nothing but whitespace (grammar.rs:96-98), naming Harper's "French spaces" rule specifically. margin draws them, so a margin user gets an invisible underline over two spaces that cannot be clicked.
Engine lifecycle differs without either being wrong: margin lazily fills a Tauri-managed
Mutex<Option<Engine>> (proofing.rs:36-40, 203-216); docs uses a module-level
LazyLock<Mutex<Harper>> (grammar.rs:39).
The [patch.crates-io] trap
Both crates carry the same two-line patch, and the four stub files behind it are code-identical.
margin/src-tauri/Cargo.toml:70-72andmargin-editor/src-tauri/Cargo.toml:98-100: the sameburn-cuda = { path = "stubs/burn-cuda" }andcubecl-cpu = { path = "stubs/cubecl-cpu" }.harper-core = { version = "=2.5.0", features = ["concurrent"] }, margin Cargo.toml:38 and docs Cargo.toml:57. Both carry a comment above it saying the pin is exact because the stubs are tied to this version's burn/cubecl graph and a minor bump could silently invalidate the patch.[profile.dev.package."*"] opt-level = 3, margin Cargo.toml:78-79 and docs Cargo.toml:107-108, with the same justification (harper's burn-ndarray POS tagger is roughly ten times slower unoptimized).- The stubs themselves:
stubs/burn-cuda/Cargo.toml(21 lines) andstubs/cubecl-cpu/Cargo.toml(16 lines). Diffing them with comment lines removed produces no output. The feature lists that have to match burn'sburn-cuda?/...references (std,doc,fusion,autotune,autotune-checks) and the pinned stub versions (burn-cuda 0.19.1, cubecl-cpu 0.8.1) are the same in both.
The only differences across all four stub files are prose: margin says "broken/heavy" where docs
says "heavy", margin's cubecl-cpu comment blames failing tracel-llvm prereleases where docs blames
build cost, and margin uses an em dash in the two lib.rs one-liners where docs uses a colon.
Why this is a trap rather than merely duplication. Cargo treats an unused [patch] as a warning,
not an error. The patch's validity depends on harper-core 2.5.0's exact transitive graph. Whoever
bumps harper-core in one repo has to know to re-audit the stub versions, and there is no test in
either repo asserting the patch is still applied. A stale stub does not fail the build; it quietly
pulls several hundred crates and an LLVM toolchain back into the graph, and the symptom is a build
that got slow. Two copies means the bump happens twice, on two different days, by which time the
comment explaining the pin has been read once and skipped once.
This is the strongest single argument for a shared crate in the audit: not because the code is
large, but because the fragile fact needs one owner. A margin-grammar crate would carry the
harper pin, the two stubs, the patch section, and one integration test asserting cubecl-cpu
resolves to the stub version.
Frontend
Typst converters: no shared code, and one real bug
margin/src/export/typst.ts is 421 lines (335 of code).
margin-editor/src/export/typst.ts is 808 lines (486 of code).
Identical in order: 9 lines, 2%.
They are genuinely different documents. margin writes a book: trim sizes (typst.ts:16-21), a title
page, chapter and part openers, a generated table of contents that queries <chap> metadata
(typst.ts:130-239). margin-docs writes an A4 document: callouts, toggles, task lists, tables, code
surfaces, mermaid diagrams, mitex math (typst.ts:206-327). Nothing about either preamble is
shareable and neither should be.
The one identical fragment is the image extension sniffer, 5 lines: margin typst.ts:240-244
(imageExtension) and docs typst.ts:342-347 (extensionOf).
The escaping philosophies are opposites, and docs' header says so directly (typst.ts:6-14): it has
no esc "that sprinkles backslashes through markup the way the sibling book exporter does".
Nothing in docs writes user text into markup; every character leaves through str
(typst.ts:98-110), which builds a Typst string literal, and a string literal has no syntax inside
it. margin escapes into markup with a character class (typst.ts:23-34): INLINE_SPECIAL covers
\ # $ * _ \ < > @ ~ ( ) [ ], guardLineStarthandles a leading= - + /and a leading1.`.
margin's str is JSON.stringify (typst.ts:36-38), and that is a bug. Typst's string literal
resolves exactly \\, \", \n, \r, \t and \u{...}, and an escape it does not recognise it
copies through as literal text
(typst-syntax-0.14.2/src/ast.rs:1248-1268, the _ => out.push_str(s.from(start)) arm).
JSON.stringify emits \b, \f, and \uXXXX for every other C0 control, for U+2028 and
U+2029, and for a lone surrogate. Every one of those lands in the PDF as the literal backslash
sequence instead of the character it stood for. There is no compile error to catch it. docs' str
(typst.ts:98-110) emits \u{...} and runs sanitize (typst.ts:80-87) to replace lone surrogates
first, precisely because they survive neither the IPC JSON nor UTF-8 on the other side. margin
carries a book title straight into str at typst.ts:136, and an href at typst.ts:57.
Secondary: margin does not neutralise Typst's markup shorthands, so -- in a manuscript becomes an
en dash and ... becomes an ellipsis in the PDF without the author asking. Arguably desirable in a
book; it is silent either way.
Image handling also diverged. margin only ever sends inline base64 from data URLs
(typst.ts:275-301, ImageInput.data: string at margin/src/ipc.ts:10-13). docs sends a path for a
file-backed image and bytes only for things with no file, such as a rendered mermaid SVG
(typst.ts:357-396, ImageInput.data: string | null at margin-editor/src/ipc.ts:244-248). The
path variant is what makes the root guard in docs' pdf.rs necessary and is why the two DTOs cannot
merge without a decision.
ExportPreview: the most duplicated file in either repo
margin/src/components/ExportPreview.tsx is 336 lines (307 of code).
margin-editor/src/components/ExportPreview.tsx is 448 lines (342 of code).
Identical in order: 200 lines, 65% of the smaller.
docs' header (lines 1-19) states outright that the sibling answers Export with the same panel for
the same reason. Shared verbatim or near-verbatim: the Frame interface, measureEditorPane
against measurePane (margin:40-47, docs:64-71, identical but for the name), the zoom constants
and step, the whole ctrl-wheel and gesture accumulator block (margin:75-129, docs:117-180, differing
in one identifier), the pdf.js page render loop with RenderTask cancellation, the ResizeObserver
on .editor-pane.
The drift is mostly docs adding: StrictMode-safe compile-once refs (docs:113-135), lazy pages via
AHEAD, an overlay key context, toast integration. margin adds unsupportedScripts messaging
(margin:129 onward, typst.ts:307-334) which docs does not have.
pdf.js loading is the one place the drift is a cost margin pays every launch. margin imports
pdfjs-dist and the worker URL at module scope in ExportPreview.tsx and sets
GlobalWorkerOptions.workerSrc at line 18, so roughly 1.5M of reader is in the main bundle whether
or not anyone opens the panel. docs has margin-editor/src/pdfjs.ts, 27 lines, which dynamic-
imports both and caches the promise (pdfjs.ts:17-26). Two surfaces use it there. docs is ahead and
the fix is 27 lines.
Proofing on the frontend: same problem, two solutions, no shared code
margin/src/editor/proofing.ts(139 lines) is a decoration plugin only. The driving loop lives inmargin/src/components/EditorView.tsx:137-255: a debounce, a whole-document pass, asetTimeout.margin-editor/src/editor/proofing.ts(642 lines, 344 of code) holds the plugin plus block batching into 8000-character runs (proofing.ts:85-88, 201-225), two module-level caches keyed by paragraph text (125-126, 253-259), a pass sequence number, and keyboard menu opening. 17 lines identical in order, 13%. The only common line of substance isDecoration.inlineover a mapped span.margin/src/proofing.ts(112 lines) has no counterpart at all.docTextandmapOffset(proofing.ts:39-73) flatten the whole document to one string with aSegmenttable to map code point offsets back to ProseMirror positions. docs solves the same offset problem per block inside proofing.ts:154-251. Genuinely the same problem solved twice, in incompatible shapes.
ProofPopover.tsx: margin 76 lines (68 of code), docs 292 (184). 29 identical in order, 42%. Both
portal to document.body, clamp to the window, humanise Harper's CamelCase category the same way
(margin:6-8 humanize, docs:57-63 readableKind, docs adding 0-9 to the class), render "" as
"Remove" (margin:58, docs:70), and lay out suggestions then actions. docs adds arrow-key walking,
the pointer-versus-chord focus split, and a store. margin's "Remember" writes to its own dictionary
file; docs' "Learn Spelling" writes to the system.
Editor and TipTap
src/editor/extensions.ts: margin 28 lines, docs 180. 12 lines identical in order, and those 12
are the StarterKit and Placeholder imports and the configure skeleton. The schemas are not
comparable: margin uses StarterKit's nodes plus Figure, ParagraphIndent, TextAlign; docs generates
one TipTap extension per entry in its own frozen src/model/schema.ts and switches all of
StarterKit's schema off (extensions.ts:128-147), because the markdown bridge is written against
that contract.
margin-mail's editor is margin-mail/src/screens/Editor.tsx, 95 lines: one StarterKit.configure
with heading: false and horizontalRule: false (Editor.tsx:41-48), no toolbar, no proofing, no
export. Its own comment (Editor.tsx:8-11) points at margin's extensions.ts as the model. Nothing to
share but the version pin, and the pins already disagree: margin ^3.27.1, margin-docs pinned
3.30.2, margin-mail ^3.31.2, across all five @tiptap/* packages.
Two frontend pairs outside the brief are worth naming because they are more duplicated than anything in it:
src/editor/search.ts: 217 against 235 code lines, 206 identical in order, 94%. docs' header (search.ts:6-7) says "Ported from margin's editor/search.ts. The only change of substance is the name of the last command". The rest of the diff is prettier's trailing commas and a return type. This is the highest-similarity file pair in either repo.src/editor/paste.ts: 70 against 163 code lines, 51 identical, 72%.src/escape.tsis byte-identical, 36 lines, in margin, margin-docs and margin-calendar.
Fonts on the frontend are already shared, and only there
margin/shared/src/fonts.ts (233 lines) is the single list of the six bundled families, the
FontRef encoding, the pairings and fontsUsed. All three UI apps import it: margin, margin-docs
and margin-mail (margin-mail/src/screens/Settings.tsx:7-12). This is the model for what a shared
Rust crate should look like, and its own header (fonts.ts:3-7) makes the argument: four lists in two
repos cannot be kept honest by hand.
The Rust side is the half that never got done. The same six ids are re-declared as include_bytes!
in margin pdf.rs:9-25 and docs pdf.rs:52-82, and the family list command exists three times. The
bytes are duplicated on disk five times: 6.8M in each of margin/public/fonts,
margin-editor/public/fonts, margin-mail/public/fonts and the margin/shared/fonts master, plus
1.6M of static cuts in margin-editor/src-tauri/fonts. margin/shared/bin/sync-fonts.mjs is the
copier.
EPUB
margin/src-tauri/src/epub.rs, 88 lines. Two commands: package_epub zips a list of
path/data/encoding records, unzip_epub reads one back. The only EPUB-specific facts are the
stored-not-deflated mimetype entry written first (epub.rs:29-31) and the text-extension list
(epub.rs:15-23). Nothing in it knows what a Book is.
So it is general in principle and margin-only in practice. It has one consumer, the frontend that
feeds it is entirely Book-shaped (margin/src/export/epub.ts at 18733 bytes and
margin/src/import/epub.ts at 17738), and no other app in the suite produces or reads an EPUB.
Sixty lines of zip plumbing is not a crate. Leave it.
Markdown and the remark stack
margin-editor/src/markdown is 2737 non-test lines (index 157, parse 597, serialize 1471, handlers
420, frontmatter 92) plus roughly 8400 lines of tests. It depends on unified, remark-parse,
remark-stringify, remark-gfm, remark-frontmatter, remark-math and mdast-util-to-markdown, none of
which appear in margin or margin-mail.
It is not shareable and should not be made so. Every line of it is written against
margin-editor/src/model/schema.ts, which the module's header (index.ts:29-36) treats as a frozen
contract, and its four stated invariants (opening writes nothing, serializing is stable, frontmatter
passes through opaque, nothing is silently dropped) are promises about a folder of markdown files.
margin has no markdown at any point in its pipeline and mail composes HTML. If mail ever gained
markdown compose it would want a serializer, not this parser, and the schema it serialized from
would be StarterKit's rather than the contract's.
What is worth extracting
Ordered by ratio of risk removed to work done.
- The harper pin, the two stub crates and the
[patch.crates-io]block. Smallest code, largest consequence, and the only duplication here that fails silently and expensively. - The AppKit menu walk from writingtools.rs:
submenu_named,edit_menu,writing_tools_menu, 21 lines already byte-identical, plus the availability probe docs has and margin does not. - fontdb:
system_db, the installed-family list (three copies today), the four-style face loader, and the source-path dedup key margin is missing. Take docs' version, drop the incorrect comment at fonts.rs:84-86. utf16_to_codepointand the NSSpellCheckercheckbody, 12 lines already identical and about 40 more that differ only in constants and the return type. Needs one sharedSpellIssue.- Typst diagnostic formatting, 16 identical lines, and the compile-and-render call. Not the preambles and not the converters.
- The nine static font cuts, so margin's PDFs stop exporting every heading at weight 400. This is an asset and a build step more than a crate, but it is the change a reader of the two PDFs would notice first.
Frontend, separately from any Rust crate: pdf.js lazy loading (27 lines, docs already has it),
ExportPreview's frame-and-zoom shell (65% identical), and margin's str bug in export/typst.ts:36.