theseus/bundled-addons/docx-editor/lib/schema.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

378 lines
14 KiB
JavaScript

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