theseus/bundled-addons/docx-editor/lib/write.js
Local Dev 92ef408ac5 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

576 lines
21 KiB
JavaScript

// Editor model -> .docx.
//
// The docx builder gives us paragraphs, runs, tables, numbering and images;
// what it can't do is merge into an existing package, so this module builds a
// complete new document and then hands it to pkg.graft(), which carries the
// original's headers, footers, notes, styles and theme across.
//
// Mapping notes, because the two models disagree in places:
//
// * Marks are per-text-node in ProseMirror and per-run in Word, which is a
// clean fit: one text node with its mark set becomes one TextRun.
// * A ProseMirror list is a tree; Word's is a flat run of paragraphs each
// tagged with a numbering id and a level. flattenList() does that, and
// allocates one numbering instance per top-level list so that a second
// list on the page starts again at 1 instead of continuing.
// * Highlight is Word's 15-value enum, not a colour, so it passes straight
// through (see schema.js).
// * Tables get single-line borders. Word writes no borders unless a table
// style says otherwise and mammoth doesn't report the ones it read, so
// this is a deliberate default rather than a round-trip: see ROUND-TRIP.md.
(function (root, factory) {
const api = factory();
if (typeof module === "object" && module.exports) module.exports = api;
root.DocxEditor = Object.assign(root.DocxEditor || {}, { write: api });
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
"use strict";
const V = () => globalThis.DOCXV;
const TWIPS_PER_INDENT = 720;
const PT_TO_TWIP = 20;
const DEFAULT_FONT_PT = 11;
function alignmentOf(align) {
const { AlignmentType } = V().docx;
switch (align) {
case "center": return AlignmentType.CENTER;
case "right": return AlignmentType.RIGHT;
case "justify": return AlignmentType.JUSTIFIED;
case "left": return AlignmentType.LEFT;
default: return undefined;
}
}
function spacingOf(attrs) {
const spacing = {};
if (attrs.lineHeight) {
spacing.line = Math.round(attrs.lineHeight * 240);
spacing.lineRule = "auto";
}
if (attrs.spaceBefore != null) spacing.before = Math.round(attrs.spaceBefore * PT_TO_TWIP);
if (attrs.spaceAfter != null) spacing.after = Math.round(attrs.spaceAfter * PT_TO_TWIP);
return Object.keys(spacing).length ? spacing : undefined;
}
function indentOf(attrs, extraTwips) {
const left = (attrs.indent || 0) * TWIPS_PER_INDENT + (extraTwips || 0);
return left ? { left } : undefined;
}
function base64ToBytes(b64) {
if (typeof Buffer !== "undefined") return new Uint8Array(Buffer.from(b64, "base64"));
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
const IMAGE_TYPES = {
"image/png": "png", "image/jpeg": "jpg", "image/jpg": "jpg",
"image/gif": "gif", "image/bmp": "bmp", "image/svg+xml": "svg",
};
// ---------------------------------------------------------------- runs ---
function runsFromInline(node, ctx) {
const { TextRun, ExternalHyperlink, InternalHyperlink, Tab,
FootnoteReferenceRun } = V().docx;
const out = [];
node.forEach((child) => {
if (child.isText) {
const props = { text: child.text };
let link = null;
for (const mark of child.marks) {
switch (mark.type.name) {
case "strong": props.bold = true; break;
case "em": props.italics = true; break;
case "underline": props.underline = {}; break;
case "strike": props.strike = true; break;
case "sup": props.superScript = true; break;
case "sub": props.subScript = true; break;
case "caps": props.allCaps = true; break;
case "smallcaps": props.smallCaps = true; break;
case "font": props.font = mark.attrs.family; break;
case "fsize": props.size = Math.round(mark.attrs.pt * 2); break; // half-points
case "color": props.color = mark.attrs.hex; break;
case "highlight": props.highlight = mark.attrs.name; break;
case "link": link = mark.attrs; break;
default: break;
}
}
// Tabs are their own element in Word, so a run's text has to be cut
// around them — left inline they'd be written as literal tab
// characters, which Word renders as nothing at all.
const pieces = String(props.text).split("\t");
const finalRuns = [];
pieces.forEach((piece, i) => {
if (i > 0) {
const tabProps = Object.assign({}, props);
delete tabProps.text;
finalRuns.push(new TextRun(Object.assign(tabProps, { children: [new Tab()] })));
}
if (piece) finalRuns.push(new TextRun(Object.assign({}, props, { text: piece })));
});
if (!finalRuns.length) finalRuns.push(new TextRun(Object.assign({}, props, { text: "" })));
if (link) {
if (link.anchor) out.push(new InternalHyperlink({ anchor: link.anchor, children: finalRuns }));
else if (link.href) out.push(new ExternalHyperlink({ link: link.href, children: finalRuns }));
else out.push(...finalRuns);
} else {
out.push(...finalRuns);
}
return;
}
switch (child.type.name) {
case "hard_break":
out.push(new TextRun({ break: 1 }));
break;
case "image": {
const run = imageRun(child, ctx);
if (run) out.push(run);
break;
}
case "note_ref": {
const id = parseInt(child.attrs.noteId, 10);
if (Number.isFinite(id)) {
try { out.push(new FootnoteReferenceRun(id)); }
catch { ctx.warn(`footnote reference ${id} could not be written`); }
}
break;
}
default:
break;
}
});
return out;
}
function imageRun(node, ctx) {
const { ImageRun } = V().docx;
const m = /^data:([^;,]+);base64,(.*)$/.exec(node.attrs.src || "");
if (!m) { ctx.warn("an image could not be saved (unsupported source)"); return null; }
const type = IMAGE_TYPES[m[1].toLowerCase()];
if (!type) { ctx.warn(`an image of type ${m[1]} could not be saved`); return null; }
const data = base64ToBytes(m[2]);
const width = node.attrs.width || 300;
const height = node.attrs.height || Math.round(width * 0.75);
const opts = {
data,
// The builder wants pixels at 96dpi and rounds to whole EMU itself
// (1px = 9525 EMU), so these stay fractional — rounding to whole
// pixels here would quantise every picture to 0.75pt and shift it a
// little further on every save.
transformation: { width: width / 0.75, height: height / 0.75 },
type,
};
if (node.attrs.alt) opts.altText = { name: node.attrs.alt, description: node.attrs.alt, title: node.attrs.alt };
if (type === "svg") {
// The builder requires a raster fallback for SVG; without one it
// throws, and a thrown save is worse than a missing picture.
ctx.warn("an SVG image was skipped (Word needs a raster fallback)");
return null;
}
return new ImageRun(opts);
}
// ---------------------------------------------------------- paragraphs ---
function paragraphFrom(node, ctx, extra) {
const { Paragraph, HeadingLevel, BorderStyle } = V().docx;
const attrs = node.attrs || {};
const opts = Object.assign({
children: runsFromInline(node, ctx),
alignment: alignmentOf(attrs.align),
spacing: spacingOf(attrs),
indent: indentOf(attrs, (extra && extra.extraIndent) || 0),
}, extra && extra.paragraph);
if (node.type.name === "heading") {
opts.heading = [HeadingLevel.HEADING_1, HeadingLevel.HEADING_2, HeadingLevel.HEADING_3,
HeadingLevel.HEADING_4, HeadingLevel.HEADING_5, HeadingLevel.HEADING_6][
Math.max(1, Math.min(6, attrs.level || 1)) - 1];
}
return new Paragraph(opts);
}
// A ProseMirror list tree flattened into Word's numbered paragraphs.
function flattenList(listNode, ctx, out, level, reference) {
listNode.forEach((item) => {
let first = true;
item.forEach((child) => {
const name = child.type.name;
if (name === "bullet_list" || name === "ordered_list") {
flattenList(child, ctx, out, level + 1, reference);
return;
}
if (name === "paragraph" || name === "heading") {
// Only the item's first paragraph carries the bullet; the rest are
// continuation paragraphs indented to match, which is what Word
// does for a multi-paragraph list item.
if (first) {
out.push(paragraphFrom(child, ctx, {
paragraph: { numbering: { reference, level } },
}));
first = false;
} else {
out.push(paragraphFrom(child, ctx, {
extraIndent: (level + 1) * TWIPS_PER_INDENT,
}));
}
return;
}
// Tables and anything else inside a list item: emit it after, since
// Word can't nest a table under a bullet in our model.
blockFrom(child, ctx, out, {});
});
if (first) {
// An empty list item still needs a bullet.
const { Paragraph } = V().docx;
out.push(new Paragraph({ numbering: { reference, level } }));
}
});
}
// Tables are a grid, not a list of rows: a cell that spans rows downwards
// is written once with vMerge="restart", and every row it reaches into
// needs its own vMerge="continue" placeholder in that column. ProseMirror
// stores the covered cells as absent (the same convention HTML uses), so
// the writer has to put them back — without them Word reads a 12-row merge
// as a 2-row one.
function tableFrom(node, ctx) {
const { Table, TableRow, TableCell, WidthType, BorderStyle, Paragraph,
VerticalMergeType } = V().docx;
const border = { style: BorderStyle.SINGLE, size: 4, color: "999999" };
const pmRows = [];
node.forEach((r) => pmRows.push(r));
const continuation = (colspan) => new TableCell(Object.assign(
{ children: [new Paragraph({})], verticalMerge: VerticalMergeType.CONTINUE },
colspan > 1 ? { columnSpan: colspan } : {}));
const realCell = (cell) => {
const colspan = cell.attrs.colspan || 1;
const rowspan = cell.attrs.rowspan || 1;
const children = [];
blocksOf(cell, ctx, children, true);
if (!children.length) children.push(new Paragraph({}));
const opts = { children };
if (colspan > 1) opts.columnSpan = colspan;
if (rowspan > 1) opts.verticalMerge = VerticalMergeType.RESTART;
if (cell.attrs.background) opts.shading = { fill: String(cell.attrs.background).replace("#", "") };
return new TableCell(opts);
};
let active = []; // [{col, colspan, rowsLeft}] still merging down
const rows = [];
for (const pmRow of pmRows) {
const pmCells = [];
pmRow.forEach((c) => pmCells.push(c));
const outCells = [];
const nextActive = [];
let col = 0;
let i = 0;
const coveringAt = (c) => active.find((a) => a.col === c);
while (i < pmCells.length || coveringAt(col)) {
const cover = coveringAt(col);
if (cover) {
outCells.push(continuation(cover.colspan));
if (cover.rowsLeft > 1) {
nextActive.push({ col: cover.col, colspan: cover.colspan, rowsLeft: cover.rowsLeft - 1 });
}
col += cover.colspan;
continue;
}
const cell = pmCells[i++];
const colspan = cell.attrs.colspan || 1;
const rowspan = cell.attrs.rowspan || 1;
outCells.push(realCell(cell));
if (rowspan > 1) nextActive.push({ col, colspan, rowsLeft: rowspan - 1 });
col += colspan;
}
active = nextActive;
rows.push(new TableRow({ children: outCells }));
}
if (!rows.length) return null;
return new Table({
rows,
width: { size: 100, type: WidthType.PERCENTAGE },
borders: { top: border, bottom: border, left: border, right: border,
insideHorizontal: border, insideVertical: border },
});
}
function blocksOf(parent, ctx, out, inCell) {
const kids = [];
parent.forEach((child) => kids.push(child));
kids.forEach((child, i) => {
blockFrom(child, ctx, out, {
next: kids[i + 1] || null,
isLast: i === kids.length - 1,
inCell: !!inCell,
});
});
}
function blockFrom(node, ctx, out, pos) {
const { Paragraph, PageBreak, TextRun, BorderStyle } = V().docx;
const at = pos || {};
switch (node.type.name) {
case "paragraph":
case "heading":
out.push(paragraphFrom(node, ctx));
break;
case "blockquote": {
node.forEach((child) => {
if (child.type.name === "paragraph" || child.type.name === "heading") {
out.push(paragraphFrom(child, ctx, {
paragraph: { style: "Quote" },
extraIndent: TWIPS_PER_INDENT,
}));
} else {
blockFrom(child, ctx, out, {});
}
});
break;
}
case "code_block": {
// One Word paragraph per line, all in the editor's SourceCode style,
// so the block reads back as a block rather than as prose.
const lines = (node.textContent || "").split("\n");
for (const line of lines) {
out.push(new Paragraph({
style: "SourceCode",
children: [new TextRun({ text: line })],
}));
}
break;
}
case "bullet_list":
case "ordered_list": {
const reference = ctx.newNumbering(node, collectLevelFormats(node, 0, []));
flattenList(node, ctx, out, 0, reference);
break;
}
case "table": {
const t = tableFrom(node, ctx);
if (t) out.push(t);
// OOXML requires a table cell to end with a paragraph, so a table in
// that position gets one. Between two tables at body level it is
// only a rendering nicety, and adding one there would come back as a
// stray empty paragraph on the next read — documents that legitimately
// hold two adjacent tables would gain a blank line on every save.
if (at.inCell && at.isLast) out.push(new Paragraph({ spacing: { after: 0 } }));
break;
}
case "horizontal_rule":
out.push(new Paragraph({
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: "808080", space: 1 } },
}));
break;
case "page_break":
out.push(new Paragraph({ children: [new PageBreak()] }));
break;
default:
ctx.warn(`"${node.type.name}" is not written to Word`);
break;
}
}
// ----------------------------------------------------------- numbering ---
const BULLETS = ["●", "○", "▪", "●", "○", "▪", "●", "○", "▪"];
function levelFormatFor(format) {
const { LevelFormat } = V().docx;
switch (format) {
case "lowerLetter": return LevelFormat.LOWER_LETTER;
case "upperLetter": return LevelFormat.UPPER_LETTER;
case "lowerRoman": return LevelFormat.LOWER_ROMAN;
case "upperRoman": return LevelFormat.UPPER_ROMAN;
case "bullet": return LevelFormat.BULLET;
default: return LevelFormat.DECIMAL;
}
}
// Word keeps numbering formats per level on one numbering definition, so a
// list that is bulleted at the top and lettered underneath needs both
// facts before the definition can be written. Walk the tree first and
// record what each depth actually uses; where two sibling sub-lists
// disagree at the same depth, the first one wins (Word has nowhere to put
// the second answer).
function collectLevelFormats(node, level, into) {
if (level > 8) return into;
const ordered = node.type.name === "ordered_list";
if (!into[level]) {
into[level] = {
ordered,
format: ordered ? (node.attrs.format || "decimal") : "bullet",
start: ordered && node.attrs.order > 1 ? node.attrs.order : null,
};
}
node.forEach((item) => {
item.forEach((child) => {
const n = child.type.name;
if (n === "bullet_list" || n === "ordered_list") collectLevelFormats(child, level + 1, into);
});
});
return into;
}
// One numbering instance per top-level list. Nine levels each, because a
// list can be nested deeper than the levels we actually saw.
function numberingConfigFor(reference, levelFormats) {
const { LevelFormat, AlignmentType } = V().docx;
const levels = [];
for (let i = 0; i < 9; i++) {
const indent = { left: (i + 1) * TWIPS_PER_INDENT, hanging: 360 };
// Depths the document didn't reach still need a definition; fall back
// to Word's own default rotation.
const spec = levelFormats[i] ||
{ ordered: false, format: "bullet", start: null };
if (spec.ordered) {
levels.push({
level: i,
format: levelFormatFor(spec.format),
text: `%${i + 1}.`,
alignment: AlignmentType.START,
style: { paragraph: { indent } },
});
if (spec.start) levels[i].start = spec.start;
} else {
levels.push({
level: i,
format: LevelFormat.BULLET,
text: BULLETS[i],
alignment: AlignmentType.LEFT,
style: { paragraph: { indent } },
});
}
}
return { reference, levels };
}
// --------------------------------------------------------------- styles ---
// Only the styles the writer itself references. Anything the original
// document defined is grafted back over the top of these by pkg.graft().
function paragraphStyles() {
return [
{
id: "SourceCode",
name: "Source Code",
basedOn: "Normal",
quickFormat: true,
run: { font: "Consolas", size: 20 },
paragraph: { spacing: { before: 0, after: 0, line: 240, lineRule: "auto" } },
},
{
id: "Quote",
name: "Quote",
basedOn: "Normal",
quickFormat: true,
run: { italics: true, color: "404040" },
paragraph: { spacing: { before: 120, after: 120 } },
},
];
}
// ------------------------------------------------------------------ api ---
/**
* Serialise an editor document to .docx bytes.
*
* @param {Node} doc ProseMirror document
* @param {object} opts
* originalBytes the file this document was read from, if any — its
* headers, footers, notes, styles and theme are grafted
* onto the result
* setup page setup read from the original (pkg.readSectionSetup)
* meta document properties (pkg.readCoreProps)
* @returns {Promise<{bytes: Uint8Array, warnings: string[], carried: string[]}>}
*/
async function docToDocx(doc, opts) {
const o = opts || {};
const { docx } = V();
const { Document, Packer } = docx;
const warnings = [];
const numbering = [];
let numberingSeq = 0;
const ctx = {
warn(msg) { if (!warnings.includes(msg)) warnings.push(msg); },
newNumbering(node, levelFormats) {
const reference = `list-${++numberingSeq}`;
numbering.push(numberingConfigFor(reference, levelFormats));
return reference;
},
};
const children = [];
blocksOf(doc, ctx, children, false);
if (!children.length) {
const { Paragraph } = docx;
children.push(new Paragraph({}));
}
const section = { children, properties: {} };
if (o.setup && o.setup.page && Object.keys(o.setup.page).length) {
section.properties.page = o.setup.page;
}
if (o.setup && o.setup.titlePg) section.properties.titlePage = true;
const meta = o.meta || {};
const document = new Document({
title: meta.title || undefined,
creator: meta.creator || undefined,
description: meta.description || undefined,
subject: meta.subject || undefined,
keywords: meta.keywords || undefined,
numbering: numbering.length ? { config: numbering } : undefined,
styles: { paragraphStyles: paragraphStyles() },
sections: [section],
});
// Packer.toBuffer asks JSZip for a "nodebuffer", which browsers don't
// support — in the tab it throws before a single byte is written. Blob
// is the browser's route; node keeps toBuffer so the round-trip tests
// exercise the same code without a Blob shim.
let bytes;
if (typeof Buffer !== "undefined") {
const buf = await Packer.toBuffer(document);
bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
} else {
const blob = await Packer.toBlob(document);
bytes = new Uint8Array(await blob.arrayBuffer());
}
let carried = [];
if (o.originalBytes) {
try {
const grafted = await globalThis.DocxEditor.pkg.graft(o.originalBytes, bytes, o.graft);
bytes = grafted.bytes;
carried = grafted.carried;
} catch (e) {
// A failed graft must not cost the user their edits: the rebuilt
// document is still a valid .docx, just a plainer one.
ctx.warn(`could not carry over the original's headers/styles (${e && e.message || e})`);
}
}
return { bytes, warnings, carried };
}
return { docToDocx, numberingConfigFor, collectLevelFormats, paragraphStyles };
});