theseus/bundled-addons/docx-editor/ROUND-TRIP.md

122 lines
6.9 KiB
Markdown
Raw Permalink Normal View History

feat(docx-editor): edit Word documents without quietly eating what Word put in them A .docx editor is easy to write badly: read the file into HTML, let someone edit it, write a fresh document back, and hand them a file that lost its headers, its page size and half its formatting without ever saying so. Three things keep this one honest. The reader doesn't use mammoth's 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 — and those are controls this editor puts in the ribbon. Taking its parsed document model instead means what the ribbon offers is what the file can actually carry. Six properties mammoth's model didn't keep are added by build-time patches, each asserting its anchor so an upgrade that moves the code fails the build rather than shipping a lossy reader. The writer rebuilds the body but carries the rest of the package across: headers, footers, footnotes, endnotes, the document's own style catalogue, its theme and its page setup, with relationship ids and content types re-wired. Word features the editor can't model are still lost, so they are detected when the file opens and named in a banner before anyone edits. Tracked changes get their own gate. mammoth renders insertions as ordinary text and drops deletions, so saving would accept every pending revision without Word ever asking. Such a document opens read-only until the user says that is what they want. Verified over 66 real documents: 65 round-trip with an identical model and a structurally valid package, the one exception being a 7 MB WMF picture, which no browser can display and the writer cannot emit. Also driven end to end through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
# 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](https://github.com/mwilliamson/mammoth.js), 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](https://www.npmjs.com/package/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.