// 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 }; });