theseus/bundled-addons/docx-editor/lib/write.js
Local Dev 56b3f2f206 feat(docx-editor): ship it in the build, updated over the first-party channel
Moving it out of the build left it with no way in. Settings can only install
from the community catalogue, so a first-party extension that isn't bundled
has a working update channel and no first copy for anyone to update — the
mechanism was all there and the front door was missing.

So it goes back beside screenshot, aegis and pdf-editor: seeded into every
profile by the build, listed under "Built into Theseus", and kept current
between releases by the operator-signed channel at
theseus.x/extensions/docx-editor/. That is the arrangement docs/ADDON-UPDATES.md
describes, and the one the signing script was written for.

About 400 KB compressed in the installer, most of it the vendored editor
libraries — next to the ~4 MB of pdf.js that pdf-editor already ships, the
weight argument for keeping it out didn't survive contact with the numbers.

The end-to-end driver goes back to checking that a fresh profile seeds it,
which is the property that actually matters now.
2026-09-21 22:47:13 +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 };
});