Building a DOCX Editor That Preserves Document Structure
How I built an open-source DOCX editor with ProseMirror while preserving the original OOXML structure
https://docx-editor.portone.io
Introduction
We needed an embeddable DOCX editor while building a contract-generation workflow at work.
The feature we were building used an existing Word document or a contract created in Google Docs as a template to generate a new document, then allowed an external instructor to review and edit only their own information in the browser. Contract clauses and other protected content had to remain locked. The documents used tables extensively, input needed to work in Korean, Chinese, Japanese, and English, and the contracts were sensitive enough that sending them to an external editing service was not an attractive option.
I initially assumed a library like this must already exist. But I could not find an option that was simple to embed in React, could export a DOCX without losing its original structure, and could lock specific parts of the document. Commercial services did not fit our requirements for cost or data handling either.
So I decided to build one. The result is @portone/docx-editor, a React editor that supports text formatting, lists, tables, images, links, and comments, and can lock supported regions represented by content controls.
The contract templates we use at work now render as intended and retain their original structure after editing. But broader compatibility requires exposure to a much wider range of DOCX files, so I released the editor under Apache-2.0 instead of keeping it as an internal tool.
import "@portone/docx-editor/styles.css"
import { DocxEditor } from "@portone/docx-editor"
export function Editor({ file }: { file: File }) {
return <DocxEditor document={file} />
}
The React API is deliberately small. The difficult part was not mounting an editor—it was writing a DOCX back without damaging the document.
Why we chose not to convert DOCX to HTML
A DOCX looks like a single document, but it is actually a ZIP package containing XML files, images, and relationship data.
contract.docx
├── [Content_Types].xml
├── _rels/.rels
└── word/
├── document.xml
├── styles.xml
├── numbering.xml
├── _rels/document.xml.rels
└── media/
One obvious way to build a browser editor is to convert DOCX to HTML, edit the HTML, and generate a new DOCX afterward. That is convenient if rendering is the only concern, but the round trip can discard Word structures and properties that HTML cannot represent.
Our requirement was stricter: information the editor does not understand must still survive if the user never touched it. @portone/docx-editor therefore reads and writes OOXML directly instead of using HTML as an intermediate format.
Import produces both an editable ProseMirror document and a session that retains the original DOCX package.
import { exportDocx, importDocx } from "@portone/docx-editor/core"
const { doc, session } = importDocx(source)
const bytes = exportDocx(doc, session)
The session contains the original package parts and the source XML for each document block. During export, the current ProseMirror node is compared with the node created at import time.
- An unchanged block is written back using its original XML.
- Only changed paragraphs and tables are serialized again.
- Package parts for images, comments, or numbering are replaced only when needed.
- A block the editor cannot model becomes a preservation node that keeps its original XML.
In other words, export rewrites the edited surface instead of rebuilding the entire document. If the editor cannot guarantee a safe round trip, the import or export fails with an explicit error such as unsupported-content rather than quietly returning a damaged file.
This preservation model lets the editable feature set grow incrementally without accidentally deleting Word features the editor does not support yet.
Using ProseMirror as the editing model
We could have built a browser editing engine with cursor movement, undo, redo, and selection handling from scratch. But validating the workflow quickly was more important than building an editing engine of our own. ProseMirror already covered the editing behavior we needed, so we chose it as the document model users interact with.
ProseMirror does not impose a finished UI and lets an application compose only the modules it needs. It is widely used as the foundation of web editors, constrains document structure with a schema, records edits as transactions, and provides building blocks for tables and history. That allowed us to focus on safely translating paragraphs, runs, tables, and images between the ProseMirror model and OOXML.
The full path looks like this:
DOCX bytes
→ parse the ZIP package and OOXML
→ ProseMirror document + session retaining the original
→ edit through browser transactions
→ serialize only changed blocks to OOXML
→ replace only the required parts in the original package
→ DOCX bytes
The OOXML and ProseMirror models do not line up perfectly. Word formatting is layered across document defaults, styles, paragraph properties, and run properties. Tables also require merge and grid information. The editor calculates effective values for display, but keeps the source XML used for export separate so it can preserve the original structure as closely as possible.
ProseMirror’s transaction model was also useful for content locking. Supported DOCX content controls are represented in the editor schema, and plugins and commands prevent transactions from changing locked ranges. Tests ensure that a command’s applicability check agrees with its actual result, so a command cannot appear disabled in the toolbar while remaining available through a keyboard shortcut.
How we made Korean, Chinese, and Japanese input reliable
Korean, Chinese, and Japanese input was essential for the contract workflow. Unlike ordinary Latin-character input, an IME does not hand the browser one finished character at a time. It repeatedly replaces an active composition buffer. Japanese にほんご may become 日本語 after candidate selection, while a Chinese pinyin buffer is replaced with Han characters. Korean behaves differently again: one syllable is assembled as ㅇ → 아 → 안, and the final consonant in 간 can move to become the initial consonant of the next syllable, producing 가나.
ProseMirror handles the browser’s composition events, but features added around it can still disrupt that process. A transaction that applies Word styles to a new paragraph redraws its DOM. Pagination also creates new decorations as the text changes. If either operation touches the active paragraph, uncommitted text can disappear, be inserted twice, or move with a page boundary beneath the caret.
The rule is simple: while view.composing is true, the editor does not redraw the active paragraph beyond ProseMirror’s own composition handling.
- Style resolution skips the paragraph holding the composition and runs after composition ends.
- Page measurement and decoration updates are deferred, then coalesced into one run after composition ends.
- Selection-adjacent UI such as link cards stays closed during composition.
- The regular keymap does not reinterpret keydown events already consumed by the IME.
Locked content controls need one additional recovery path. Rejecting a transaction inside a locked range protects the document, but some browsers remove the composition text from the DOM without sending compositionend. ProseMirror can then remain stuck with view.composing === true, which also leaves deferred work such as pagination suspended indefinitely.
After rejecting that transaction, the lock plugin schedules a content-neutral transaction on the next animation frame. This asks ProseMirror to synchronize the DOM with the current document and ends the stale composition state. The locked content remains unchanged, no partial composition text remains on screen, and the editor is ready for the next input.
This behavior cannot be verified reliably in jsdom alone. Playwright and Chrome DevTools Protocol’s Input.imeSetComposition reproduce the composition buffers delivered to a real browser. The tests cover Japanese candidate replacement, Chinese pinyin conversion, Korean syllable assembly and final-consonant movement, and the buffer changes produced as Korean jamo are deleted one by one. They assert that the rendered view and document state remain in sync throughout. These browser tests turn the rule—do not introduce extra redraws in the active paragraph during IME composition—into a regression boundary for future pagination and styling changes.
Building quality constraints with coding agents
I built the initial version with coding agents. I defined the architecture and expected behavior, then reviewed the implementation, tests, and documentation they produced and used the results to direct the next task.
Agents were fast when a task had a clear scope and completion criteria. Long-lived properties such as DOCX compatibility were harder to preserve by repeating them in prompts. Important decisions therefore became constraints the repository could check automatically instead of explanations that existed only in conversation.
Checking architecture boundaries automatically
The OOXML and document-processing layers do not depend on the editor UI; the UI composes the layers below them. After documenting this dependency direction and the level assigned to each folder, folderBoundaries.test.ts was added to reject reverse imports, production files unreachable from a public entry point, and new folders with no assigned layer.
This keeps the architecture from gradually collapsing when an agent adds a feature in the most convenient location for the immediate task.
Making valid WordprocessingML a completion criterion
Looking correct in a browser is not enough for a DOCX editor. Exported WordprocessingML must also satisfy the OOXML schema.
The suite checks documents imported and exported without edits as well as exports in which a paragraph was actually changed. It validates the generated WordprocessingML against the ECMA-376 Transitional schemas with xmllint. The test cannot silently pass when the validator is missing or the fixture set is empty, and a deliberately invalid XML sample acts as a negative control.
Leaving decisions as context for the next task
The repository records relevant OOXML specification notes, the document structures that must be preserved, and behavior that is not supported yet. Tests define completion criteria, while documentation gives the next task its starting context.
The development loop became:
- Define the expected behavior and the conditions that must not fail.
- Let an agent implement the feature and its tests, then verify the result with real DOCX files and a browser.
- Turn any mismatch into an architectural rule, test, or document that guides the next task.
Coding agents made it possible to cover a wide feature set quickly, but the speed came from verifiable criteria rather than longer prompts. Without schema validation, round-trip tests, real-browser tests, and architecture-boundary checks, the repository might contain a similar amount of code without being a library I would be comfortable releasing.
Current scope and what comes next
Version 0.1 supports:
- Text formatting, paragraph styles, alignment, indentation, and line spacing
- Bulleted and numbered lists
- Table editing, including adding and removing rows and columns, merging and splitting cells, and resizing
- Inserting, pasting, and resizing images
- Links and comment threads
- Read-only mode and locks for supported content controls
- DOCX import and export
The goal was never to implement every Word feature. It was more important to cover the contract workflow while preserving document structures the editor does not understand yet. Version 0.1 is useful for real contracts within its supported scope; outside that scope, it prioritizes failing safely over silently damaging a document.
Page boundaries are approximated from browser measurements and can differ from Word or printed output. Footnote and endnote references and their plain-text bodies can be viewed but not edited. Uncommon list formats may render differently. Existing tracked changes are preserved in the original document but are not displayed or editable.
I would especially appreciate DOCX samples that expose import, rendering, or round-trip issues, as well as feedback from native Chinese and Japanese users. Contributions toward pagination, editable footnotes and endnotes, and broader document compatibility are welcome.