theseus/extensions/docx-editor/ROUND-TRIP.md
Local Dev 5e4bd22609 refactor(docx-editor): ship it as a community extension, not part of the browser
A .docx editor is a megabyte of vendored library. Bundling it would charge
that to everyone who wanted a browser, including the people who will never
open a Word document in it.

So it leaves the build: out of bundled-addons/, out of extraResources, absent
from a fresh profile. It arrives the way anyone else's extension does —
Settings › Extensions › Community, from the catalogue the gateway builds, and
listed on theseus.x/extensions alongside everything else published there.
That also means it is signed by the owner of a BNS name rather than by the
operator key, which is the right trust story for something that isn't part of
the browser.

`npm run pack` produces the tarball the publish page takes; the signature
needs the publisher name's wallet, so it isn't something the repo can do.

The end-to-end test now installs the extension into a throwaway profile the
way the community installer would, and asserts up front that a fresh profile
doesn't already have it — the bundling is what was being removed, so it is
worth a test that would notice it coming back.
2026-09-21 01:06:58 +02:00

6.9 KiB
Raw Blame History

What survives a round trip

The editor rebuilds a document's body from what you see on screen and carries the rest of the original package across. This is the ledger of what that costs.

Measured over 66 real-world Word documents found on a working machine (CVs, contracts, invoices, forms, letters — Greek, Russian, German and English):

clean round-trip : 65
content drift    :  1   (a 7 MB WMF picture, see "Dropped")
invalid package  :  0
threw            :  0

"Clean" means: read the file, save it, read it again, and the two editor documents are identical — same blocks, same attributes, same marks on the same text. The saved package is also checked for well-formed XML, no dangling relationship ids and no relationships pointing at parts that aren't there.

Reproduce with:

cd addon-build/docx-editor
npm install && npm run build
node test/roundtrip.mjs                 # the built-in fixture
node test/corpus.mjs <a folder of .docx> # a real corpus

How the two halves work

Reading uses mammoth, but not its HTML. mammoth's converter is deliberately semantic, and HTML has nowhere to put a run's colour or a paragraph's line spacing, so it drops them. We take its parsed document model instead, through the public transformDocument hook, and walk that into the editor's model. See addon-build/docx-editor/patches.mjs for the six properties we taught that model to carry: run colour, paragraph spacing, paragraph bottom border, image display size, numbering format and numbering id.

Writing uses docx, which always builds a brand-new package. Anything living outside the document body would therefore vanish, so lib/pkg.js grafts it back: headers, footers, footnotes, endnotes, the style catalogue, the theme and the page setup, re-wiring relationship ids and content types as it goes.

Kept

How
Headers and footers The parts are copied across with their own relationships and images, and re-referenced from the new sectPr.
Footnotes and endnotes The markers survive in the body as their own node; footnotes.xml is copied wholesale so the ids still match.
Page size, orientation, margins, gutter, title page Read off the first sectPr and handed to the builder.
The document's styles The original styles.xml is merged over the builder's. Where both define a style id the original wins — it is what the document actually looked like. docDefaults comes across too, so unstyled paragraphs don't shift.
Theme, fonts theme1.xml is copied.
Title, author, subject, keywords From docProps/core.xml.
Bold, italic, underline, strike, super/subscript, all-caps, small-caps
Font family, size, colour, highlight Highlight is Word's 15-value enum, not a hex colour, so it passes through exactly.
Alignment, indent, line spacing, space before/after
Headings 16, quotes, code blocks Code blocks ride on a SourceCode paragraph style.
Bulleted and numbered lists, nested, with their numbering format Format is kept per level, so a list that is decimal at the top and lettered underneath stays that way.
Where one list ends and the next begins Tracked by Word's numId, so a second list still restarts at 1.
Tables, including merged cells Both directions. A 12-row vertical merge comes back as a 12-row vertical merge.
Images At the size Word was displaying them, to EMU precision, not the file's natural size.
Links, internal anchors
Page breaks, horizontal rules A rule is Word's empty paragraph with a bottom border, and is written back as one.

Dropped

These are detected when the file opens and named in a banner before any editing, and again in the About dialog. The original file on disk is never overwritten — a save downloads <name>-edited.docx.

  • Tracked changes. mammoth renders insertions as ordinary text and drops deletions, so a save would silently accept every pending revision. A document with them opens read-only until you explicitly choose "Accept all and edit".
  • Comments. Same gate as tracked changes.
  • Equations (OMML), shapes, text boxes and WordArt, content controls.
  • Fields — page numbers, tables of contents, cross-references. The text Word last calculated is kept; the field code that would recalculate it is not.
  • Bookmarks.
  • Section breaks and multi-column layout. Only the first section's page setup is kept.
  • Metafile pictures (WMF/EMF). Word's vector picture format: browsers can't display it and the builder can't write it. This is the single drift in the corpus above — one CV with a 7 MB WMF.

Kept, but not exactly

  • Paragraph borders and shading. Only the rule under an empty paragraph round-trips. A box around a paragraph, or a shaded paragraph, is lost.
  • Custom tab stops. Tab characters are kept; the stop positions are not.
  • Exact line spacing. atLeast and exact line rules are read but the editor has no control for them, so they are written back as-is only when the paragraph is untouched.
  • Table borders. mammoth doesn't report the borders it read, so every table is written with a plain single-line border. A borderless table gains lines.
  • List indentation depth. A list Word started at level 2 with no level 0 or 1 above it becomes a top-level list, and is written at level 0.
  • An empty paragraph after a table inside a cell. OOXML forbids a cell that ends with a table, so every such document carries a paragraph the author never typed. It is dropped on read and put back on write.

Decisions worth knowing about

Why not preserve the dropped features as raw XML? docx can embed raw OOXML (ImportedXmlComponent), so it is technically possible. What makes it expensive is position: mammoth silently discards the elements it can't model and reports no location for them, so anchoring a passthrough node in the right place needs a second OOXML reader running alongside mammoth purely to recover block order. That is a large amount of machinery whose failure mode is a subtly corrupt package, which is worse than an honest warning. The grafting approach gets headers, footers, notes, page setup and styles — the things most real documents actually have — without that risk.

Why patch mammoth instead of using it as shipped? Colour, line spacing and numbering format are all editable in this editor's ribbon. Shipping without the patches would mean the editor shows a control for something it silently eats on the next save. The patches are six string replacements applied at build time and each one asserts its anchor, so a mammoth upgrade that moves the code fails the build instead of quietly shipping a lossy reader.

Why is the original file never overwritten? Because of everything on the "Dropped" list. A save is a download of a new file, so the original is always still there to fall back on.