theseus/bundled-addons/docx-editor/lib/schema.js

379 lines
14 KiB
JavaScript
Raw Normal View History

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