A DOCX editor has an awkward requirement. It needs to understand enough of Word's document model to edit and lay out a document, and it cannot assume that everything in the file belongs to that model.
A .docx file is an Open Packaging Conventions (OPC) ZIP containing XML parts,
binary parts, relationships, and package metadata. Word can put things in that
package that an editor does not need to render or edit at all: custom XML, VBA
projects, embedded objects, compatibility settings, fields, bookmarks, and
application-specific extensions.
The dangerous implementation converts the document into a smaller representation, edits that representation, and then generates a new DOCX from it. Anything outside the intermediate representation disappears.
We took a different approach in docx-editor. The editor owns a canonical
representation of the parts it needs to understand, and everything else remains
part of the original package. That division is what makes round-tripping
possible.
The guarantee it buys is narrow, and worth stating before the mechanics: editing a document should not remove or reinterpret content the editor did not modify. Saving does not reproduce the original bytes.
The package is not the document model
The first decision was not to treat a DOCX file as one XML document.
The input is an OPC package:
document.docx
├── [Content_Types].xml
├── _rels/.rels
├── word/
│ ├── document.xml
│ ├── styles.xml
│ ├── numbering.xml
│ ├── settings.xml
│ ├── media/
│ └── ...
└── ...Some of those parts need to be understood by the editor. Others do not.
A part whose content type is XML gets parsed into the editor's canonical tree, up to a bounded part count. Binary parts are not decoded and reconstructed just because the document was saved. They stay package entries and are copied through.
That gives the serializer two different jobs:
- Serialize the parts the editor actually changed.
- Preserve everything else from the original package.
The canonical tree is a partial representation on purpose. In a
DOCX → JSON → DOCX architecture the intermediate format is everything that
survives a save.
The canonical tree only models what the editor needs
The core tree has typed nodes for constructs that layout and editing require, such as paragraphs, runs, and tables.
Unknown XML does not cause the parser to throw it away. It becomes a generic node that holds the original element, its attributes, and its children:
This matters for two reasons.
An extension does not have to be implemented before a document containing it can be opened and saved. And the parser does not have to pretend that an unknown element is something it understands. It can preserve the element without handing the layout engine semantics that element does not have.
There is also a difference between unknown and invalid.
If a known element appears somewhere the editor cannot safely interpret, treating it as a normal typed node is worse than treating it as opaque data. The parser therefore has cases where a construct is preserved generically rather than forced into an incorrect interpretation.
The rule is to parse what we understand and preserve what we do not.
Saving is not a full reconstruction
The same principle applies in the other direction.
A save does not take the current visual representation and rebuild a DOCX from scratch. The editor serializes the canonical tree for the parts it owns. Package entries that were never part of that editable representation stay available to the package writer.
That is how these survive an edit elsewhere in the document:
- images and other binary media
- embedded fonts
- OLE objects
- VBA projects
- package relationships
- XML parts the editor does not modify
An edit to one part does not turn every other part into regenerated output.
IDs and references are part of the preservation problem
Content preservation is not enough if references change underneath it.
DOCX contains many identifiers that connect otherwise separate pieces of the package: relationship IDs, bookmark names, style IDs, numbering definitions, field references, and others. An editor that regenerates these identifiers produces a document that looks correct immediately and contains broken references.
docx-editor preserves existing identifiers rather than renumbering them during
serialization. New relationship IDs are minted only for genuinely new content.
There is one deliberate exception. Paragraph IDs have to satisfy Word's
uniqueness requirement, so an incoming w14:paraId that is missing, malformed,
or duplicated gets a replacement. The replacement is derived from the paragraph,
so the same input file produces the same ID every time. This is an explicit
normalization rule, not a side effect of rebuilding the document.
The DOM is not the source of truth
There is another problem once the document reaches the browser.
The rendered page uses contenteditable, and the DOM cannot be treated as the
editor's document model. Browsers are free to mutate editable markup in ways
that are useful for HTML editing and have no meaningful DOCX equivalent. A
browser mutation can produce a DOM structure the document tree cannot represent
safely.
The editor therefore treats the DOM as a rendering surface. When the browser changes it, the editor compares the result with the structure it rendered and converts the difference into document operations. If the difference cannot be represented safely, the edit is rejected rather than silently discarded.
The browser never becomes the authoritative representation of the document.
A rejected mutation reaches the application through the editor snapshot, as
lastRejection. An unsupported operation is therefore observable, instead of
producing "I typed something and it disappeared".
React and Vue are adapters, not separate editors
The implementation lives in @docx-editor.dev/core. React and Vue provide
bindings around the same engine. They do not maintain separate document models.
This is useful beyond code reuse. DOCX parsing, mutation handling, layout, and serialization are tested independently of the framework integration.
A bug in serialization should not have a React version and a Vue version. There should be one bug.
How we test round-tripping
Round-tripping is not something we want to establish with a few example files.
The test suite round-trips real documents and checks the result after parsing it again. Two levels of comparison are useful.
The first is the canonical tree. Fingerprinting the parsed representation detects changes introduced by parsing or serialization.
The second is a save-and-reopen cycle:
This catches a class of bugs that byte-level comparison cannot. XML serialization is allowed to normalize formatting and ordering, so the saved ZIP does not need to be byte-identical to the original. What matters is that reopening it produces the same document semantics for content the editor did not intentionally change.
There are also targeted tests for constructs such as fields, hyperlinks, tracked changes, and content controls, plus browser tests that exercise the editing surface.
What "lossless" means here
It does not mean that saving a DOCX produces the same bytes. XML owned by the editor is parsed and serialized again, so its byte representation can change.
The guarantee is narrower and more useful:
Editing the document should not remove or reinterpret content that the editor did not modify.
Binary package payloads are preserved byte for byte. XML the editor owns is preserved structurally and semantically.
Some constructs are deliberately not editable. Text box story content renders but cannot be modified, and unsupported drawing geometry gets a placeholder that reserves its extent. Those are limits of the editing model, not reasons to discard the underlying DOCX content. Not editable is different from not preservable.
The practical test
The easiest way to evaluate the behavior is with a document that contains things an HTML editor normally throws away.
Open a DOCX containing fields, bookmarks, custom XML, embedded objects, tracked changes, and other Word-specific structures. Change one sentence. Save it. Open both files in Word and compare everything around the edit.
The demo below runs the same engine, and its file picker takes your own document:
If content disappears that should have survived, that is a bug rather than an expected consequence of converting DOCX into a smaller intermediate format. Attach the file to a GitHub issue.
The implementation is open source in docx-editor. The pipeline is documented in Architecture, and the supported Word features are listed in Word fidelity.