// The editor's document model. // // This schema is the contract between the two halves of the round-trip: the // reader (mammoth's document model -> here) and the writer (here -> the docx // builder). Every attribute below exists because something on the Word side // needs it, and every one of them is written back out. If you add a node or a // mark, add it to BOTH read.js and write.js or it will silently vanish the // first time someone saves. // // Units follow Word rather than CSS, deliberately — converting twips to // pixels and back is how round-trips accumulate drift: // indent integer level, 1 level = 720 twips (Word's default tab) // fsize points // lineHeight multiple of single spacing (1, 1.15, 1.5, 2) // space* points before/after the paragraph // highlight Word's highlight enum name, NOT a hex colour — w:highlight // only accepts the 15 named values, so storing a hex here // would mean guessing on the way out (function (root, factory) { const api = factory(); if (typeof module === "object" && module.exports) module.exports = api; root.DocxEditor = Object.assign(root.DocxEditor || {}, { schema: api }); })(typeof globalThis !== "undefined" ? globalThis : this, function () { "use strict"; const V = () => globalThis.DOCXV; // Word's highlight palette, in the order the ribbon shows it, with the CSS // each one renders as in the editor. const HIGHLIGHTS = [ { name: "yellow", css: "#ffff00", label: "Yellow" }, { name: "green", css: "#00ff00", label: "Bright green" }, { name: "cyan", css: "#00ffff", label: "Turquoise" }, { name: "magenta", css: "#ff00ff", label: "Pink" }, { name: "blue", css: "#0000ff", label: "Blue" }, { name: "red", css: "#ff0000", label: "Red" }, { name: "darkBlue", css: "#000080", label: "Dark blue" }, { name: "darkCyan", css: "#008080", label: "Teal" }, { name: "darkGreen", css: "#008000", label: "Green" }, { name: "darkMagenta", css: "#800080", label: "Violet" }, { name: "darkRed", css: "#800000", label: "Dark red" }, { name: "darkYellow", css: "#808000", label: "Dark yellow" }, { name: "darkGray", css: "#808080", label: "Grey 50%" }, { name: "lightGray", css: "#c0c0c0", label: "Grey 25%" }, { name: "black", css: "#000000", label: "Black" }, ]; const HIGHLIGHT_CSS = Object.fromEntries(HIGHLIGHTS.map((h) => [h.name, h.css])); const TWIPS_PER_INDENT = 720; // Attributes shared by every block that can carry paragraph formatting. function paragraphAttrs(extra) { return Object.assign({ align: { default: null }, // left | center | right | justify indent: { default: 0 }, // 0..8 lineHeight: { default: null }, // 1 | 1.15 | 1.5 | 2 | … spaceBefore: { default: null }, // points spaceAfter: { default: null }, // points }, extra || {}); } // Reading paragraph formatting back off a DOM element, for copy/paste and // for the drag-and-drop of HTML into the editor. function readParagraphAttrs(dom) { const st = dom.style || {}; const alignRaw = (st.textAlign || "").toLowerCase(); const align = ["left", "center", "right", "justify"].includes(alignRaw) ? alignRaw : null; const indentAttr = dom.getAttribute("data-indent"); let indent = indentAttr ? parseInt(indentAttr, 10) : 0; if (!Number.isFinite(indent) || indent < 0) indent = 0; const lh = parseFloat(st.lineHeight); const num = (v) => { const n = parseFloat(v); return Number.isFinite(n) ? n : null; }; return { align, indent: Math.min(8, indent), lineHeight: Number.isFinite(lh) ? lh : null, spaceBefore: num(dom.getAttribute("data-space-before")), spaceAfter: num(dom.getAttribute("data-space-after")), }; } function paragraphStyle(attrs) { const css = []; if (attrs.align) css.push(`text-align:${attrs.align}`); if (attrs.indent) css.push(`margin-left:${attrs.indent * 0.5}in`); if (attrs.lineHeight) css.push(`line-height:${attrs.lineHeight}`); if (attrs.spaceBefore != null) css.push(`margin-top:${attrs.spaceBefore}pt`); if (attrs.spaceAfter != null) css.push(`margin-bottom:${attrs.spaceAfter}pt`); return css.join(";"); } function paragraphDomAttrs(attrs) { const out = {}; const style = paragraphStyle(attrs); if (style) out.style = style; if (attrs.indent) out["data-indent"] = String(attrs.indent); if (attrs.spaceBefore != null) out["data-space-before"] = String(attrs.spaceBefore); if (attrs.spaceAfter != null) out["data-space-after"] = String(attrs.spaceAfter); return out; } function build() { const { model, schemaList, tables } = V().pm; const { Schema } = model; const nodes = { doc: { content: "block+" }, paragraph: { content: "inline*", group: "block", attrs: paragraphAttrs(), parseDOM: [{ tag: "p", getAttrs: readParagraphAttrs }], toDOM(node) { return ["p", paragraphDomAttrs(node.attrs), 0]; }, }, heading: { content: "inline*", group: "block", defining: true, attrs: paragraphAttrs({ level: { default: 1 } }), parseDOM: [1, 2, 3, 4, 5, 6].map((level) => ({ tag: "h" + level, getAttrs: (dom) => Object.assign(readParagraphAttrs(dom), { level }), })), toDOM(node) { return ["h" + node.attrs.level, paragraphDomAttrs(node.attrs), 0]; }, }, blockquote: { content: "block+", group: "block", defining: true, parseDOM: [{ tag: "blockquote" }], toDOM() { return ["blockquote", 0]; }, }, code_block: { content: "text*", marks: "", group: "block", code: true, defining: true, parseDOM: [{ tag: "pre", preserveWhitespace: "full" }], toDOM() { return ["pre", ["code", 0]]; }, }, horizontal_rule: { group: "block", parseDOM: [{ tag: "hr" }], toDOM() { return ["hr"]; }, }, // Word's hard page break. An atom so the caret skips over it rather // than landing inside something with no content. page_break: { group: "block", atom: true, selectable: true, parseDOM: [{ tag: "div.docx-page-break" }], toDOM() { return ["div", { class: "docx-page-break", contenteditable: "false" }, ["span", "Page break"]]; }, }, text: { group: "inline" }, image: { inline: true, group: "inline", draggable: true, attrs: { src: {}, alt: { default: null }, title: { default: null }, width: { default: null }, height: { default: null }, // points }, parseDOM: [{ tag: "img[src]", getAttrs: (dom) => ({ src: dom.getAttribute("src"), alt: dom.getAttribute("alt"), title: dom.getAttribute("title"), width: parseFloat(dom.getAttribute("data-w")) || null, height: parseFloat(dom.getAttribute("data-h")) || null, }), }], toDOM(node) { const a = { src: node.attrs.src, alt: node.attrs.alt || "", title: node.attrs.title || "" }; if (node.attrs.width) { a["data-w"] = String(node.attrs.width); a.style = `width:${node.attrs.width}pt`; } if (node.attrs.height) a["data-h"] = String(node.attrs.height); return ["img", a]; }, }, hard_break: { inline: true, group: "inline", selectable: false, parseDOM: [{ tag: "br" }], toDOM() { return ["br"]; }, }, // A footnote or endnote the editor doesn't render but refuses to throw // away: the note text stays in the package (see pkg.graft) and this // carries the reference that points at it. note_ref: { inline: true, group: "inline", atom: true, selectable: true, attrs: { noteType: { default: "footnote" }, noteId: { default: "" }, label: { default: "*" } }, parseDOM: [{ tag: "sup.docx-note-ref", getAttrs: (dom) => ({ noteType: dom.getAttribute("data-note-type") || "footnote", noteId: dom.getAttribute("data-note-id") || "", label: dom.textContent || "*", }), }], toDOM(node) { return ["sup", { class: "docx-note-ref", contenteditable: "false", "data-note-type": node.attrs.noteType, "data-note-id": node.attrs.noteId, title: (node.attrs.noteType === "endnote" ? "Endnote" : "Footnote") + " — kept in the file, not shown in the editor", }, node.attrs.label]; }, }, }; const marks = { strong: { parseDOM: [{ tag: "strong" }, { tag: "b" }, { style: "font-weight", getAttrs: (v) => /^(bold(er)?|[5-9]\d{2,})$/.test(v) && null }], toDOM() { return ["strong", 0]; }, }, em: { parseDOM: [{ tag: "i" }, { tag: "em" }, { style: "font-style=italic" }], toDOM() { return ["em", 0]; }, }, underline: { parseDOM: [{ tag: "u" }, { style: "text-decoration=underline" }], toDOM() { return ["u", 0]; }, }, strike: { parseDOM: [{ tag: "s" }, { tag: "strike" }, { tag: "del" }, { style: "text-decoration=line-through" }], toDOM() { return ["s", 0]; }, }, // Word treats these as one property (w:vertAlign), so they exclude // each other here too. sup: { group: "vertalign", excludes: "vertalign", parseDOM: [{ tag: "sup:not(.docx-note-ref)" }], toDOM() { return ["sup", 0]; }, }, sub: { group: "vertalign", excludes: "vertalign", parseDOM: [{ tag: "sub" }], toDOM() { return ["sub", 0]; }, }, caps: { parseDOM: [{ style: "text-transform=uppercase" }], toDOM() { return ["span", { style: "text-transform:uppercase" }, 0]; }, }, smallcaps: { parseDOM: [{ style: "font-variant=small-caps" }], toDOM() { return ["span", { style: "font-variant:small-caps" }, 0]; }, }, link: { attrs: { href: { default: "" }, title: { default: null }, anchor: { default: null } }, inclusive: false, parseDOM: [{ tag: "a[href]", getAttrs: (dom) => ({ href: dom.getAttribute("href") || "", title: dom.getAttribute("title"), anchor: dom.getAttribute("data-anchor"), }), }], toDOM(node) { const a = { href: node.attrs.href || "#", title: node.attrs.title || "" }; if (node.attrs.anchor) a["data-anchor"] = node.attrs.anchor; return ["a", a, 0]; }, }, font: { attrs: { family: {} }, parseDOM: [{ tag: "span[data-font]", getAttrs: (dom) => ({ family: dom.getAttribute("data-font") }), }], toDOM(node) { return ["span", { "data-font": node.attrs.family, style: `font-family:${JSON.stringify(node.attrs.family)}` }, 0]; }, }, fsize: { attrs: { pt: {} }, parseDOM: [{ tag: "span[data-size]", getAttrs: (dom) => ({ pt: parseFloat(dom.getAttribute("data-size")) || 11 }), }], toDOM(node) { return ["span", { "data-size": String(node.attrs.pt), style: `font-size:${node.attrs.pt}pt` }, 0]; }, }, color: { attrs: { hex: {} }, // RRGGBB, no leading # parseDOM: [{ tag: "span[data-color]", getAttrs: (dom) => ({ hex: (dom.getAttribute("data-color") || "").replace("#", "") }), }], toDOM(node) { return ["span", { "data-color": node.attrs.hex, style: `color:#${node.attrs.hex}` }, 0]; }, }, highlight: { attrs: { name: { default: "yellow" } }, parseDOM: [{ tag: "mark", getAttrs: (dom) => ({ name: dom.getAttribute("data-highlight") || "yellow" }), }], toDOM(node) { const css = HIGHLIGHT_CSS[node.attrs.name] || "#ffff00"; return ["mark", { "data-highlight": node.attrs.name, style: `background-color:${css};color:${css === "#000000" || css === "#000080" || css === "#800000" || css === "#808000" ? "#fff" : "inherit"}`, }, 0]; }, }, }; // Lists and tables come from the ProseMirror packages, so the node specs // match what their commands expect. Building a throwaway Schema first is // the cheapest way to get an OrderedMap without reaching for the // orderedmap module directly — it isn't re-exported by the bundle. let nodeMap = new Schema({ nodes, marks }).spec.nodes; nodeMap = schemaList.addListNodes(nodeMap, "paragraph block*", "block"); // Ordered lists keep Word's numbering format instead of collapsing // every list to 1. 2. 3. nodeMap = nodeMap.update("ordered_list", { content: "list_item+", group: "block", attrs: { order: { default: 1 }, // decimal | lowerLetter | upperLetter | lowerRoman | upperRoman format: { default: "decimal" }, }, parseDOM: [{ tag: "ol", getAttrs: (dom) => ({ order: dom.hasAttribute("start") ? parseInt(dom.getAttribute("start"), 10) || 1 : 1, format: dom.getAttribute("data-format") || "decimal", }), }], toDOM(node) { const a = {}; if (node.attrs.order !== 1) a.start = node.attrs.order; if (node.attrs.format !== "decimal") a["data-format"] = node.attrs.format; return ["ol", a, 0]; }, }); nodeMap = nodeMap.append(tables.tableNodes({ tableGroup: "block", cellContent: "block+", cellAttributes: { background: { default: null, getFromDOM: (dom) => dom.style.backgroundColor || null, setDOMAttr: (value, attrs) => { if (value) attrs.style = (attrs.style || "") + `background-color:${value};`; }, }, }, })); return new Schema({ nodes: nodeMap, marks }); } return { build, HIGHLIGHTS, HIGHLIGHT_CSS, TWIPS_PER_INDENT }; });