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.
This commit is contained in:
parent
decf118c88
commit
92ef408ac5
24 changed files with 7164 additions and 0 deletions
9
addon-build/docx-editor/.gitignore
vendored
Normal file
9
addon-build/docx-editor/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# The patched copy of mammoth is regenerated by `npm run build`; the patches
|
||||||
|
# themselves (patches.mjs) are the source of truth and are checked in.
|
||||||
|
.patched/
|
||||||
|
|
||||||
|
# Test scratch — regenerate with test/fixture.mjs and test/roundtrip.mjs.
|
||||||
|
test/fixture.docx
|
||||||
|
test/roundtrip-out.docx
|
||||||
|
test/roundtrip-a.json
|
||||||
|
test/roundtrip-b.json
|
||||||
77
addon-build/docx-editor/build.mjs
Normal file
77
addon-build/docx-editor/build.mjs
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
// Bundles the add-on's npm dependencies into
|
||||||
|
// bundled-addons/docx-editor/vendor/docx-vendor.js.
|
||||||
|
//
|
||||||
|
// npm run build (from addon-build/docx-editor/)
|
||||||
|
//
|
||||||
|
// mammoth is patched on the way through — see patches.mjs for why. The
|
||||||
|
// patched copy is materialised under .patched/ so the node round-trip tests
|
||||||
|
// exercise exactly the same reader the browser does.
|
||||||
|
import * as esbuild from "esbuild";
|
||||||
|
import { mkdirSync, writeFileSync, readFileSync, statSync, rmSync, cpSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import path from "node:path";
|
||||||
|
import { applyPatches, patches } from "./patches.mjs";
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const out = path.resolve(here, "../../bundled-addons/docx-editor/vendor");
|
||||||
|
const patchedRoot = path.join(here, ".patched");
|
||||||
|
|
||||||
|
// --- 1. patched mammoth ---------------------------------------------------
|
||||||
|
rmSync(patchedRoot, { recursive: true, force: true });
|
||||||
|
mkdirSync(patchedRoot, { recursive: true });
|
||||||
|
cpSync(path.join(here, "node_modules", "mammoth"), path.join(patchedRoot, "mammoth"), { recursive: true });
|
||||||
|
const touched = new Set();
|
||||||
|
for (const p of patches) {
|
||||||
|
const f = path.join(patchedRoot, "mammoth", p.file);
|
||||||
|
if (touched.has(f)) continue;
|
||||||
|
touched.add(f);
|
||||||
|
}
|
||||||
|
for (const f of touched) {
|
||||||
|
const rel = "mammoth/" + path.relative(path.join(patchedRoot, "mammoth"), f).split(path.sep).join("/");
|
||||||
|
const { code, hits } = applyPatches(rel, readFileSync(f, "utf8"));
|
||||||
|
writeFileSync(f, code);
|
||||||
|
console.log(`patched ${rel} (${hits} hunks)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. bundle ------------------------------------------------------------
|
||||||
|
mkdirSync(out, { recursive: true });
|
||||||
|
const res = await esbuild.build({
|
||||||
|
entryPoints: [path.join(here, "vendor-entry.js")],
|
||||||
|
bundle: true,
|
||||||
|
minify: true,
|
||||||
|
format: "iife",
|
||||||
|
platform: "browser",
|
||||||
|
target: ["chrome120"],
|
||||||
|
legalComments: "none",
|
||||||
|
outfile: path.join(out, "docx-vendor.js"),
|
||||||
|
define: { "process.env.NODE_ENV": '"production"' },
|
||||||
|
alias: { mammoth: path.join(patchedRoot, "mammoth") },
|
||||||
|
logLevel: "info",
|
||||||
|
});
|
||||||
|
if (res.errors.length) process.exit(1);
|
||||||
|
|
||||||
|
// --- 3. licences ----------------------------------------------------------
|
||||||
|
// Vendored code without its licence text is the kind of thing that bites
|
||||||
|
// later; this file ships next to the bundle.
|
||||||
|
const pkgs = ["mammoth", "docx", "jszip", "underscore", "orderedmap", "w3c-keyname",
|
||||||
|
"rope-sequence", "prosemirror-state", "prosemirror-view", "prosemirror-model",
|
||||||
|
"prosemirror-schema-basic", "prosemirror-schema-list", "prosemirror-tables",
|
||||||
|
"prosemirror-history", "prosemirror-commands", "prosemirror-keymap",
|
||||||
|
"prosemirror-inputrules", "prosemirror-dropcursor", "prosemirror-gapcursor",
|
||||||
|
"prosemirror-transform"];
|
||||||
|
let notice = "Third-party code bundled into vendor/docx-vendor.js\n" +
|
||||||
|
"===================================================\n\n" +
|
||||||
|
"mammoth is shipped with small local patches (colour, paragraph spacing,\n" +
|
||||||
|
"numbering format); see addon-build/docx-editor/patches.mjs.\n\n";
|
||||||
|
for (const p of pkgs) {
|
||||||
|
let j; try { j = JSON.parse(readFileSync(path.join(here, "node_modules", p, "package.json"), "utf8")); }
|
||||||
|
catch { continue; }
|
||||||
|
let text = "";
|
||||||
|
for (const f of ["LICENSE", "LICENSE.md", "LICENSE.txt", "LICENCE", "license", "LICENSE-MIT"]) {
|
||||||
|
try { text = readFileSync(path.join(here, "node_modules", p, f), "utf8").trim(); break; } catch {}
|
||||||
|
}
|
||||||
|
notice += `--- ${p} ${j.version} — ${j.license || "see project"} ---\n` +
|
||||||
|
(text || `(no licence file in the package; see ${j.homepage || j.repository?.url || "the project homepage"})`) + "\n\n";
|
||||||
|
}
|
||||||
|
writeFileSync(path.join(out, "LICENSES.txt"), notice);
|
||||||
|
console.log(`vendor bundle: ${(statSync(path.join(out, "docx-vendor.js")).size / 1024).toFixed(0)} KB`);
|
||||||
1587
addon-build/docx-editor/package-lock.json
generated
Normal file
1587
addon-build/docx-editor/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
29
addon-build/docx-editor/package.json
Normal file
29
addon-build/docx-editor/package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"name": "docx-editor-vendor-build",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"description": "Build-time only: bundles mammoth + ProseMirror + docx into bundled-addons/docx-editor/vendor/.",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build.mjs"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"docx": "^9.5.1",
|
||||||
|
"esbuild": "^0.28.2",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
|
"mammoth": "^1.12.3",
|
||||||
|
"prosemirror-commands": "^1.7.1",
|
||||||
|
"prosemirror-dropcursor": "^1.8.2",
|
||||||
|
"prosemirror-gapcursor": "^1.3.2",
|
||||||
|
"prosemirror-history": "^1.4.1",
|
||||||
|
"prosemirror-inputrules": "^1.5.0",
|
||||||
|
"prosemirror-keymap": "^1.2.3",
|
||||||
|
"prosemirror-model": "^1.25.0",
|
||||||
|
"prosemirror-schema-basic": "^1.2.4",
|
||||||
|
"prosemirror-schema-list": "^1.5.1",
|
||||||
|
"prosemirror-state": "^1.4.3",
|
||||||
|
"prosemirror-tables": "^1.7.1",
|
||||||
|
"prosemirror-view": "^1.40.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
204
addon-build/docx-editor/patches.mjs
Normal file
204
addon-build/docx-editor/patches.mjs
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
// Build-time patches applied to vendored mammoth.
|
||||||
|
//
|
||||||
|
// Why: mammoth's document model deliberately drops direct formatting that
|
||||||
|
// isn't semantic — run colour, paragraph line/space spacing — and flattens
|
||||||
|
// every numbering level to a bare isOrdered boolean. All three are v1 editor
|
||||||
|
// features (text colour, line spacing, numbered-list formats), so without
|
||||||
|
// these a document would visibly lose them on the first round-trip.
|
||||||
|
//
|
||||||
|
// Each patch asserts its anchor: if a mammoth upgrade moves the code, the
|
||||||
|
// build fails loudly instead of silently shipping an editor that eats
|
||||||
|
// formatting.
|
||||||
|
export const patches = [
|
||||||
|
{
|
||||||
|
file: "lib/docx/body-reader.js",
|
||||||
|
find: ` highlight: readHighlightValue(element.firstOrEmpty("w:highlight").attributes["w:val"])`,
|
||||||
|
replace: ` highlight: readHighlightValue(element.firstOrEmpty("w:highlight").attributes["w:val"]),
|
||||||
|
color: readColorValue(element.firstOrEmpty("w:color").attributes["w:val"])`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: "lib/docx/body-reader.js",
|
||||||
|
find: ` function readUnderline(element) {`,
|
||||||
|
replace: ` function readColorValue(value) {
|
||||||
|
// w:color w:val is RRGGBB, or "auto" meaning "let the renderer pick".
|
||||||
|
return /^[0-9a-fA-F]{6}$/.test(value || "") ? value.toUpperCase() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readParagraphSpacing(element) {
|
||||||
|
var attrs = element.attributes;
|
||||||
|
var line = attrs["w:line"];
|
||||||
|
var num = function(v) { return /^-?[0-9]+$/.test(v || "") ? parseInt(v, 10) : null; };
|
||||||
|
return {
|
||||||
|
// w:line is 240ths of a line when w:lineRule is auto (the common
|
||||||
|
// case); exact/atLeast are in twips and the reader leaves them be.
|
||||||
|
line: num(line),
|
||||||
|
lineRule: attrs["w:lineRule"] || null,
|
||||||
|
before: num(attrs["w:before"]),
|
||||||
|
after: num(attrs["w:after"])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readUnderline(element) {`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: "lib/docx/body-reader.js",
|
||||||
|
find: ` indent: readParagraphIndent(element.firstOrEmpty("w:ind"))`,
|
||||||
|
replace: ` indent: readParagraphIndent(element.firstOrEmpty("w:ind")),
|
||||||
|
spacing: readParagraphSpacing(element.firstOrEmpty("w:spacing")),
|
||||||
|
// Word's horizontal rule is an empty paragraph with a bottom
|
||||||
|
// border; without this the editor can't tell one from a
|
||||||
|
// blank line, and can't write one back either.
|
||||||
|
hasBottomBorder: readHasBottomBorder(element.firstOrEmpty("w:pBdr"))`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: "lib/docx/body-reader.js",
|
||||||
|
find: ` function readParagraphIndent(element) {`,
|
||||||
|
replace: ` function readHasBottomBorder(element) {
|
||||||
|
var bottom = element.firstOrEmpty("w:bottom").attributes["w:val"];
|
||||||
|
return !!bottom && bottom !== "none" && bottom !== "nil";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readParagraphIndent(element) {`,
|
||||||
|
},
|
||||||
|
// Images: mammoth hands back the file but not the size Word was drawing it
|
||||||
|
// at (wp:extent, in EMU). Without it a picture the author scaled down to a
|
||||||
|
// thumbnail would come back at full natural size on the next save.
|
||||||
|
{
|
||||||
|
file: "lib/docx/body-reader.js",
|
||||||
|
find: ` return readImage(blipImageFile, altText).map(function(imageElement) {`,
|
||||||
|
replace: ` var extentAttributes = element.firstOrEmpty("wp:extent").attributes;
|
||||||
|
return readImage(blipImageFile, altText, extentAttributes).map(function(imageElement) {`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: "lib/docx/body-reader.js",
|
||||||
|
find: ` function readImage(imageFile, altText) {
|
||||||
|
var contentType = contentTypes.findContentType(imageFile.path);
|
||||||
|
|
||||||
|
var image = documents.Image({
|
||||||
|
readImage: imageFile.read,
|
||||||
|
altText: altText,
|
||||||
|
contentType: contentType
|
||||||
|
});`,
|
||||||
|
replace: ` function readImage(imageFile, altText, extent) {
|
||||||
|
var contentType = contentTypes.findContentType(imageFile.path);
|
||||||
|
|
||||||
|
// 12700 EMU to the point.
|
||||||
|
var emuToPt = function(v) {
|
||||||
|
return /^[0-9]+$/.test(v || "") ? Math.round(parseInt(v, 10) / 12700 * 100) / 100 : null;
|
||||||
|
};
|
||||||
|
var image = documents.Image({
|
||||||
|
readImage: imageFile.read,
|
||||||
|
altText: altText,
|
||||||
|
contentType: contentType,
|
||||||
|
widthPt: emuToPt(extent && extent.cx),
|
||||||
|
heightPt: emuToPt(extent && extent.cy)
|
||||||
|
});`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: "lib/documents.js",
|
||||||
|
find: ` altText: options.altText,
|
||||||
|
contentType: options.contentType`,
|
||||||
|
replace: ` altText: options.altText,
|
||||||
|
contentType: options.contentType,
|
||||||
|
widthPt: options.widthPt == null ? null : options.widthPt,
|
||||||
|
heightPt: options.heightPt == null ? null : options.heightPt`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: "lib/documents.js",
|
||||||
|
find: ` highlight: properties.highlight || null
|
||||||
|
};
|
||||||
|
}`,
|
||||||
|
replace: ` highlight: properties.highlight || null,
|
||||||
|
color: properties.color || null
|
||||||
|
};
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: "lib/documents.js",
|
||||||
|
find: ` indent: {
|
||||||
|
start: indent.start || null,
|
||||||
|
end: indent.end || null,
|
||||||
|
firstLine: indent.firstLine || null,
|
||||||
|
hanging: indent.hanging || null
|
||||||
|
}
|
||||||
|
};`,
|
||||||
|
replace: ` indent: {
|
||||||
|
start: indent.start || null,
|
||||||
|
end: indent.end || null,
|
||||||
|
firstLine: indent.firstLine || null,
|
||||||
|
hanging: indent.hanging || null
|
||||||
|
},
|
||||||
|
spacing: properties.spacing || null,
|
||||||
|
hasBottomBorder: !!properties.hasBottomBorder
|
||||||
|
};`,
|
||||||
|
},
|
||||||
|
// Which numbering definition a list item belongs to. Word uses numId to
|
||||||
|
// tell two adjacent lists apart — the point at which the numbering starts
|
||||||
|
// again at 1 — and mammoth resolves it to a level and then forgets it,
|
||||||
|
// which leaves the reader unable to see where one list ends and the next
|
||||||
|
// begins.
|
||||||
|
{
|
||||||
|
file: "lib/docx/body-reader.js",
|
||||||
|
find: `function readNumberingProperties(styleId, element, numbering) {
|
||||||
|
var level = element.firstOrEmpty("w:ilvl").attributes["w:val"];
|
||||||
|
var numId = element.firstOrEmpty("w:numId").attributes["w:val"];
|
||||||
|
if (level !== undefined && numId !== undefined) {
|
||||||
|
return numbering.findLevel(numId, level);
|
||||||
|
}`,
|
||||||
|
replace: `function readNumberingProperties(styleId, element, numbering) {
|
||||||
|
var level = element.firstOrEmpty("w:ilvl").attributes["w:val"];
|
||||||
|
var numId = element.firstOrEmpty("w:numId").attributes["w:val"];
|
||||||
|
var withNumId = function(found, id) {
|
||||||
|
return found == null ? found : Object.assign({}, found, {numId: id == null ? null : String(id)});
|
||||||
|
};
|
||||||
|
if (level !== undefined && numId !== undefined) {
|
||||||
|
return withNumId(numbering.findLevel(numId, level), numId);
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: "lib/docx/body-reader.js",
|
||||||
|
find: ` if (numId !== undefined) {
|
||||||
|
return numbering.findLevel(numId, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}`,
|
||||||
|
replace: ` if (numId !== undefined) {
|
||||||
|
return withNumId(numbering.findLevel(numId, "0"), numId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: "lib/docx/numbering-xml.js",
|
||||||
|
find: ` levelWithoutIndex = {
|
||||||
|
isOrdered: isOrdered,`,
|
||||||
|
replace: ` levelWithoutIndex = {
|
||||||
|
numFmt: numFmt || null,
|
||||||
|
isOrdered: isOrdered,`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: "lib/docx/numbering-xml.js",
|
||||||
|
find: ` levels[levelIndex] = {
|
||||||
|
isOrdered: isOrdered,`,
|
||||||
|
replace: ` levels[levelIndex] = {
|
||||||
|
numFmt: numFmt || null,
|
||||||
|
isOrdered: isOrdered,`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function applyPatches(relPath, source) {
|
||||||
|
let out = source;
|
||||||
|
let hits = 0;
|
||||||
|
for (const p of patches) {
|
||||||
|
if (relPath !== "mammoth/" + p.file) continue;
|
||||||
|
if (!out.includes(p.find)) {
|
||||||
|
throw new Error(`mammoth patch anchor missing in ${p.file}:\n${p.find.slice(0, 90)}…\n` +
|
||||||
|
`A mammoth upgrade probably moved it. Re-check the patch before shipping.`);
|
||||||
|
}
|
||||||
|
out = out.replace(p.find, p.replace);
|
||||||
|
hits++;
|
||||||
|
}
|
||||||
|
return { code: out, hits };
|
||||||
|
}
|
||||||
114
addon-build/docx-editor/test/corpus.mjs
Normal file
114
addon-build/docx-editor/test/corpus.mjs
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
// Runs the round-trip over a folder of real .docx files.
|
||||||
|
//
|
||||||
|
// node test/corpus.mjs <dir-or-file> [more…]
|
||||||
|
//
|
||||||
|
// Reports structure only — block counts, features found, whether the saved
|
||||||
|
// package is well-formed and whether a second read matches the first. It
|
||||||
|
// never prints document text, and it writes its output to a temp folder
|
||||||
|
// rather than next to the input.
|
||||||
|
import { readFileSync, writeFileSync, readdirSync, statSync, mkdtempSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
import { loadAddonLibs, summarise, diff } from "./harness.mjs";
|
||||||
|
|
||||||
|
const DocxEditor = loadAddonLibs();
|
||||||
|
const schema = DocxEditor.schema.build();
|
||||||
|
const outDir = mkdtempSync(path.join(os.tmpdir(), "docx-corpus-"));
|
||||||
|
|
||||||
|
function collect(target, into) {
|
||||||
|
let st;
|
||||||
|
try { st = statSync(target); } catch { return into; }
|
||||||
|
if (st.isDirectory()) {
|
||||||
|
for (const name of readdirSync(target)) {
|
||||||
|
if (name.startsWith("~$")) continue; // Word lock files
|
||||||
|
collect(path.join(target, name), into);
|
||||||
|
}
|
||||||
|
} else if (/\.docx$/i.test(target)) {
|
||||||
|
into.push(target);
|
||||||
|
}
|
||||||
|
return into;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = [];
|
||||||
|
for (const arg of process.argv.slice(2)) collect(arg, targets);
|
||||||
|
if (!targets.length) { console.error("no .docx files found"); process.exit(2); }
|
||||||
|
|
||||||
|
const counters = { ok: 0, drift: 0, invalid: 0, failed: 0 };
|
||||||
|
const featureTally = new Map();
|
||||||
|
const problems = [];
|
||||||
|
|
||||||
|
for (const file of targets) {
|
||||||
|
const label = path.basename(file).replace(/[^\x20-\x7e]/g, "?");
|
||||||
|
let bytes;
|
||||||
|
try { bytes = new Uint8Array(readFileSync(file)); }
|
||||||
|
catch (e) { problems.push(`${label}: unreadable (${e.message})`); counters.failed++; continue; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const first = await DocxEditor.read.docxToDoc(bytes, schema);
|
||||||
|
for (const f of first.report.features) {
|
||||||
|
featureTally.set(f.label, (featureTally.get(f.label) || 0) + 1);
|
||||||
|
}
|
||||||
|
const saved = await DocxEditor.write.docToDocx(first.doc, {
|
||||||
|
originalBytes: bytes, setup: first.setup, meta: first.meta,
|
||||||
|
});
|
||||||
|
const second = await DocxEditor.read.docxToDoc(saved.bytes, schema);
|
||||||
|
|
||||||
|
// Package sanity, same checks as the fixture round-trip.
|
||||||
|
const zip = await DocxEditor.pkg.loadZip(saved.bytes);
|
||||||
|
const names = Object.keys(zip.files);
|
||||||
|
const bad = [];
|
||||||
|
for (const name of names) {
|
||||||
|
if (!name.endsWith(".xml") && !name.endsWith(".rels")) continue;
|
||||||
|
try { DocxEditor.pkg.parseXml(await zip.file(name).async("string")); }
|
||||||
|
catch (e) { bad.push(name); }
|
||||||
|
}
|
||||||
|
const relsDoc = DocxEditor.pkg.parseXml(await zip.file("word/_rels/document.xml.rels").async("string"));
|
||||||
|
const declared = new Set(Array.from(relsDoc.getElementsByTagName("*"))
|
||||||
|
.filter((e) => e.localName === "Relationship").map((e) => e.getAttribute("Id")));
|
||||||
|
const docXml = await zip.file("word/document.xml").async("string");
|
||||||
|
const dangling = [...new Set([...docXml.matchAll(/r:(?:id|embed|link)="([^"]+)"/g)].map((m) => m[1]))]
|
||||||
|
.filter((id) => !declared.has(id));
|
||||||
|
const missingParts = Array.from(relsDoc.getElementsByTagName("*"))
|
||||||
|
.filter((e) => e.localName === "Relationship" && e.getAttribute("TargetMode") !== "External")
|
||||||
|
.map((e) => "word/" + (e.getAttribute("Target") || "").replace(/^\.\//, ""))
|
||||||
|
.filter((p) => !p.includes("://") && !names.includes(p));
|
||||||
|
|
||||||
|
const d = diff(summarise(first.doc), summarise(second.doc));
|
||||||
|
const sizeKb = (bytes.length / 1024).toFixed(0);
|
||||||
|
|
||||||
|
if (bad.length || dangling.length || missingParts.length) {
|
||||||
|
counters.invalid++;
|
||||||
|
problems.push(`${label}: INVALID PACKAGE — ${[
|
||||||
|
bad.length ? `malformed ${bad.join(",")}` : "",
|
||||||
|
dangling.length ? `dangling ${dangling.join(",")}` : "",
|
||||||
|
missingParts.length ? `missing ${missingParts.join(",")}` : "",
|
||||||
|
].filter(Boolean).join("; ")}`);
|
||||||
|
writeFileSync(path.join(outDir, label + ".out.docx"), Buffer.from(saved.bytes));
|
||||||
|
} else if (d.length) {
|
||||||
|
counters.drift++;
|
||||||
|
problems.push(`${label} (${sizeKb} KB, ${first.doc.childCount} blocks): ${d.length} drift — ` +
|
||||||
|
d.slice(0, 3).map((x) => x.replace(/"[^"]{40,}"/g, '"…"')).join(" | "));
|
||||||
|
} else {
|
||||||
|
counters.ok++;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
counters.failed++;
|
||||||
|
problems.push(`${label}: THREW — ${(e && e.message || e).toString().slice(0, 160)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\ncorpus: ${targets.length} documents`);
|
||||||
|
console.log(` clean round-trip : ${counters.ok}`);
|
||||||
|
console.log(` content drift : ${counters.drift}`);
|
||||||
|
console.log(` invalid package : ${counters.invalid}`);
|
||||||
|
console.log(` threw : ${counters.failed}`);
|
||||||
|
console.log(`\nfeatures across the corpus:`);
|
||||||
|
for (const [label, n] of [...featureTally].sort((a, b) => b[1] - a[1])) {
|
||||||
|
console.log(` ${String(n).padStart(3)}x ${label}`);
|
||||||
|
}
|
||||||
|
if (problems.length) {
|
||||||
|
console.log(`\nproblems:`);
|
||||||
|
for (const p of problems) console.log(" " + p);
|
||||||
|
}
|
||||||
|
console.log(`\noutput for failures: ${outDir}`);
|
||||||
|
process.exit(counters.invalid || counters.failed ? 2 : counters.drift ? 1 : 0);
|
||||||
204
addon-build/docx-editor/test/fixture.mjs
Normal file
204
addon-build/docx-editor/test/fixture.mjs
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
// Builds the round-trip fixture: one .docx containing every feature v1
|
||||||
|
// claims to support, plus a few it doesn't (a header, a footer, a footnote,
|
||||||
|
// a text box) so the preservation half of the deal is tested too.
|
||||||
|
//
|
||||||
|
// node test/fixture.mjs [out.docx]
|
||||||
|
import {
|
||||||
|
Document, Packer, Paragraph, TextRun, HeadingLevel, AlignmentType, Table, TableRow,
|
||||||
|
TableCell, WidthType, BorderStyle, ImageRun, ExternalHyperlink, PageBreak, Header,
|
||||||
|
Footer, FootnoteReferenceRun, LevelFormat, Tab,
|
||||||
|
} from "docx";
|
||||||
|
import { writeFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import path from "node:path";
|
||||||
|
import zlib from "node:zlib";
|
||||||
|
|
||||||
|
// A 4x3 red PNG, built rather than checked in so the fixture stays one file.
|
||||||
|
function tinyPng(w = 4, h = 3, rgb = [220, 40, 40]) {
|
||||||
|
const crcTable = (() => {
|
||||||
|
const t = new Int32Array(256);
|
||||||
|
for (let n = 0; n < 256; n++) {
|
||||||
|
let c = n;
|
||||||
|
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||||
|
t[n] = c;
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
})();
|
||||||
|
const crc = (buf) => {
|
||||||
|
let c = -1;
|
||||||
|
for (const b of buf) c = crcTable[(c ^ b) & 0xff] ^ (c >>> 8);
|
||||||
|
return (c ^ -1) >>> 0;
|
||||||
|
};
|
||||||
|
const chunk = (type, data) => {
|
||||||
|
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
|
||||||
|
const body = Buffer.concat([Buffer.from(type, "latin1"), data]);
|
||||||
|
const c = Buffer.alloc(4); c.writeUInt32BE(crc(body));
|
||||||
|
return Buffer.concat([len, body, c]);
|
||||||
|
};
|
||||||
|
const ihdr = Buffer.alloc(13);
|
||||||
|
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4);
|
||||||
|
ihdr[8] = 8; ihdr[9] = 2; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
|
||||||
|
const raw = Buffer.concat(Array.from({ length: h }, () =>
|
||||||
|
Buffer.concat([Buffer.from([0]), Buffer.concat(Array.from({ length: w }, () => Buffer.from(rgb)))])));
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||||
|
chunk("IHDR", ihdr),
|
||||||
|
chunk("IDAT", zlib.deflateSync(raw)),
|
||||||
|
chunk("IEND", Buffer.alloc(0)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const border = { style: BorderStyle.SINGLE, size: 4, color: "999999" };
|
||||||
|
const cell = (text, opts = {}) => new TableCell(Object.assign({
|
||||||
|
children: [new Paragraph({ children: [new TextRun({ text })] })],
|
||||||
|
}, opts));
|
||||||
|
|
||||||
|
export function buildFixture() {
|
||||||
|
const png = tinyPng();
|
||||||
|
|
||||||
|
const doc = new Document({
|
||||||
|
title: "Round-trip fixture",
|
||||||
|
creator: "Silent Mode",
|
||||||
|
description: "Every v1 feature, once.",
|
||||||
|
numbering: {
|
||||||
|
config: [
|
||||||
|
{
|
||||||
|
reference: "bullets",
|
||||||
|
levels: [
|
||||||
|
{ level: 0, format: LevelFormat.BULLET, text: "●", style: { paragraph: { indent: { left: 720, hanging: 360 } } } },
|
||||||
|
{ level: 1, format: LevelFormat.BULLET, text: "○", style: { paragraph: { indent: { left: 1440, hanging: 360 } } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: "romans",
|
||||||
|
levels: [
|
||||||
|
{ level: 0, format: LevelFormat.LOWER_ROMAN, text: "%1.", style: { paragraph: { indent: { left: 720, hanging: 360 } } } },
|
||||||
|
{ level: 1, format: LevelFormat.LOWER_LETTER, text: "%2.", style: { paragraph: { indent: { left: 1440, hanging: 360 } } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
footnotes: {
|
||||||
|
1: { children: [new Paragraph({ children: [new TextRun("A footnote the editor never renders.")] })] },
|
||||||
|
},
|
||||||
|
sections: [{
|
||||||
|
properties: {
|
||||||
|
page: {
|
||||||
|
size: { width: 11906, height: 16838, orientation: "portrait" }, // A4
|
||||||
|
margin: { top: 1134, right: 1134, bottom: 1134, left: 1701 }, // 2cm / 3cm left
|
||||||
|
},
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
default: new Header({ children: [new Paragraph({ children: [new TextRun("Fixture header")] })] }),
|
||||||
|
},
|
||||||
|
footers: {
|
||||||
|
default: new Footer({ children: [new Paragraph({ children: [new TextRun("Fixture footer")] })] }),
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun("Heading one")] }),
|
||||||
|
new Paragraph({ heading: HeadingLevel.HEADING_2, children: [new TextRun("Heading two")] }),
|
||||||
|
new Paragraph({ heading: HeadingLevel.HEADING_3, children: [new TextRun("Heading three")] }),
|
||||||
|
|
||||||
|
new Paragraph({
|
||||||
|
children: [
|
||||||
|
new TextRun({ text: "plain " }),
|
||||||
|
new TextRun({ text: "bold", bold: true }),
|
||||||
|
new TextRun({ text: " italic", italics: true }),
|
||||||
|
new TextRun({ text: " underline", underline: {} }),
|
||||||
|
new TextRun({ text: " strike", strike: true }),
|
||||||
|
new TextRun({ text: " sup", superScript: true }),
|
||||||
|
new TextRun({ text: " sub", subScript: true }),
|
||||||
|
new TextRun({ text: " smallcaps", smallCaps: true }),
|
||||||
|
new TextRun({ text: " allcaps", allCaps: true }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
new Paragraph({
|
||||||
|
children: [
|
||||||
|
new TextRun({ text: "Georgia 16pt teal", font: "Georgia", size: 32, color: "008080" }),
|
||||||
|
new TextRun({ text: " highlighted", highlight: "yellow" }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
new Paragraph({
|
||||||
|
alignment: AlignmentType.CENTER,
|
||||||
|
children: [new TextRun("centred")],
|
||||||
|
}),
|
||||||
|
new Paragraph({
|
||||||
|
alignment: AlignmentType.RIGHT,
|
||||||
|
children: [new TextRun("right aligned")],
|
||||||
|
}),
|
||||||
|
new Paragraph({
|
||||||
|
alignment: AlignmentType.JUSTIFIED,
|
||||||
|
spacing: { line: 360, lineRule: "auto", before: 120, after: 240 },
|
||||||
|
indent: { left: 720 },
|
||||||
|
children: [new TextRun("justified, 1.5 line spacing, 6pt before, 12pt after, indented one level")],
|
||||||
|
}),
|
||||||
|
new Paragraph({
|
||||||
|
children: [
|
||||||
|
new TextRun("before tab"),
|
||||||
|
new TextRun({ children: [new Tab()] }),
|
||||||
|
new TextRun("after tab"),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
new Paragraph({
|
||||||
|
children: [
|
||||||
|
new TextRun("a link to "),
|
||||||
|
new ExternalHyperlink({
|
||||||
|
link: "https://silentmode.st/",
|
||||||
|
children: [new TextRun({ text: "silentmode.st", style: "Hyperlink" })],
|
||||||
|
}),
|
||||||
|
new TextRun(" and a footnote"),
|
||||||
|
new FootnoteReferenceRun(1),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
|
||||||
|
new Paragraph({ numbering: { reference: "bullets", level: 0 }, children: [new TextRun("bullet one")] }),
|
||||||
|
new Paragraph({ numbering: { reference: "bullets", level: 1 }, children: [new TextRun("nested bullet")] }),
|
||||||
|
new Paragraph({ numbering: { reference: "bullets", level: 0 }, children: [new TextRun("bullet two")] }),
|
||||||
|
|
||||||
|
new Paragraph({ numbering: { reference: "romans", level: 0 }, children: [new TextRun("roman one")] }),
|
||||||
|
new Paragraph({ numbering: { reference: "romans", level: 1 }, children: [new TextRun("lettered sub-item")] }),
|
||||||
|
new Paragraph({ numbering: { reference: "romans", level: 0 }, children: [new TextRun("roman two")] }),
|
||||||
|
|
||||||
|
new Paragraph({ style: "Quote", children: [new TextRun("A quotation, styled as Quote.")] }),
|
||||||
|
|
||||||
|
new Paragraph({
|
||||||
|
children: [new ImageRun({
|
||||||
|
data: png, type: "png",
|
||||||
|
transformation: { width: 96, height: 72 },
|
||||||
|
altText: { name: "red", description: "a red rectangle", title: "red" },
|
||||||
|
})],
|
||||||
|
}),
|
||||||
|
|
||||||
|
new Table({
|
||||||
|
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||||
|
borders: { top: border, bottom: border, left: border, right: border,
|
||||||
|
insideHorizontal: border, insideVertical: border },
|
||||||
|
rows: [
|
||||||
|
new TableRow({ children: [cell("head A"), cell("head B"), cell("head C")] }),
|
||||||
|
new TableRow({ children: [cell("spans two", { columnSpan: 2 }), cell("c2")] }),
|
||||||
|
new TableRow({ children: [cell("tall", { rowSpan: 2 }), cell("b3"), cell("c3")] }),
|
||||||
|
new TableRow({ children: [cell("b4"), cell("c4")] }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
new Paragraph({ children: [new TextRun("after the table")] }),
|
||||||
|
|
||||||
|
new Paragraph({
|
||||||
|
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: "808080", space: 1 } },
|
||||||
|
}),
|
||||||
|
new Paragraph({ children: [new TextRun("after the rule")] }),
|
||||||
|
|
||||||
|
new Paragraph({ children: [new PageBreak()] }),
|
||||||
|
new Paragraph({ children: [new TextRun("second page")] }),
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
return Packer.toBuffer(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && process.argv[1].endsWith("fixture.mjs")) {
|
||||||
|
const out = process.argv[2] ||
|
||||||
|
path.join(path.dirname(fileURLToPath(import.meta.url)), "fixture.docx");
|
||||||
|
const buf = await buildFixture();
|
||||||
|
writeFileSync(out, buf);
|
||||||
|
console.log(`wrote ${out} (${(buf.length / 1024).toFixed(1)} KB)`);
|
||||||
|
}
|
||||||
124
addon-build/docx-editor/test/harness.mjs
Normal file
124
addon-build/docx-editor/test/harness.mjs
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
// Loads the add-on's libraries the way the browser does, but under node, so
|
||||||
|
// the round-trip can be tested without driving a browser.
|
||||||
|
//
|
||||||
|
// The only difference from the real thing is where the vendored packages come
|
||||||
|
// from: the browser gets them out of vendor/docx-vendor.js, node gets them
|
||||||
|
// from node_modules — with mammoth taken from .patched/, the same patched copy
|
||||||
|
// that goes into the bundle.
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import path from "node:path";
|
||||||
|
import vm from "node:vm";
|
||||||
|
import { JSDOM } from "jsdom";
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
export const ADDON = path.resolve(here, "../../../bundled-addons/docx-editor");
|
||||||
|
|
||||||
|
export function loadAddonLibs() {
|
||||||
|
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
||||||
|
globalThis.window = dom.window;
|
||||||
|
globalThis.document = dom.window.document;
|
||||||
|
globalThis.DOMParser = dom.window.DOMParser;
|
||||||
|
globalThis.XMLSerializer = dom.window.XMLSerializer;
|
||||||
|
globalThis.Node = dom.window.Node;
|
||||||
|
|
||||||
|
globalThis.DOCXV = {
|
||||||
|
mammoth: require(path.resolve(here, "../.patched/mammoth")),
|
||||||
|
docx: require("docx"),
|
||||||
|
JSZip: require("jszip"),
|
||||||
|
pm: {
|
||||||
|
state: require("prosemirror-state"),
|
||||||
|
view: null, // not needed outside the browser
|
||||||
|
model: require("prosemirror-model"),
|
||||||
|
schemaBasic: require("prosemirror-schema-basic"),
|
||||||
|
schemaList: require("prosemirror-schema-list"),
|
||||||
|
tables: require("prosemirror-tables"),
|
||||||
|
history: require("prosemirror-history"),
|
||||||
|
commands: require("prosemirror-commands"),
|
||||||
|
keymap: require("prosemirror-keymap"),
|
||||||
|
inputrules: require("prosemirror-inputrules"),
|
||||||
|
dropcursor: null,
|
||||||
|
gapcursor: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
globalThis.DocxEditor = {};
|
||||||
|
|
||||||
|
for (const f of ["pkg.js", "schema.js", "read.js", "write.js"]) {
|
||||||
|
const src = readFileSync(path.join(ADDON, "lib", f), "utf8");
|
||||||
|
vm.runInThisContext(src, { filename: path.join(ADDON, "lib", f) });
|
||||||
|
}
|
||||||
|
return globalThis.DocxEditor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A compact, comparable view of a ProseMirror document: node types, the
|
||||||
|
// attributes that came from Word, and each text node's marks. Positions and
|
||||||
|
// ids are left out so the diff shows content drift and nothing else.
|
||||||
|
export function summarise(doc) {
|
||||||
|
const MEANINGFUL = ["level", "align", "indent", "lineHeight", "spaceBefore", "spaceAfter",
|
||||||
|
"format", "order", "colspan", "rowspan", "noteType", "noteId",
|
||||||
|
"alt", "width", "height"];
|
||||||
|
function attrs(node) {
|
||||||
|
const out = {};
|
||||||
|
for (const k of MEANINGFUL) {
|
||||||
|
const v = node.attrs && node.attrs[k];
|
||||||
|
if (v === undefined || v === null) continue;
|
||||||
|
if ((k === "colspan" || k === "rowspan" || k === "order") && v === 1) continue;
|
||||||
|
if (k === "indent" && v === 0) continue;
|
||||||
|
out[k] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function walk(node) {
|
||||||
|
if (node.isText) {
|
||||||
|
const marks = node.marks.map((m) => {
|
||||||
|
const a = Object.keys(m.attrs || {})
|
||||||
|
.filter((k) => m.attrs[k] !== null && m.attrs[k] !== undefined)
|
||||||
|
.sort()
|
||||||
|
.map((k) => `${k}=${m.attrs[k]}`)
|
||||||
|
.join(",");
|
||||||
|
return a ? `${m.type.name}(${a})` : m.type.name;
|
||||||
|
}).sort();
|
||||||
|
return { t: "text", text: node.text, marks };
|
||||||
|
}
|
||||||
|
const entry = { t: node.type.name };
|
||||||
|
const a = attrs(node);
|
||||||
|
if (Object.keys(a).length) entry.a = a;
|
||||||
|
if (node.type.name === "image") {
|
||||||
|
// Compare the bytes by length, not by the whole base64 blob.
|
||||||
|
entry.a = Object.assign(entry.a || {}, { srcLen: (node.attrs.src || "").length });
|
||||||
|
}
|
||||||
|
const kids = [];
|
||||||
|
node.forEach((child) => kids.push(walk(child)));
|
||||||
|
if (kids.length) entry.c = kids;
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
return walk(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function diff(a, b, pathStr = "doc", out = []) {
|
||||||
|
const ja = JSON.stringify(a), jb = JSON.stringify(b);
|
||||||
|
if (ja === jb) return out;
|
||||||
|
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) {
|
||||||
|
out.push(`${pathStr}: ${ja} -> ${jb}`);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (Array.isArray(a) !== Array.isArray(b)) {
|
||||||
|
out.push(`${pathStr}: shape changed`);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (Array.isArray(a)) {
|
||||||
|
if (a.length !== b.length) out.push(`${pathStr}: ${a.length} children -> ${b.length}`);
|
||||||
|
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||||
|
if (i >= a.length) { out.push(`${pathStr}[${i}]: added ${JSON.stringify(b[i]).slice(0, 120)}`); continue; }
|
||||||
|
if (i >= b.length) { out.push(`${pathStr}[${i}]: lost ${JSON.stringify(a[i]).slice(0, 120)}`); continue; }
|
||||||
|
diff(a[i], b[i], `${pathStr}[${i}]`, out);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) {
|
||||||
|
diff(a[k], b[k], `${pathStr}.${k}`, out);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
72
addon-build/docx-editor/test/probe.mjs
Normal file
72
addon-build/docx-editor/test/probe.mjs
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
// Ad-hoc probe for one document: prints structure around a given block
|
||||||
|
// index, before and after a round-trip, plus the raw vMerge / numId picture
|
||||||
|
// from both packages. Structure only — no document text beyond short labels.
|
||||||
|
//
|
||||||
|
// node test/probe.mjs <file.docx> [blockIndex]
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { loadAddonLibs, summarise } from "./harness.mjs";
|
||||||
|
|
||||||
|
const DocxEditor = loadAddonLibs();
|
||||||
|
const schema = DocxEditor.schema.build();
|
||||||
|
const file = process.argv[2];
|
||||||
|
const focus = process.argv[3] ? parseInt(process.argv[3], 10) : null;
|
||||||
|
const bytes = new Uint8Array(readFileSync(file));
|
||||||
|
|
||||||
|
const W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||||
|
|
||||||
|
async function numberingPicture(b, label) {
|
||||||
|
const zip = await DocxEditor.pkg.loadZip(b);
|
||||||
|
const doc = DocxEditor.pkg.parseXml(await zip.file("word/document.xml").async("string"));
|
||||||
|
const ps = doc.getElementsByTagNameNS(W, "p");
|
||||||
|
const seq = [];
|
||||||
|
for (let i = 0; i < ps.length; i++) {
|
||||||
|
const numPr = ps[i].getElementsByTagNameNS(W, "numPr")[0];
|
||||||
|
if (!numPr) { seq.push("."); continue; }
|
||||||
|
const numId = numPr.getElementsByTagNameNS(W, "numId")[0];
|
||||||
|
const ilvl = numPr.getElementsByTagNameNS(W, "ilvl")[0];
|
||||||
|
seq.push(`${numId ? numId.getAttributeNS(W, "val") : "?"}/${ilvl ? ilvl.getAttributeNS(W, "val") : "0"}`);
|
||||||
|
}
|
||||||
|
console.log(`${label} numbering (numId/level per paragraph, "." = not a list item):`);
|
||||||
|
console.log(" " + seq.join(" "));
|
||||||
|
|
||||||
|
const merges = [];
|
||||||
|
const rows = doc.getElementsByTagNameNS(W, "tr");
|
||||||
|
for (let r = 0; r < rows.length; r++) {
|
||||||
|
const cells = rows[r].getElementsByTagNameNS(W, "tc");
|
||||||
|
const marks = [];
|
||||||
|
for (let c = 0; c < cells.length; c++) {
|
||||||
|
const vm = cells[c].getElementsByTagNameNS(W, "vMerge")[0];
|
||||||
|
if (!vm) { marks.push("-"); continue; }
|
||||||
|
marks.push(vm.getAttributeNS(W, "val") === "restart" ? "R" : "c");
|
||||||
|
}
|
||||||
|
if (marks.includes("R") || marks.includes("c")) merges.push(`r${r}:${marks.join("")}`);
|
||||||
|
}
|
||||||
|
if (merges.length) console.log(`${label} vMerge: ${merges.slice(0, 20).join(" ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = await DocxEditor.read.docxToDoc(bytes, schema);
|
||||||
|
const saved = await DocxEditor.write.docToDocx(first.doc, {
|
||||||
|
originalBytes: bytes, setup: first.setup, meta: first.meta,
|
||||||
|
});
|
||||||
|
const second = await DocxEditor.read.docxToDoc(saved.bytes, schema);
|
||||||
|
|
||||||
|
console.log(`blocks: ${first.doc.childCount} -> ${second.doc.childCount}`);
|
||||||
|
if (saved.warnings.length) console.log(`warnings: ${saved.warnings.join(" | ")}`);
|
||||||
|
console.log();
|
||||||
|
await numberingPicture(bytes, "original");
|
||||||
|
console.log();
|
||||||
|
await numberingPicture(saved.bytes, "saved ");
|
||||||
|
|
||||||
|
if (focus !== null) {
|
||||||
|
const outline = (doc, from) => {
|
||||||
|
const lines = [];
|
||||||
|
for (let i = from; i < Math.min(doc.childCount, from + 8); i++) {
|
||||||
|
const n = doc.child(i);
|
||||||
|
lines.push(` [${i}] ${n.type.name}${n.childCount ? ` (${n.childCount} kids)` : ""} ` +
|
||||||
|
JSON.stringify(n.textContent.slice(0, 40)));
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
};
|
||||||
|
console.log(`\nbefore, from [${focus}]:\n` + outline(first.doc, focus));
|
||||||
|
console.log(`\nafter, from [${focus}]:\n` + outline(second.doc, focus));
|
||||||
|
}
|
||||||
95
addon-build/docx-editor/test/roundtrip.mjs
Normal file
95
addon-build/docx-editor/test/roundtrip.mjs
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
// The round-trip test: fixture.docx -> editor model -> .docx -> editor model,
|
||||||
|
// then diff the two models. Anything that shows up in the diff is something a
|
||||||
|
// user would lose by opening a document and pressing Save.
|
||||||
|
//
|
||||||
|
// node test/roundtrip.mjs [some-other.docx]
|
||||||
|
import { writeFileSync, readFileSync, existsSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import path from "node:path";
|
||||||
|
import { loadAddonLibs, summarise, diff } from "./harness.mjs";
|
||||||
|
import { buildFixture } from "./fixture.mjs";
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const DocxEditor = loadAddonLibs();
|
||||||
|
const schema = DocxEditor.schema.build();
|
||||||
|
|
||||||
|
const input = process.argv[2];
|
||||||
|
const original = input && existsSync(input)
|
||||||
|
? new Uint8Array(readFileSync(input))
|
||||||
|
: new Uint8Array(await buildFixture());
|
||||||
|
console.log(`input: ${input || "generated fixture"} (${(original.length / 1024).toFixed(1)} KB)\n`);
|
||||||
|
|
||||||
|
// --- pass 1: read ---------------------------------------------------------
|
||||||
|
const first = await DocxEditor.read.docxToDoc(original, schema);
|
||||||
|
console.log(`read: ${first.doc.childCount} top-level blocks`);
|
||||||
|
if (first.report.features.length) {
|
||||||
|
for (const f of first.report.features) {
|
||||||
|
console.log(` [${f.level}] ${f.label}${f.note ? " — " + f.note : ""}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (first.warnings.length) console.log(` warnings: ${first.warnings.join(", ")}`);
|
||||||
|
|
||||||
|
// --- pass 2: write --------------------------------------------------------
|
||||||
|
const saved = await DocxEditor.write.docToDocx(first.doc, {
|
||||||
|
originalBytes: original,
|
||||||
|
setup: first.setup,
|
||||||
|
meta: first.meta,
|
||||||
|
});
|
||||||
|
console.log(`\nwrote: ${(saved.bytes.length / 1024).toFixed(1)} KB`);
|
||||||
|
if (saved.carried.length) console.log(` carried over: ${saved.carried.join(", ")}`);
|
||||||
|
if (saved.warnings.length) console.log(` warnings: ${saved.warnings.join(", ")}`);
|
||||||
|
const outPath = path.join(here, "roundtrip-out.docx");
|
||||||
|
writeFileSync(outPath, Buffer.from(saved.bytes));
|
||||||
|
|
||||||
|
// --- pass 3: read back ----------------------------------------------------
|
||||||
|
const second = await DocxEditor.read.docxToDoc(saved.bytes, schema);
|
||||||
|
console.log(`re-read: ${second.doc.childCount} top-level blocks`);
|
||||||
|
|
||||||
|
// --- package sanity -------------------------------------------------------
|
||||||
|
const zip = await DocxEditor.pkg.loadZip(saved.bytes);
|
||||||
|
const names = Object.keys(zip.files).sort();
|
||||||
|
const required = ["[Content_Types].xml", "_rels/.rels", "word/document.xml",
|
||||||
|
"word/_rels/document.xml.rels", "word/styles.xml"];
|
||||||
|
const missing = required.filter((r) => !names.includes(r));
|
||||||
|
let xmlErrors = [];
|
||||||
|
for (const name of names) {
|
||||||
|
if (!name.endsWith(".xml") && !name.endsWith(".rels")) continue;
|
||||||
|
try { DocxEditor.pkg.parseXml(await zip.file(name).async("string")); }
|
||||||
|
catch (e) { xmlErrors.push(`${name}: ${e.message}`); }
|
||||||
|
}
|
||||||
|
// Every r:id the document references must exist in its rels part.
|
||||||
|
const relsDoc = DocxEditor.pkg.parseXml(await zip.file("word/_rels/document.xml.rels").async("string"));
|
||||||
|
const declared = new Set(Array.from(relsDoc.getElementsByTagName("*"))
|
||||||
|
.filter((e) => e.localName === "Relationship").map((e) => e.getAttribute("Id")));
|
||||||
|
const docXml = await zip.file("word/document.xml").async("string");
|
||||||
|
const referenced = [...docXml.matchAll(/r:(?:id|embed|link)="([^"]+)"/g)].map((m) => m[1]);
|
||||||
|
const danglingRels = [...new Set(referenced)].filter((id) => !declared.has(id));
|
||||||
|
// Every part declared in the rels must actually be in the package.
|
||||||
|
const danglingParts = Array.from(relsDoc.getElementsByTagName("*"))
|
||||||
|
.filter((e) => e.localName === "Relationship" && e.getAttribute("TargetMode") !== "External")
|
||||||
|
.map((e) => "word/" + (e.getAttribute("Target") || "").replace(/^\.\//, ""))
|
||||||
|
.filter((p) => !p.includes("://") && !names.includes(p));
|
||||||
|
|
||||||
|
console.log("\npackage sanity");
|
||||||
|
console.log(` parts: ${names.length}`);
|
||||||
|
console.log(` missing required: ${missing.length ? missing.join(", ") : "none"}`);
|
||||||
|
console.log(` malformed xml: ${xmlErrors.length ? xmlErrors.join("; ") : "none"}`);
|
||||||
|
console.log(` dangling r:ids: ${danglingRels.length ? danglingRels.join(", ") : "none"}`);
|
||||||
|
console.log(` rels pointing at missing parts: ${danglingParts.length ? danglingParts.join(", ") : "none"}`);
|
||||||
|
for (const want of ["word/header1.xml", "word/footer1.xml", "word/footnotes.xml"]) {
|
||||||
|
if (names.includes(want)) console.log(` kept ${want}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the diff -------------------------------------------------------------
|
||||||
|
const a = summarise(first.doc);
|
||||||
|
const b = summarise(second.doc);
|
||||||
|
const d = diff(a, b);
|
||||||
|
console.log(`\nround-trip diff: ${d.length ? d.length + " difference(s)" : "clean"}`);
|
||||||
|
for (const line of d.slice(0, 60)) console.log(" " + line);
|
||||||
|
if (d.length > 60) console.log(` … and ${d.length - 60} more`);
|
||||||
|
|
||||||
|
writeFileSync(path.join(here, "roundtrip-a.json"), JSON.stringify(a, null, 1));
|
||||||
|
writeFileSync(path.join(here, "roundtrip-b.json"), JSON.stringify(b, null, 1));
|
||||||
|
|
||||||
|
const fatal = missing.length || xmlErrors.length || danglingRels.length || danglingParts.length;
|
||||||
|
process.exit(fatal ? 2 : d.length ? 1 : 0);
|
||||||
30
addon-build/docx-editor/vendor-entry.js
Normal file
30
addon-build/docx-editor/vendor-entry.js
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
// Build-time entry. Everything the docx-editor add-on needs from npm gets
|
||||||
|
// pulled in here and re-exported on one global, so the shipped add-on can
|
||||||
|
// stay plain classic scripts (file:// pages can't load ES modules — Chromium
|
||||||
|
// blocks module fetches from the null origin).
|
||||||
|
import mammoth from "mammoth";
|
||||||
|
import * as docx from "docx";
|
||||||
|
import JSZip from "jszip";
|
||||||
|
|
||||||
|
import * as pmState from "prosemirror-state";
|
||||||
|
import * as pmView from "prosemirror-view";
|
||||||
|
import * as pmModel from "prosemirror-model";
|
||||||
|
import * as pmSchemaBasic from "prosemirror-schema-basic";
|
||||||
|
import * as pmSchemaList from "prosemirror-schema-list";
|
||||||
|
import * as pmTables from "prosemirror-tables";
|
||||||
|
import * as pmHistory from "prosemirror-history";
|
||||||
|
import * as pmCommands from "prosemirror-commands";
|
||||||
|
import * as pmKeymap from "prosemirror-keymap";
|
||||||
|
import * as pmInputRules from "prosemirror-inputrules";
|
||||||
|
import * as pmDropCursor from "prosemirror-dropcursor";
|
||||||
|
import * as pmGapCursor from "prosemirror-gapcursor";
|
||||||
|
|
||||||
|
window.DOCXV = {
|
||||||
|
mammoth, docx, JSZip,
|
||||||
|
pm: {
|
||||||
|
state: pmState, view: pmView, model: pmModel,
|
||||||
|
schemaBasic: pmSchemaBasic, schemaList: pmSchemaList, tables: pmTables,
|
||||||
|
history: pmHistory, commands: pmCommands, keymap: pmKeymap,
|
||||||
|
inputrules: pmInputRules, dropcursor: pmDropCursor, gapcursor: pmGapCursor,
|
||||||
|
},
|
||||||
|
};
|
||||||
121
bundled-addons/docx-editor/ROUND-TRIP.md
Normal file
121
bundled-addons/docx-editor/ROUND-TRIP.md
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
# What survives a round trip
|
||||||
|
|
||||||
|
The editor rebuilds a document's body from what you see on screen and carries
|
||||||
|
the rest of the original package across. This is the ledger of what that costs.
|
||||||
|
|
||||||
|
Measured over 66 real-world Word documents found on a working machine (CVs,
|
||||||
|
contracts, invoices, forms, letters — Greek, Russian, German and English):
|
||||||
|
|
||||||
|
clean round-trip : 65
|
||||||
|
content drift : 1 (a 7 MB WMF picture, see "Dropped")
|
||||||
|
invalid package : 0
|
||||||
|
threw : 0
|
||||||
|
|
||||||
|
"Clean" means: read the file, save it, read it again, and the two editor
|
||||||
|
documents are identical — same blocks, same attributes, same marks on the same
|
||||||
|
text. The saved package is also checked for well-formed XML, no dangling
|
||||||
|
relationship ids and no relationships pointing at parts that aren't there.
|
||||||
|
|
||||||
|
Reproduce with:
|
||||||
|
|
||||||
|
cd addon-build/docx-editor
|
||||||
|
npm install && npm run build
|
||||||
|
node test/roundtrip.mjs # the built-in fixture
|
||||||
|
node test/corpus.mjs <a folder of .docx> # a real corpus
|
||||||
|
|
||||||
|
## How the two halves work
|
||||||
|
|
||||||
|
Reading uses [mammoth](https://github.com/mwilliamson/mammoth.js), but not its
|
||||||
|
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. We take its
|
||||||
|
parsed *document model* instead, through the public `transformDocument` hook,
|
||||||
|
and walk that into the editor's model. See `addon-build/docx-editor/patches.mjs`
|
||||||
|
for the six properties we taught that model to carry: run colour, paragraph
|
||||||
|
spacing, paragraph bottom border, image display size, numbering format and
|
||||||
|
numbering id.
|
||||||
|
|
||||||
|
Writing uses [docx](https://www.npmjs.com/package/docx), which always builds a
|
||||||
|
brand-new package. Anything living outside the document body would therefore
|
||||||
|
vanish, so `lib/pkg.js` grafts it back: headers, footers, footnotes, endnotes,
|
||||||
|
the style catalogue, the theme and the page setup, re-wiring relationship ids
|
||||||
|
and content types as it goes.
|
||||||
|
|
||||||
|
## Kept
|
||||||
|
|
||||||
|
| | How |
|
||||||
|
|---|---|
|
||||||
|
| Headers and footers | The parts are copied across with their own relationships and images, and re-referenced from the new `sectPr`. |
|
||||||
|
| Footnotes and endnotes | The markers survive in the body as their own node; `footnotes.xml` is copied wholesale so the ids still match. |
|
||||||
|
| Page size, orientation, margins, gutter, title page | Read off the first `sectPr` and handed to the builder. |
|
||||||
|
| The document's styles | The original `styles.xml` is merged over the builder's. Where both define a style id the original wins — it is what the document actually looked like. `docDefaults` comes across too, so unstyled paragraphs don't shift. |
|
||||||
|
| Theme, fonts | `theme1.xml` is copied. |
|
||||||
|
| Title, author, subject, keywords | From `docProps/core.xml`. |
|
||||||
|
| Bold, italic, underline, strike, super/subscript, all-caps, small-caps | |
|
||||||
|
| Font family, size, colour, highlight | Highlight is Word's 15-value enum, not a hex colour, so it passes through exactly. |
|
||||||
|
| Alignment, indent, line spacing, space before/after | |
|
||||||
|
| Headings 1–6, quotes, code blocks | Code blocks ride on a `SourceCode` paragraph style. |
|
||||||
|
| Bulleted and numbered lists, nested, with their numbering format | Format is kept per level, so a list that is decimal at the top and lettered underneath stays that way. |
|
||||||
|
| Where one list ends and the next begins | Tracked by Word's `numId`, so a second list still restarts at 1. |
|
||||||
|
| Tables, including merged cells | Both directions. A 12-row vertical merge comes back as a 12-row vertical merge. |
|
||||||
|
| Images | At the size Word was displaying them, to EMU precision, not the file's natural size. |
|
||||||
|
| Links, internal anchors | |
|
||||||
|
| Page breaks, horizontal rules | A rule is Word's empty paragraph with a bottom border, and is written back as one. |
|
||||||
|
|
||||||
|
## Dropped
|
||||||
|
|
||||||
|
These are detected when the file opens and named in a banner before any
|
||||||
|
editing, and again in the About dialog. The original file on disk is never
|
||||||
|
overwritten — a save downloads `<name>-edited.docx`.
|
||||||
|
|
||||||
|
- **Tracked changes.** mammoth renders insertions as ordinary text and drops
|
||||||
|
deletions, so a save would silently accept every pending revision. A document
|
||||||
|
with them opens read-only until you explicitly choose "Accept all and edit".
|
||||||
|
- **Comments.** Same gate as tracked changes.
|
||||||
|
- **Equations** (OMML), **shapes, text boxes and WordArt**, **content controls**.
|
||||||
|
- **Fields** — page numbers, tables of contents, cross-references. The text Word
|
||||||
|
last calculated is kept; the field code that would recalculate it is not.
|
||||||
|
- **Bookmarks.**
|
||||||
|
- **Section breaks and multi-column layout.** Only the first section's page
|
||||||
|
setup is kept.
|
||||||
|
- **Metafile pictures (WMF/EMF).** Word's vector picture format: browsers can't
|
||||||
|
display it and the builder can't write it. This is the single drift in the
|
||||||
|
corpus above — one CV with a 7 MB WMF.
|
||||||
|
|
||||||
|
## Kept, but not exactly
|
||||||
|
|
||||||
|
- **Paragraph borders and shading.** Only the rule under an empty paragraph
|
||||||
|
round-trips. A box around a paragraph, or a shaded paragraph, is lost.
|
||||||
|
- **Custom tab stops.** Tab characters are kept; the stop positions are not.
|
||||||
|
- **Exact line spacing.** `atLeast` and `exact` line rules are read but the
|
||||||
|
editor has no control for them, so they are written back as-is only when the
|
||||||
|
paragraph is untouched.
|
||||||
|
- **Table borders.** mammoth doesn't report the borders it read, so every table
|
||||||
|
is written with a plain single-line border. A borderless table gains lines.
|
||||||
|
- **List indentation depth.** A list Word started at level 2 with no level 0 or
|
||||||
|
1 above it becomes a top-level list, and is written at level 0.
|
||||||
|
- **An empty paragraph after a table inside a cell.** OOXML forbids a cell that
|
||||||
|
ends with a table, so every such document carries a paragraph the author
|
||||||
|
never typed. It is dropped on read and put back on write.
|
||||||
|
|
||||||
|
## Decisions worth knowing about
|
||||||
|
|
||||||
|
**Why not preserve the dropped features as raw XML?** `docx` can embed raw
|
||||||
|
OOXML (`ImportedXmlComponent`), so it is technically possible. What makes it
|
||||||
|
expensive is position: mammoth silently discards the elements it can't model
|
||||||
|
and reports no location for them, so anchoring a passthrough node in the right
|
||||||
|
place needs a second OOXML reader running alongside mammoth purely to recover
|
||||||
|
block order. That is a large amount of machinery whose failure mode is a subtly
|
||||||
|
corrupt package, which is worse than an honest warning. The grafting approach
|
||||||
|
gets headers, footers, notes, page setup and styles — the things most real
|
||||||
|
documents actually have — without that risk.
|
||||||
|
|
||||||
|
**Why patch mammoth instead of using it as shipped?** Colour, line spacing and
|
||||||
|
numbering format are all editable in this editor's ribbon. Shipping without the
|
||||||
|
patches would mean the editor shows a control for something it silently eats on
|
||||||
|
the next save. The patches are six string replacements applied at build time
|
||||||
|
and each one asserts its anchor, so a mammoth upgrade that moves the code fails
|
||||||
|
the build instead of quietly shipping a lossy reader.
|
||||||
|
|
||||||
|
**Why is the original file never overwritten?** Because of everything on the
|
||||||
|
"Dropped" list. A save is a download of a new file, so the original is always
|
||||||
|
still there to fall back on.
|
||||||
11
bundled-addons/docx-editor/addon.json
Normal file
11
bundled-addons/docx-editor/addon.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"id": "docx-editor",
|
||||||
|
"name": "Word editor",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Open, edit and save Word documents (.docx) in a full Theseus tab. Ribbon-style formatting, tables, lists, images and links; headers, footers, footnotes, page setup and the document's own styles are carried through a save untouched.",
|
||||||
|
"author": "Silent Mode",
|
||||||
|
"icon": "📝",
|
||||||
|
"main": "index.js",
|
||||||
|
"capabilities": ["sidebar-panel", "open-tab"],
|
||||||
|
"updateURL": "https://navigate.st/bns/theseus.x/extensions/docx-editor/updates.json"
|
||||||
|
}
|
||||||
234
bundled-addons/docx-editor/editor.css
Normal file
234
bundled-addons/docx-editor/editor.css
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
/* Word editor — full-tab. Shares the screenshot editor's shell (same
|
||||||
|
variables, same topbar + toolbar + footer skeleton) with a ribbon-ish
|
||||||
|
toolbar over a continuous document surface.
|
||||||
|
|
||||||
|
"Ribbon-ish" means grouped button sets with labelled groups, not a real
|
||||||
|
ribbon widget: no tabs, no gallery, no contextual tab strip. It reads as
|
||||||
|
Office without pretending to be it. */
|
||||||
|
:root { color-scheme: light dark;
|
||||||
|
--bg:#0e131c; --panel:#141a24; --panel2:#191f2b; --line:rgba(255,255,255,.09);
|
||||||
|
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d;
|
||||||
|
--danger:#ff5b5b; --warn:#ffb648; --board:#0a0d13;
|
||||||
|
--paper:#ffffff; --paper-ink:#14161a; --paper-edge:rgba(0,0,0,.4); }
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root { --bg:#f8faff; --panel:#ffffff; --panel2:#eff3fb; --line:rgba(0,0,0,.10);
|
||||||
|
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; --acid:#0AC18E;
|
||||||
|
--board:#dde3ee; --paper-edge:rgba(0,0,0,.18); }
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; height: 100%; }
|
||||||
|
body { background: var(--bg); color: var(--ink);
|
||||||
|
font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
|
||||||
|
/* ---- top bar --------------------------------------------------------- */
|
||||||
|
.topbar { display: flex; align-items: center; gap: 4px; padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid var(--line); background: var(--panel);
|
||||||
|
user-select: none; flex-wrap: nowrap; }
|
||||||
|
.topbar .spacer { flex: 1; }
|
||||||
|
.topbar .docname { color: var(--ink); font-size: 12.5px; font-weight: 600;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
max-width: 42vw; margin: 0 6px; }
|
||||||
|
.topbar .docname .dirty { color: var(--acid); margin-left: 4px; }
|
||||||
|
|
||||||
|
/* ---- ribbon ---------------------------------------------------------- */
|
||||||
|
.ribbon { display: flex; align-items: stretch; gap: 0;
|
||||||
|
padding: 4px 6px 2px; border-bottom: 1px solid var(--line);
|
||||||
|
background: linear-gradient(180deg, var(--panel), var(--panel2));
|
||||||
|
user-select: none; overflow-x: auto; overflow-y: hidden; }
|
||||||
|
.rgroup { display: flex; flex-direction: column; align-items: center;
|
||||||
|
gap: 3px; padding: 0 8px; flex: 0 0 auto;
|
||||||
|
border-right: 1px solid var(--line); }
|
||||||
|
.rgroup:last-child { border-right: 0; }
|
||||||
|
.rgroup .rrow { display: flex; align-items: center; gap: 3px; flex-wrap: nowrap; }
|
||||||
|
.rgroup .rlabel { font-size: 10px; color: var(--dim); letter-spacing: .02em;
|
||||||
|
text-transform: lowercase; }
|
||||||
|
.rgroup[data-contextual] { opacity: .45; pointer-events: none; }
|
||||||
|
.rgroup[data-contextual="on"] { opacity: 1; pointer-events: auto; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border: 1px solid transparent; background: transparent; color: var(--ink);
|
||||||
|
min-width: 26px; height: 26px; border-radius: 5px; cursor: pointer;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
padding: 0 4px; font: inherit; line-height: 0;
|
||||||
|
transition: background 90ms, border-color 90ms;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) { background: var(--panel2); border-color: var(--line); }
|
||||||
|
.btn.on { background: rgb(from var(--acid) r g b / .16);
|
||||||
|
border-color: rgb(from var(--acid) r g b / .55); color: var(--acid); }
|
||||||
|
.btn:disabled { opacity: .35; cursor: default; }
|
||||||
|
.btn svg { width: 15px; height: 15px; display: block; }
|
||||||
|
.btn.wide { min-width: auto; padding: 0 8px; gap: 5px; line-height: 1; }
|
||||||
|
.btn.wide span { font-size: 12px; }
|
||||||
|
.btn.primary { background: var(--acid); color: #101418; border-color: transparent; font-weight: 600; }
|
||||||
|
.btn.primary:hover:not(:disabled) { filter: brightness(1.06); background: var(--acid); }
|
||||||
|
.btn.danger:hover:not(:disabled) { border-color: rgba(255,91,91,.55); color: var(--danger); }
|
||||||
|
.btn .caret { width: 8px; height: 8px; opacity: .6; }
|
||||||
|
|
||||||
|
select.rsel, input.rnum {
|
||||||
|
height: 26px; border-radius: 5px; border: 1px solid var(--line);
|
||||||
|
background: var(--panel2); color: var(--ink); font: inherit; font-size: 12px;
|
||||||
|
padding: 0 4px; cursor: pointer; max-width: 150px;
|
||||||
|
}
|
||||||
|
select.rsel:focus, input.rnum:focus { outline: 1px solid rgb(from var(--acid) r g b / .5); }
|
||||||
|
input.rnum { width: 52px; cursor: text; text-align: center; }
|
||||||
|
.swatch-btn { position: relative; }
|
||||||
|
.swatch-btn .bar { position: absolute; left: 4px; right: 4px; bottom: 3px;
|
||||||
|
height: 3px; border-radius: 1px; background: #c00; }
|
||||||
|
|
||||||
|
/* colour / highlight popovers */
|
||||||
|
.pop { position: absolute; z-index: 60; background: var(--panel);
|
||||||
|
border: 1px solid var(--line); border-radius: 8px; padding: 8px;
|
||||||
|
box-shadow: 0 10px 28px rgba(0,0,0,.35); }
|
||||||
|
.pop .grid { display: grid; grid-template-columns: repeat(8, 20px); gap: 4px; }
|
||||||
|
.pop .chip { width: 20px; height: 20px; border-radius: 4px; cursor: pointer;
|
||||||
|
border: 1px solid var(--line); padding: 0; }
|
||||||
|
.pop .chip:hover { outline: 2px solid rgb(from var(--acid) r g b / .6); }
|
||||||
|
.pop .prow { display: flex; align-items: center; gap: 6px; margin-top: 8px; }
|
||||||
|
.pop .prow .btn { height: 24px; }
|
||||||
|
|
||||||
|
/* ---- banners --------------------------------------------------------- */
|
||||||
|
.banners { background: var(--board); }
|
||||||
|
.banner { display: flex; align-items: flex-start; gap: 10px;
|
||||||
|
padding: 9px 14px; font-size: 12.5px; line-height: 1.45;
|
||||||
|
border-bottom: 1px solid var(--line); background: var(--panel); }
|
||||||
|
.banner .ico { flex: 0 0 auto; font-size: 14px; line-height: 1.3; }
|
||||||
|
.banner .body { flex: 1 1 auto; min-width: 0; }
|
||||||
|
.banner .body b { font-weight: 600; }
|
||||||
|
.banner .acts { flex: 0 0 auto; display: flex; gap: 6px; }
|
||||||
|
.banner.warn { border-left: 3px solid var(--warn); }
|
||||||
|
.banner.block { border-left: 3px solid var(--danger); }
|
||||||
|
.banner.info { border-left: 3px solid var(--acid); }
|
||||||
|
.banner .btn { border-color: var(--line); background: var(--panel2); }
|
||||||
|
|
||||||
|
/* ---- document surface ------------------------------------------------ */
|
||||||
|
.board { flex: 1; overflow: auto; background: var(--board); padding: 20px 16px 60px; }
|
||||||
|
.sheet { max-width: 8.27in; margin: 0 auto; background: var(--paper);
|
||||||
|
color: var(--paper-ink); box-shadow: 0 2px 18px var(--paper-edge);
|
||||||
|
padding: 0.9in 1in; min-height: 60vh; }
|
||||||
|
.sheet:focus { outline: none; }
|
||||||
|
.sheet .ProseMirror { outline: none; min-height: 50vh; }
|
||||||
|
|
||||||
|
/* Word's own defaults are a serif body at 11pt with a little space after
|
||||||
|
each paragraph; matching them means what the editor shows is roughly
|
||||||
|
what Word will show. */
|
||||||
|
.sheet {
|
||||||
|
font: 11pt/1.5 Georgia, "Times New Roman", serif;
|
||||||
|
}
|
||||||
|
.sheet p { margin: 0 0 8pt; }
|
||||||
|
.sheet h1, .sheet h2, .sheet h3, .sheet h4, .sheet h5, .sheet h6 {
|
||||||
|
font-family: "Segoe UI Semibold", "Segoe UI", Calibri, system-ui, sans-serif;
|
||||||
|
color: #1f4e79; font-weight: 600; margin: 14pt 0 6pt; line-height: 1.25;
|
||||||
|
}
|
||||||
|
.sheet h1 { font-size: 20pt; } .sheet h2 { font-size: 16pt; }
|
||||||
|
.sheet h3 { font-size: 13pt; } .sheet h4 { font-size: 12pt; }
|
||||||
|
.sheet h5 { font-size: 11pt; } .sheet h6 { font-size: 10.5pt; color: #2e74b5; }
|
||||||
|
.sheet blockquote { margin: 8pt 0 8pt 24pt; padding-left: 10pt;
|
||||||
|
border-left: 3px solid rgba(0,0,0,.15); color: #404040; font-style: italic; }
|
||||||
|
.sheet pre { font: 10pt/1.4 Consolas, "Courier New", monospace;
|
||||||
|
background: rgba(0,0,0,.04); border: 1px solid rgba(0,0,0,.08);
|
||||||
|
border-radius: 3px; padding: 8pt 10pt; margin: 8pt 0; white-space: pre-wrap; }
|
||||||
|
.sheet hr { border: 0; border-top: 1px solid #808080; margin: 10pt 0; }
|
||||||
|
.sheet ul, .sheet ol { margin: 0 0 8pt; padding-left: 28pt; }
|
||||||
|
.sheet li { margin: 0 0 2pt; }
|
||||||
|
.sheet li > p { margin: 0 0 2pt; }
|
||||||
|
.sheet a { color: #0563c1; text-decoration: underline; }
|
||||||
|
.sheet img { max-width: 100%; height: auto; vertical-align: baseline; }
|
||||||
|
.sheet table { border-collapse: collapse; margin: 8pt 0; width: 100%; table-layout: fixed; }
|
||||||
|
.sheet td, .sheet th { border: 1px solid #999; padding: 4pt 6pt; vertical-align: top;
|
||||||
|
position: relative; min-width: 1em; }
|
||||||
|
.sheet th { background: rgba(0,0,0,.04); font-weight: 600; text-align: left; }
|
||||||
|
.sheet td > p:last-child, .sheet th > p:last-child { margin-bottom: 0; }
|
||||||
|
.sheet .docx-page-break {
|
||||||
|
border-top: 1px dashed #b00; margin: 14pt 0; text-align: center;
|
||||||
|
user-select: none; position: relative;
|
||||||
|
}
|
||||||
|
.sheet .docx-page-break span {
|
||||||
|
font: 9pt/1 system-ui, sans-serif; color: #b00; background: var(--paper);
|
||||||
|
padding: 0 8px; position: relative; top: -6pt; letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
.sheet .docx-note-ref {
|
||||||
|
color: #0563c1; font-size: .7em; padding: 0 1px; cursor: default;
|
||||||
|
border-bottom: 1px dotted #0563c1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* prosemirror-tables' own furniture */
|
||||||
|
.sheet .selectedCell:after {
|
||||||
|
content: ""; position: absolute; inset: 0; background: rgba(100,150,255,.25);
|
||||||
|
pointer-events: none; z-index: 2;
|
||||||
|
}
|
||||||
|
.sheet .column-resize-handle {
|
||||||
|
position: absolute; right: -2px; top: 0; bottom: 0; width: 4px;
|
||||||
|
background: #6ba0ff; pointer-events: none; z-index: 3;
|
||||||
|
}
|
||||||
|
.resize-cursor { cursor: col-resize; }
|
||||||
|
.ProseMirror-gapcursor { display: none; pointer-events: none; position: absolute; }
|
||||||
|
.ProseMirror-gapcursor:after {
|
||||||
|
content: ""; display: block; position: absolute; top: -2px;
|
||||||
|
width: 20px; border-top: 1px solid var(--paper-ink); animation: pm-blink 1.1s steps(2, start) infinite;
|
||||||
|
}
|
||||||
|
.ProseMirror-focused .ProseMirror-gapcursor { display: block; }
|
||||||
|
@keyframes pm-blink { to { visibility: hidden; } }
|
||||||
|
.ProseMirror-selectednode { outline: 2px solid #6ba0ff; }
|
||||||
|
|
||||||
|
.empty-state { color: var(--dim); text-align: center; padding: 60px 20px; }
|
||||||
|
|
||||||
|
/* ---- footer ---------------------------------------------------------- */
|
||||||
|
.footer { display: flex; align-items: center; gap: 14px;
|
||||||
|
padding: 5px 12px; border-top: 1px solid var(--line);
|
||||||
|
background: var(--panel); font-size: 11.5px; color: var(--dim); }
|
||||||
|
.footer .stat { flex: 0 0 auto; white-space: nowrap; }
|
||||||
|
.footer .msg { flex: 1 1 auto; min-width: 0; overflow: hidden;
|
||||||
|
text-overflow: ellipsis; white-space: nowrap; text-align: right; }
|
||||||
|
.footer .msg.err { color: var(--danger); }
|
||||||
|
.footer .msg.ok { color: var(--acid); }
|
||||||
|
.footer .fbtn { border: 1px solid var(--line); background: var(--panel2);
|
||||||
|
color: var(--ink); border-radius: 5px; cursor: pointer;
|
||||||
|
padding: 3px 7px; font: inherit; font-size: 11px; flex: 0 0 auto; }
|
||||||
|
.footer .fbtn:hover { border-color: rgb(from var(--acid) r g b / .55); }
|
||||||
|
|
||||||
|
/* ---- dialogs --------------------------------------------------------- */
|
||||||
|
.scrim { position: fixed; inset: 0; background: rgba(4,7,12,.6); z-index: 80;
|
||||||
|
display: flex; align-items: center; justify-content: center; padding: 24px; }
|
||||||
|
.dialog { background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
|
||||||
|
box-shadow: 0 18px 50px rgba(0,0,0,.45); width: min(560px, 100%);
|
||||||
|
max-height: 80vh; display: flex; flex-direction: column; }
|
||||||
|
.dialog h2 { margin: 0; padding: 14px 18px 10px; font-size: 14.5px; font-weight: 600; }
|
||||||
|
.dialog .dbody { padding: 0 18px 4px; overflow: auto; font-size: 12.5px; line-height: 1.55; color: var(--mut); }
|
||||||
|
.dialog .dbody h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em;
|
||||||
|
color: var(--dim); margin: 14px 0 6px; font-weight: 600; }
|
||||||
|
.dialog .dbody ul { margin: 0 0 8px; padding-left: 18px; }
|
||||||
|
.dialog .dbody li { margin: 2px 0; }
|
||||||
|
.dialog .dbody code { font-family: Consolas, monospace; font-size: 11.5px; color: var(--ink); }
|
||||||
|
.dialog .dfoot { display: flex; gap: 8px; justify-content: flex-end;
|
||||||
|
padding: 12px 18px 14px; border-top: 1px solid var(--line); margin-top: 10px; }
|
||||||
|
.dialog .field { display: flex; flex-direction: column; gap: 4px; margin: 10px 0; }
|
||||||
|
.dialog .field label { font-size: 11.5px; color: var(--dim); }
|
||||||
|
.dialog .field input {
|
||||||
|
height: 30px; border-radius: 6px; border: 1px solid var(--line);
|
||||||
|
background: var(--panel2); color: var(--ink); font: inherit; padding: 0 8px;
|
||||||
|
}
|
||||||
|
.dialog .field input:focus { outline: 1px solid rgb(from var(--acid) r g b / .5); }
|
||||||
|
.dialog .row { display: flex; gap: 12px; }
|
||||||
|
.dialog .row .field { flex: 1; }
|
||||||
|
.pill { display: inline-block; font-size: 10.5px; padding: 1px 6px; border-radius: 99px;
|
||||||
|
border: 1px solid var(--line); margin-right: 6px; }
|
||||||
|
.pill.keep { color: var(--acid); border-color: rgb(from var(--acid) r g b / .45); }
|
||||||
|
.pill.lose { color: var(--warn); border-color: rgba(255,182,72,.45); }
|
||||||
|
.pill.stop { color: var(--danger); border-color: rgba(255,91,91,.45); }
|
||||||
|
|
||||||
|
/* ---- drop target ----------------------------------------------------- */
|
||||||
|
.dropzone { position: fixed; inset: 0; z-index: 90; background: rgba(10,14,20,.82);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
border: 3px dashed var(--acid); font-size: 16px; color: var(--ink); }
|
||||||
|
|
||||||
|
/* ---- print ----------------------------------------------------------- */
|
||||||
|
@media print {
|
||||||
|
.topbar, .ribbon, .footer, .banners, .pop, .scrim, .dropzone { display: none !important; }
|
||||||
|
html, body { height: auto; overflow: visible; background: #fff; }
|
||||||
|
.board { overflow: visible; padding: 0; background: #fff; }
|
||||||
|
.sheet { box-shadow: none; max-width: none; margin: 0; padding: 0; background: #fff; color: #000; }
|
||||||
|
.sheet .docx-page-break { border: 0; margin: 0; break-after: page; page-break-after: always; }
|
||||||
|
.sheet .docx-page-break span { display: none; }
|
||||||
|
}
|
||||||
210
bundled-addons/docx-editor/editor.html
Normal file
210
bundled-addons/docx-editor/editor.html
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Word editor</title>
|
||||||
|
<link rel="stylesheet" href="editor.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="topbar">
|
||||||
|
<button class="btn wide" id="file-new" title="Start a blank document">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><path d="M4 1.5h5l3 3V14.5H4z"/><path d="M9 1.5v3h3"/></svg>
|
||||||
|
<span>New</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn wide" id="file-open" title="Open a .docx file (Ctrl+O)">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><path d="M1.5 4h4l1.5 2h7.5v7.5h-13z"/><path d="M1.5 4V2.5h5"/></svg>
|
||||||
|
<span>Open</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn wide primary" id="file-save" title="Save as .docx (Ctrl+S)">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><path d="M8 2v8M4.5 6.5L8 10l3.5-3.5M2.5 13.5h11"/></svg>
|
||||||
|
<span>Save</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn" id="file-print" title="Print (Ctrl+P)">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><path d="M4.5 6V2.5h7V6"/><rect x="2.5" y="6" width="11" height="5"/><path d="M4.5 11v2.5h7V11"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class="docname" id="docname">Untitled document</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
|
||||||
|
<button class="btn" id="about" title="About this editor, and what it can't do yet">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><circle cx="8" cy="8" r="6.2"/><path d="M8 7.2v4M8 4.9v.9" stroke-linecap="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn danger" id="discard" title="Close this tab">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ribbon. Grouped button sets with a caption under each group — the
|
||||||
|
Office 2007 shape without the tab strip, which would hide half the
|
||||||
|
controls behind a click for no gain at this feature count. -->
|
||||||
|
<div class="ribbon" id="ribbon">
|
||||||
|
|
||||||
|
<div class="rgroup">
|
||||||
|
<div class="rrow">
|
||||||
|
<button class="btn" id="undo" title="Undo (Ctrl+Z)" disabled>
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 8c0-3 2-5 5-5s5 2 5 5-2 5-5 5"/><path d="M6 5L3 8l3 3"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn" id="redo" title="Redo (Ctrl+Y)" disabled>
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M13 8c0-3-2-5-5-5S3 5 3 8s2 5 5 5"/><path d="M10 5l3 3-3 3"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="rlabel">undo</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rgroup">
|
||||||
|
<div class="rrow">
|
||||||
|
<select class="rsel" id="style-select" title="Paragraph style">
|
||||||
|
<option value="paragraph">Normal</option>
|
||||||
|
<option value="h1">Heading 1</option>
|
||||||
|
<option value="h2">Heading 2</option>
|
||||||
|
<option value="h3">Heading 3</option>
|
||||||
|
<option value="h4">Heading 4</option>
|
||||||
|
<option value="h5">Heading 5</option>
|
||||||
|
<option value="h6">Heading 6</option>
|
||||||
|
<option value="blockquote">Quote</option>
|
||||||
|
<option value="code_block">Code</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="rlabel">style</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rgroup">
|
||||||
|
<div class="rrow">
|
||||||
|
<select class="rsel" id="font-family" title="Font">
|
||||||
|
<option value="">(document font)</option>
|
||||||
|
</select>
|
||||||
|
<input class="rnum" id="font-size" type="number" min="4" max="400" step="0.5" title="Size in points" placeholder="11">
|
||||||
|
</div>
|
||||||
|
<div class="rrow">
|
||||||
|
<button class="btn" id="m-strong" title="Bold (Ctrl+B)"><b style="font:600 13px/1 Georgia,serif">B</b></button>
|
||||||
|
<button class="btn" id="m-em" title="Italic (Ctrl+I)"><i style="font:italic 13px/1 Georgia,serif">I</i></button>
|
||||||
|
<button class="btn" id="m-underline" title="Underline (Ctrl+U)"><span style="font:13px/1 Georgia,serif;text-decoration:underline">U</span></button>
|
||||||
|
<button class="btn" id="m-strike" title="Strikethrough"><span style="font:13px/1 Georgia,serif;text-decoration:line-through">S</span></button>
|
||||||
|
<button class="btn" id="m-sup" title="Superscript"><span style="font:12px/1 Georgia,serif">x<sup>2</sup></span></button>
|
||||||
|
<button class="btn" id="m-sub" title="Subscript"><span style="font:12px/1 Georgia,serif">x<sub>2</sub></span></button>
|
||||||
|
<button class="btn swatch-btn" id="m-color" title="Text colour">
|
||||||
|
<span style="font:600 12px/1 Georgia,serif">A</span><span class="bar" id="color-bar"></span>
|
||||||
|
</button>
|
||||||
|
<button class="btn swatch-btn" id="m-highlight" title="Highlight">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><path d="M3 11l5-5 2 2-5 5H3z"/><path d="M9 5l2-2 2 2-2 2z"/></svg>
|
||||||
|
<span class="bar" id="hl-bar" style="background:#ffff00"></span>
|
||||||
|
</button>
|
||||||
|
<button class="btn" id="m-clear" title="Clear formatting">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><path d="M4 3h8M7 3l-2 9M9.5 9.5l3.5 3.5M13 9.5L9.5 13"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="rlabel">font</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rgroup">
|
||||||
|
<div class="rrow">
|
||||||
|
<button class="btn" id="a-left" title="Align left">
|
||||||
|
<svg viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><path d="M2 4h12M2 7h8M2 10h12M2 13h7"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn" id="a-center" title="Centre">
|
||||||
|
<svg viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><path d="M2 4h12M4 7h8M2 10h12M4.5 13h7"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn" id="a-right" title="Align right">
|
||||||
|
<svg viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><path d="M2 4h12M6 7h8M2 10h12M7 13h7"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn" id="a-justify" title="Justify">
|
||||||
|
<svg viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><path d="M2 4h12M2 7h12M2 10h12M2 13h12"/></svg>
|
||||||
|
</button>
|
||||||
|
<select class="rsel" id="line-height" title="Line spacing">
|
||||||
|
<option value="">Spacing</option>
|
||||||
|
<option value="1">Single</option>
|
||||||
|
<option value="1.15">1.15</option>
|
||||||
|
<option value="1.5">1.5</option>
|
||||||
|
<option value="2">Double</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="rrow">
|
||||||
|
<button class="btn" id="indent-out" title="Decrease indent">
|
||||||
|
<svg viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" fill="none"><path d="M7 4h7M7 8h7M7 12h7M2 4v8M5 6L3 8l2 2"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn" id="indent-in" title="Increase indent">
|
||||||
|
<svg viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" fill="none"><path d="M7 4h7M7 8h7M7 12h7M2 4v8M3 6l2 2-2 2"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn" id="l-bullet" title="Bulleted list">
|
||||||
|
<svg viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><path d="M6 4h8M6 8h8M6 12h8"/><circle cx="3" cy="4" r="1" fill="currentColor" stroke="none"/><circle cx="3" cy="8" r="1" fill="currentColor" stroke="none"/><circle cx="3" cy="12" r="1" fill="currentColor" stroke="none"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn" id="l-ordered" title="Numbered list">
|
||||||
|
<svg viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" fill="none"><path d="M6 4h8M6 8h8M6 12h8"/><text x="1" y="5.5" font-size="5" fill="currentColor" stroke="none">1</text><text x="1" y="9.5" font-size="5" fill="currentColor" stroke="none">2</text><text x="1" y="13.5" font-size="5" fill="currentColor" stroke="none">3</text></svg>
|
||||||
|
</button>
|
||||||
|
<select class="rsel" id="list-format" title="Numbering format" style="max-width:96px">
|
||||||
|
<option value="decimal">1. 2. 3.</option>
|
||||||
|
<option value="lowerLetter">a. b. c.</option>
|
||||||
|
<option value="upperLetter">A. B. C.</option>
|
||||||
|
<option value="lowerRoman">i. ii. iii.</option>
|
||||||
|
<option value="upperRoman">I. II. III.</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="rlabel">paragraph</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rgroup">
|
||||||
|
<div class="rrow">
|
||||||
|
<button class="btn" id="i-link" title="Link (Ctrl+K)">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><path d="M6.5 9.5l3-3M7 4.5l1.5-1.5a2.5 2.5 0 013.5 3.5L10.5 8"/><path d="M9 11.5L7.5 13a2.5 2.5 0 01-3.5-3.5L5.5 8"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn" id="i-image" title="Insert a picture">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><rect x="2" y="3" width="12" height="10" rx="1"/><circle cx="5.7" cy="6.3" r="1.1"/><path d="M2.6 11.6L6 8.6l2.3 2 2.2-2.2 2.9 3"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn" id="i-table" title="Insert a table">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="2" y="3" width="12" height="10"/><path d="M2 6.3h12M2 9.7h12M6 3v10M10 3v10"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn" id="i-rule" title="Horizontal rule">
|
||||||
|
<svg viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><path d="M2 8h12"/><path d="M4 4.5h8M4 11.5h8" opacity=".35"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn" id="i-pagebreak" title="Page break (Ctrl+Enter)">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M4 2h8M4 14h8M2 8h12" stroke-dasharray="2 1.6"/><path d="M4 2v3M12 2v3M4 14v-3M12 14v-3"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="rlabel">insert</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rgroup" id="table-group" data-contextual="off">
|
||||||
|
<div class="rrow">
|
||||||
|
<button class="btn wide" id="t-row-after" title="Insert a row below"><span>+ Row</span></button>
|
||||||
|
<button class="btn wide" id="t-col-after" title="Insert a column to the right"><span>+ Col</span></button>
|
||||||
|
<button class="btn wide" id="t-merge" title="Merge the selected cells"><span>Merge</span></button>
|
||||||
|
<button class="btn wide" id="t-split" title="Split the selected cell"><span>Split</span></button>
|
||||||
|
</div>
|
||||||
|
<div class="rrow">
|
||||||
|
<button class="btn wide" id="t-row-del" title="Delete this row"><span>− Row</span></button>
|
||||||
|
<button class="btn wide" id="t-col-del" title="Delete this column"><span>− Col</span></button>
|
||||||
|
<button class="btn wide" id="t-header" title="Toggle the header row"><span>Header</span></button>
|
||||||
|
<button class="btn wide danger" id="t-del" title="Delete the whole table"><span>Delete</span></button>
|
||||||
|
</div>
|
||||||
|
<div class="rlabel">table</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="banners" id="banners"></div>
|
||||||
|
|
||||||
|
<div class="board" id="board">
|
||||||
|
<div class="sheet" id="sheet">
|
||||||
|
<div class="empty-state" id="empty">Loading document…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<span class="stat" id="stat-words">0 words</span>
|
||||||
|
<span class="stat" id="stat-pages">1 page</span>
|
||||||
|
<span class="stat"><button class="fbtn" id="open-folder">Open folder</button></span>
|
||||||
|
<span class="msg" id="msg"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="file" id="file-input" accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document" hidden>
|
||||||
|
<input type="file" id="image-input" accept="image/png,image/jpeg,image/gif,image/bmp" hidden>
|
||||||
|
<a id="download-link" style="display:none"></a>
|
||||||
|
|
||||||
|
<script src="vendor/docx-vendor.js"></script>
|
||||||
|
<script src="lib/pkg.js"></script>
|
||||||
|
<script src="lib/schema.js"></script>
|
||||||
|
<script src="lib/read.js"></script>
|
||||||
|
<script src="lib/write.js"></script>
|
||||||
|
<script src="editor.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1015
bundled-addons/docx-editor/editor.js
Normal file
1015
bundled-addons/docx-editor/editor.js
Normal file
File diff suppressed because it is too large
Load diff
192
bundled-addons/docx-editor/index.js
Normal file
192
bundled-addons/docx-editor/index.js
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
// Word editor — the add-on half. All the document work happens in the
|
||||||
|
// editor tab; this side owns the sidebar panel, the scratch folder and the
|
||||||
|
// ring of recently-opened documents.
|
||||||
|
//
|
||||||
|
// Flow:
|
||||||
|
// panel picks a file (<input type=file>, or a drop) → invokes "stash" with
|
||||||
|
// the bytes → we write them to the scratch dir and push them onto the
|
||||||
|
// recent ring → panel invokes "openEditor" → we park a pointer under
|
||||||
|
// storage.__pending and openTab("editor.html") → the editor drains
|
||||||
|
// __pending on its first paint and pulls the bytes with "getBytes".
|
||||||
|
//
|
||||||
|
// Only a pointer goes through storage, never the document: add-on storage is
|
||||||
|
// a single JSON file rewritten in full on every set, and a few megabytes of
|
||||||
|
// base64 in there would make every unrelated write expensive.
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const MAX_RECENT = 8; // documents kept in the ring
|
||||||
|
const SCRATCH_DIR = "docx-scratch";
|
||||||
|
const MAX_BYTES = 64 * 1024 * 1024; // refuse absurd inputs early
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
activate(api) {
|
||||||
|
api.registerSidebarPanel({
|
||||||
|
id: "main",
|
||||||
|
title: "Word editor",
|
||||||
|
icon: "📝",
|
||||||
|
page: "panel.html",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Per-add-on scratch dir under <userData>/addons-data/, same arrangement
|
||||||
|
// as the screenshot add-on: never write inside the add-on folder itself,
|
||||||
|
// where it would confuse anyone reading the shipped source.
|
||||||
|
const dataParent = path.dirname(path.join(api.folder, ".."));
|
||||||
|
const scratchDir = path.join(dataParent, "addons-data", SCRATCH_DIR);
|
||||||
|
try { fs.mkdirSync(scratchDir, { recursive: true }); }
|
||||||
|
catch (e) { api.log("scratch mkdir failed:", e?.message); }
|
||||||
|
|
||||||
|
function stamp() {
|
||||||
|
const d = new Date();
|
||||||
|
const p = (n) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A scratch filename that can't escape the folder however the original
|
||||||
|
// was named. The display name is kept separately in the ring.
|
||||||
|
function scratchName(displayName) {
|
||||||
|
const base = String(displayName || "document")
|
||||||
|
.replace(/\.docx$/i, "")
|
||||||
|
.replace(/[^\w.\- ]+/g, "_")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 60) || "document";
|
||||||
|
return `${stamp()}-${base}.docx`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneRecent(recent) {
|
||||||
|
const alive = recent.filter((r) => { try { fs.accessSync(r.path); return true; } catch { return false; } });
|
||||||
|
const keep = alive.slice(0, MAX_RECENT);
|
||||||
|
for (const gone of alive.slice(MAX_RECENT)) { try { fs.unlinkSync(gone.path); } catch {} }
|
||||||
|
return keep;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRecent() {
|
||||||
|
let recent = api.storage.get("recent", []);
|
||||||
|
return Array.isArray(recent) ? recent : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRecent(id) {
|
||||||
|
return readRecent().find((r) => r.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathOf(id) {
|
||||||
|
// Belt and braces: the id came from a renderer, so re-derive the path
|
||||||
|
// from the ring rather than joining whatever string arrived.
|
||||||
|
const hit = findRecent(id);
|
||||||
|
if (!hit) throw new Error(`no document "${id}" in the recent list`);
|
||||||
|
const abs = path.resolve(hit.path);
|
||||||
|
if (path.dirname(abs) !== path.resolve(scratchDir)) {
|
||||||
|
throw new Error("document path escapes the scratch folder");
|
||||||
|
}
|
||||||
|
return abs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write bytes to the scratch folder and put them at the head of the ring.
|
||||||
|
// `kind` distinguishes what the user opened from what the editor saved
|
||||||
|
// back, so the panel can say which is which.
|
||||||
|
function stash({ name, base64, kind, replaces }) {
|
||||||
|
const buf = Buffer.from(String(base64 || ""), "base64");
|
||||||
|
if (!buf.length) throw new Error("no document bytes");
|
||||||
|
if (buf.length > MAX_BYTES) throw new Error(`document is too large (${(buf.length / 1048576).toFixed(0)} MB)`);
|
||||||
|
// Every .docx is a zip; catching this here beats a confusing parse
|
||||||
|
// error three layers deeper in the editor.
|
||||||
|
if (!(buf[0] === 0x50 && buf[1] === 0x4b)) {
|
||||||
|
throw new Error("that doesn't look like a .docx file");
|
||||||
|
}
|
||||||
|
const id = scratchName(name);
|
||||||
|
const file = path.join(scratchDir, id);
|
||||||
|
fs.writeFileSync(file, buf);
|
||||||
|
|
||||||
|
let recent = readRecent();
|
||||||
|
if (replaces) recent = recent.filter((r) => r.id !== replaces);
|
||||||
|
recent.unshift({
|
||||||
|
id,
|
||||||
|
name: String(name || "document.docx"),
|
||||||
|
path: file,
|
||||||
|
bytes: buf.length,
|
||||||
|
at: Date.now(),
|
||||||
|
kind: kind === "saved" ? "saved" : "opened",
|
||||||
|
});
|
||||||
|
recent = pruneRecent(recent);
|
||||||
|
api.storage.set("recent", recent);
|
||||||
|
api.log(`stashed ${id} (${buf.length} bytes, ${kind || "opened"})`);
|
||||||
|
return { id, name: String(name || "document.docx"), bytes: buf.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
api.onMessage("stash", (payload) => stash(payload || {}));
|
||||||
|
|
||||||
|
// Hand a document to the editor tab. The pointer under __pending is what
|
||||||
|
// the editor drains on its first paint; the query string carries the same
|
||||||
|
// id so a reload of the tab still finds its document.
|
||||||
|
api.onMessage("openEditor", (payload) => {
|
||||||
|
const id = payload && payload.id ? String(payload.id) : "";
|
||||||
|
if (id) {
|
||||||
|
const hit = findRecent(id);
|
||||||
|
if (!hit) throw new Error(`no document "${id}" in the recent list`);
|
||||||
|
api.storage.set("__pending", { id, name: hit.name, at: Date.now() });
|
||||||
|
} else {
|
||||||
|
api.storage.set("__pending", null);
|
||||||
|
}
|
||||||
|
api.openTab("editor.html", id ? { query: { doc: id } } : undefined);
|
||||||
|
api.log(id ? `opening editor for ${id}` : "opening editor with a blank document");
|
||||||
|
return { ok: true, id };
|
||||||
|
});
|
||||||
|
|
||||||
|
api.onMessage("listRecent", () => {
|
||||||
|
const recent = pruneRecent(readRecent());
|
||||||
|
api.storage.set("recent", recent);
|
||||||
|
return recent.map((r) => ({ id: r.id, name: r.name, bytes: r.bytes, at: r.at, kind: r.kind }));
|
||||||
|
});
|
||||||
|
|
||||||
|
api.onMessage("getBytes", (payload) => {
|
||||||
|
const id = String(payload && payload.id || "");
|
||||||
|
const hit = findRecent(id);
|
||||||
|
if (!hit) throw new Error(`no document "${id}" in the recent list`);
|
||||||
|
const buf = fs.readFileSync(pathOf(id));
|
||||||
|
return { id, name: hit.name, base64: buf.toString("base64"), bytes: buf.length, at: hit.at, kind: hit.kind };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Autosave. The editor calls this as the user works; each document keeps
|
||||||
|
// one autosave entry rather than filling the ring with its own history.
|
||||||
|
api.onMessage("autosave", (payload) => {
|
||||||
|
const p = payload || {};
|
||||||
|
const res = stash({
|
||||||
|
name: String(p.name || "document.docx"),
|
||||||
|
base64: p.base64,
|
||||||
|
kind: "saved",
|
||||||
|
replaces: p.replaces ? String(p.replaces) : "",
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
|
||||||
|
api.onMessage("clearRecent", (payload) => {
|
||||||
|
const id = payload && payload.id ? String(payload.id) : "";
|
||||||
|
let recent = readRecent();
|
||||||
|
if (id) {
|
||||||
|
const hit = recent.find((r) => r.id === id);
|
||||||
|
if (hit) { try { fs.unlinkSync(hit.path); } catch {} }
|
||||||
|
recent = recent.filter((r) => r.id !== id);
|
||||||
|
} else {
|
||||||
|
for (const r of recent) { try { fs.unlinkSync(r.path); } catch {} }
|
||||||
|
recent = [];
|
||||||
|
}
|
||||||
|
api.storage.set("recent", recent);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
api.onMessage("openFolder", (payload) => {
|
||||||
|
const { shell } = api.require("electron");
|
||||||
|
const id = payload && payload.id ? String(payload.id) : "";
|
||||||
|
if (id) {
|
||||||
|
const hit = findRecent(id);
|
||||||
|
if (hit) { shell.showItemInFolder(hit.path); return { ok: true, path: hit.path }; }
|
||||||
|
}
|
||||||
|
shell.openPath(scratchDir);
|
||||||
|
return { ok: true, path: scratchDir };
|
||||||
|
});
|
||||||
|
|
||||||
|
api.log("registered docx-editor sidebar panel");
|
||||||
|
},
|
||||||
|
};
|
||||||
490
bundled-addons/docx-editor/lib/pkg.js
Normal file
490
bundled-addons/docx-editor/lib/pkg.js
Normal file
|
|
@ -0,0 +1,490 @@
|
||||||
|
// Package-level work on a .docx: everything that happens at the zip layer,
|
||||||
|
// either side of mammoth and the docx builder.
|
||||||
|
//
|
||||||
|
// Two jobs:
|
||||||
|
//
|
||||||
|
// scan(zip) — walk the original package and report what's in it that the
|
||||||
|
// v1 editor can't render, so the user is told BEFORE they
|
||||||
|
// edit rather than after they've lost something.
|
||||||
|
//
|
||||||
|
// graft(...) — the save-side half of the preservation deal. docx (the npm
|
||||||
|
// builder) always emits a brand-new package, so anything that
|
||||||
|
// lives outside the document body would vanish on save. We
|
||||||
|
// take the parts that survive a body rewrite unharmed —
|
||||||
|
// headers, footers, footnotes, endnotes, the style catalogue,
|
||||||
|
// the theme, page setup — and carry them across from the
|
||||||
|
// original into the freshly built package, re-wiring
|
||||||
|
// relationship ids and content types as we go.
|
||||||
|
//
|
||||||
|
// Body-level things we can't model (textboxes, shapes, equations, content
|
||||||
|
// controls, fields) are NOT preserved; scan() names them so the loss is
|
||||||
|
// visible. See ROUND-TRIP.md for the full ledger.
|
||||||
|
(function (root, factory) {
|
||||||
|
const api = factory();
|
||||||
|
if (typeof module === "object" && module.exports) module.exports = api;
|
||||||
|
root.DocxEditor = Object.assign(root.DocxEditor || {}, { pkg: api });
|
||||||
|
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const V = () => globalThis.DOCXV;
|
||||||
|
|
||||||
|
const W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||||
|
const R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||||
|
const CT_NS = "http://schemas.openxmlformats.org/package/2006/content-types";
|
||||||
|
const PR_NS = "http://schemas.openxmlformats.org/package/2006/relationships";
|
||||||
|
|
||||||
|
const CONTENT_TYPES = {
|
||||||
|
header: "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml",
|
||||||
|
footer: "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml",
|
||||||
|
footnotes: "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml",
|
||||||
|
endnotes: "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml",
|
||||||
|
};
|
||||||
|
const REL_TYPES = {
|
||||||
|
header: R_NS + "/header",
|
||||||
|
footer: R_NS + "/footer",
|
||||||
|
footnotes: R_NS + "/footnotes",
|
||||||
|
endnotes: R_NS + "/endnotes",
|
||||||
|
image: R_NS + "/image",
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseXml(text) {
|
||||||
|
const doc = new DOMParser().parseFromString(text, "application/xml");
|
||||||
|
const err = doc.getElementsByTagName("parsererror")[0];
|
||||||
|
if (err) throw new Error("malformed XML in package: " + err.textContent.slice(0, 200));
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
function serializeXml(doc) {
|
||||||
|
const body = new XMLSerializer().serializeToString(doc);
|
||||||
|
return body.startsWith("<?xml")
|
||||||
|
? body
|
||||||
|
: '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\r\n' + body;
|
||||||
|
}
|
||||||
|
// Namespace-agnostic child lookup: packages in the wild are inconsistent
|
||||||
|
// about prefixes, and getElementsByTagNameNS is the only reliable route.
|
||||||
|
function kids(el, ns, local) {
|
||||||
|
return Array.prototype.filter.call(el.childNodes,
|
||||||
|
(n) => n.nodeType === 1 && n.namespaceURI === ns && n.localName === local);
|
||||||
|
}
|
||||||
|
function firstKid(el, ns, local) { return kids(el, ns, local)[0] || null; }
|
||||||
|
function descendants(doc, ns, local) {
|
||||||
|
return Array.prototype.slice.call(doc.getElementsByTagNameNS(ns, local));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadZip(bytes) {
|
||||||
|
return V().JSZip.loadAsync(bytes);
|
||||||
|
}
|
||||||
|
async function textOf(zip, path) {
|
||||||
|
const f = zip.file(path);
|
||||||
|
return f ? f.async("string") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- scan ---
|
||||||
|
|
||||||
|
// What the scan can say about a feature.
|
||||||
|
// preserved — survives a save untouched (carried across by graft()).
|
||||||
|
// lossy — the content survives but not exactly as Word wrote it.
|
||||||
|
// dropped — gone on save; the user needs to know before editing.
|
||||||
|
// blocked — dangerous to flatten silently; editing is gated on consent.
|
||||||
|
const FEATURES = [
|
||||||
|
{ key: "trackedChanges", level: "blocked", label: "Tracked changes",
|
||||||
|
note: "Saving would silently accept every pending revision.",
|
||||||
|
test: (d) => descendants(d, W_NS, "ins").length + descendants(d, W_NS, "del").length > 0 },
|
||||||
|
{ key: "comments", level: "blocked", label: "Comments",
|
||||||
|
note: "Comment anchors and the comment text are not carried across.",
|
||||||
|
test: (d, z) => !!z.file("word/comments.xml") &&
|
||||||
|
descendants(d, W_NS, "commentRangeStart").length > 0 },
|
||||||
|
|
||||||
|
{ key: "headers", level: "preserved", label: "Headers",
|
||||||
|
test: (d, z) => z.file(/^word\/header\d*\.xml$/).length > 0 },
|
||||||
|
{ key: "footers", level: "preserved", label: "Footers",
|
||||||
|
test: (d, z) => z.file(/^word\/footer\d*\.xml$/).length > 0 },
|
||||||
|
{ key: "footnotes", level: "preserved", label: "Footnotes",
|
||||||
|
test: (d, z) => !!z.file("word/footnotes.xml") &&
|
||||||
|
descendants(d, W_NS, "footnoteReference").length > 0 },
|
||||||
|
{ key: "endnotes", level: "preserved", label: "Endnotes",
|
||||||
|
test: (d, z) => !!z.file("word/endnotes.xml") &&
|
||||||
|
descendants(d, W_NS, "endnoteReference").length > 0 },
|
||||||
|
{ key: "pageSetup", level: "preserved", label: "Page size and margins",
|
||||||
|
test: (d) => descendants(d, W_NS, "sectPr").length > 0 },
|
||||||
|
|
||||||
|
{ key: "equations", level: "dropped", label: "Equations",
|
||||||
|
note: "OMML equations are removed from the body.",
|
||||||
|
test: (d) => d.getElementsByTagNameNS("http://schemas.openxmlformats.org/officeDocument/2006/math", "oMath").length > 0 },
|
||||||
|
{ key: "shapes", level: "dropped", label: "Shapes, text boxes and WordArt",
|
||||||
|
test: (d) => descendants(d, W_NS, "pict").length > 0 ||
|
||||||
|
d.getElementsByTagNameNS("http://schemas.openxmlformats.org/markup-compatibility/2006", "AlternateContent").length > 0 },
|
||||||
|
{ key: "contentControls", level: "dropped", label: "Content controls",
|
||||||
|
test: (d) => descendants(d, W_NS, "sdt").length > 0 },
|
||||||
|
{ key: "fields", level: "dropped", label: "Fields (page numbers, tables of contents, cross-references)",
|
||||||
|
note: "Field codes are dropped; the text Word last calculated is kept.",
|
||||||
|
test: (d) => descendants(d, W_NS, "fldSimple").length > 0 ||
|
||||||
|
descendants(d, W_NS, "instrText").length > 0 },
|
||||||
|
{ key: "bookmarks", level: "dropped", label: "Bookmarks",
|
||||||
|
test: (d) => descendants(d, W_NS, "bookmarkStart")
|
||||||
|
.some((b) => !(b.getAttributeNS(W_NS, "name") || "").startsWith("_GoBack") ) },
|
||||||
|
{ key: "sections", level: "dropped", label: "Multiple sections",
|
||||||
|
note: "Only the first section's page setup is kept; section breaks are lost.",
|
||||||
|
test: (d) => descendants(d, W_NS, "sectPr").length > 1 },
|
||||||
|
{ key: "columns", level: "dropped", label: "Multi-column layout",
|
||||||
|
test: (d) => descendants(d, W_NS, "cols").some((c) => {
|
||||||
|
const n = c.getAttributeNS(W_NS, "num");
|
||||||
|
return n && parseInt(n, 10) > 1;
|
||||||
|
}) },
|
||||||
|
|
||||||
|
{ key: "metafiles", level: "dropped", label: "Metafile pictures (WMF/EMF)",
|
||||||
|
note: "Word's vector picture format. Browsers can't display it and it can't be written back, so those pictures are lost on save.",
|
||||||
|
test: (d, z) => z.file(/^word\/media\/.*\.(wmf|emf)$/i).length > 0 },
|
||||||
|
|
||||||
|
{ key: "paragraphBorders", level: "lossy", label: "Paragraph borders and shading",
|
||||||
|
note: "A rule under an empty paragraph is kept as a horizontal rule; other borders are dropped.",
|
||||||
|
test: (d) => descendants(d, W_NS, "pBdr").length > 0 || descendants(d, W_NS, "shd").length > 0 },
|
||||||
|
{ key: "tabStops", level: "lossy", label: "Custom tab stops",
|
||||||
|
note: "Tab characters are kept, custom stop positions are not.",
|
||||||
|
test: (d) => descendants(d, W_NS, "tabs").length > 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function scan(zip) {
|
||||||
|
const xml = await textOf(zip, "word/document.xml");
|
||||||
|
if (!xml) throw new Error("not a Word document: word/document.xml is missing");
|
||||||
|
const doc = parseXml(xml);
|
||||||
|
const found = [];
|
||||||
|
for (const f of FEATURES) {
|
||||||
|
let hit = false;
|
||||||
|
try { hit = !!f.test(doc, zip); } catch { hit = false; }
|
||||||
|
if (hit) found.push({ key: f.key, level: f.level, label: f.label, note: f.note || "" });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
features: found,
|
||||||
|
blocked: found.filter((f) => f.level === "blocked"),
|
||||||
|
dropped: found.filter((f) => f.level === "dropped"),
|
||||||
|
lossy: found.filter((f) => f.level === "lossy"),
|
||||||
|
preserved: found.filter((f) => f.level === "preserved"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ page setup ---
|
||||||
|
|
||||||
|
// The first sectPr, mapped onto what the docx builder wants. Word writes
|
||||||
|
// these in twips; the builder takes twips too, so this is mostly a rename.
|
||||||
|
async function readSectionSetup(zip) {
|
||||||
|
const xml = await textOf(zip, "word/document.xml");
|
||||||
|
if (!xml) return null;
|
||||||
|
const doc = parseXml(xml);
|
||||||
|
const sect = descendants(doc, W_NS, "sectPr")[0];
|
||||||
|
if (!sect) return null;
|
||||||
|
const num = (el, attr) => {
|
||||||
|
if (!el) return null;
|
||||||
|
const v = el.getAttributeNS(W_NS, attr);
|
||||||
|
return /^-?\d+$/.test(v || "") ? parseInt(v, 10) : null;
|
||||||
|
};
|
||||||
|
const pgSz = firstKid(sect, W_NS, "pgSz");
|
||||||
|
const pgMar = firstKid(sect, W_NS, "pgMar");
|
||||||
|
const setup = { page: {} };
|
||||||
|
if (pgSz) {
|
||||||
|
const w = num(pgSz, "w"), h = num(pgSz, "h");
|
||||||
|
const orient = pgSz.getAttributeNS(W_NS, "orient");
|
||||||
|
if (w && h) setup.page.size = { width: w, height: h, orientation: orient === "landscape" ? "landscape" : "portrait" };
|
||||||
|
}
|
||||||
|
if (pgMar) {
|
||||||
|
const m = {};
|
||||||
|
for (const [k, a] of [["top", "top"], ["right", "right"], ["bottom", "bottom"],
|
||||||
|
["left", "left"], ["header", "header"], ["footer", "footer"], ["gutter", "gutter"]]) {
|
||||||
|
const v = num(pgMar, a);
|
||||||
|
if (v !== null) m[k] = v;
|
||||||
|
}
|
||||||
|
if (Object.keys(m).length) setup.page.margin = m;
|
||||||
|
}
|
||||||
|
setup.titlePg = !!firstKid(sect, W_NS, "titlePg");
|
||||||
|
// Which header/footer parts this section points at, by type.
|
||||||
|
setup.refs = [];
|
||||||
|
for (const kind of ["header", "footer"]) {
|
||||||
|
for (const ref of kids(sect, W_NS, kind + "Reference")) {
|
||||||
|
setup.refs.push({
|
||||||
|
kind,
|
||||||
|
type: ref.getAttributeNS(W_NS, "type") || "default",
|
||||||
|
rId: ref.getAttributeNS(R_NS, "id") || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return setup;
|
||||||
|
}
|
||||||
|
|
||||||
|
// docProps/core.xml — cheap to carry, and losing the author of a document
|
||||||
|
// is the kind of small betrayal people notice.
|
||||||
|
async function readCoreProps(zip) {
|
||||||
|
const xml = await textOf(zip, "docProps/core.xml");
|
||||||
|
if (!xml) return {};
|
||||||
|
let doc; try { doc = parseXml(xml); } catch { return {}; }
|
||||||
|
const pick = (ns, local) => {
|
||||||
|
const el = doc.getElementsByTagNameNS(ns, local)[0];
|
||||||
|
return el && el.textContent ? el.textContent : undefined;
|
||||||
|
};
|
||||||
|
const DC = "http://purl.org/dc/elements/1.1/";
|
||||||
|
const CP = "http://schemas.openxmlformats.org/package/2006/metadata/core-properties";
|
||||||
|
return {
|
||||||
|
title: pick(DC, "title"),
|
||||||
|
creator: pick(DC, "creator"),
|
||||||
|
description: pick(DC, "description"),
|
||||||
|
subject: pick(DC, "subject"),
|
||||||
|
keywords: pick(CP, "keywords"),
|
||||||
|
lastModifiedBy: pick(CP, "lastModifiedBy"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- graft ---
|
||||||
|
|
||||||
|
function relsPathFor(partPath) {
|
||||||
|
const i = partPath.lastIndexOf("/");
|
||||||
|
return partPath.slice(0, i) + "/_rels" + partPath.slice(i) + ".rels";
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextRelId(relsDoc) {
|
||||||
|
let max = 0;
|
||||||
|
for (const r of descendants(relsDoc, PR_NS, "Relationship")) {
|
||||||
|
const m = /^rId(\d+)$/.exec(r.getAttribute("Id") || "");
|
||||||
|
if (m) max = Math.max(max, parseInt(m[1], 10));
|
||||||
|
}
|
||||||
|
return (n) => "rId" + (max + n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRelationship(relsDoc, id, type, target) {
|
||||||
|
const el = relsDoc.createElementNS(PR_NS, "Relationship");
|
||||||
|
el.setAttribute("Id", id);
|
||||||
|
el.setAttribute("Type", type);
|
||||||
|
el.setAttribute("Target", target);
|
||||||
|
relsDoc.documentElement.appendChild(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOverride(ctDoc, partName, contentType) {
|
||||||
|
const already = descendants(ctDoc, CT_NS, "Override")
|
||||||
|
.some((o) => o.getAttribute("PartName") === partName);
|
||||||
|
if (already) return;
|
||||||
|
const el = ctDoc.createElementNS(CT_NS, "Override");
|
||||||
|
el.setAttribute("PartName", partName);
|
||||||
|
el.setAttribute("ContentType", contentType);
|
||||||
|
ctDoc.documentElement.appendChild(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDefaultExt(ctDoc, ext, contentType) {
|
||||||
|
const already = descendants(ctDoc, CT_NS, "Default")
|
||||||
|
.some((o) => (o.getAttribute("Extension") || "").toLowerCase() === ext.toLowerCase());
|
||||||
|
if (already) return;
|
||||||
|
const el = ctDoc.createElementNS(CT_NS, "Default");
|
||||||
|
el.setAttribute("Extension", ext);
|
||||||
|
el.setAttribute("ContentType", contentType);
|
||||||
|
ctDoc.documentElement.insertBefore(el, ctDoc.documentElement.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy one part plus anything its own .rels file points at. Media gets a
|
||||||
|
// fresh name whenever the generated package already has a file there, and
|
||||||
|
// the part's rels are rewritten to match — otherwise a header's logo would
|
||||||
|
// quietly replace an image from the body.
|
||||||
|
async function copyPartWithRels(orig, gen, partPath, usedMedia) {
|
||||||
|
const data = await orig.file(partPath).async("uint8array");
|
||||||
|
gen.file(partPath, data);
|
||||||
|
const rp = relsPathFor(partPath);
|
||||||
|
const relsText = await textOf(orig, rp);
|
||||||
|
if (!relsText) return;
|
||||||
|
const relsDoc = parseXml(relsText);
|
||||||
|
for (const rel of descendants(relsDoc, PR_NS, "Relationship")) {
|
||||||
|
if (rel.getAttribute("TargetMode") === "External") continue;
|
||||||
|
const target = rel.getAttribute("Target") || "";
|
||||||
|
// Targets in word/_rels/*.rels are relative to word/.
|
||||||
|
const src = ("word/" + target.replace(/^\.\//, "")).replace(/\/+/g, "/");
|
||||||
|
const f = orig.file(src);
|
||||||
|
if (!f) continue;
|
||||||
|
let dest = src;
|
||||||
|
if (gen.file(dest) && !usedMedia.has(src)) {
|
||||||
|
const dot = src.lastIndexOf(".");
|
||||||
|
dest = src.slice(0, dot) + "-carried" + usedMedia.size + src.slice(dot);
|
||||||
|
rel.setAttribute("Target", dest.replace(/^word\//, ""));
|
||||||
|
}
|
||||||
|
usedMedia.set(src, dest);
|
||||||
|
gen.file(dest, await f.async("uint8array"));
|
||||||
|
}
|
||||||
|
gen.file(rp, serializeXml(relsDoc));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge the original style catalogue into the generated one. Where both
|
||||||
|
// define a style id, the ORIGINAL wins: it is what the document actually
|
||||||
|
// looked like, and the builder's defaults are only there to make a blank
|
||||||
|
// document presentable. Styles the original doesn't have (the editor's own
|
||||||
|
// SourceCode, for instance) are left in place.
|
||||||
|
async function mergeStyles(orig, gen) {
|
||||||
|
const origText = await textOf(orig, "word/styles.xml");
|
||||||
|
const genText = await textOf(gen, "word/styles.xml");
|
||||||
|
if (!origText || !genText) return { merged: 0 };
|
||||||
|
const origDoc = parseXml(origText);
|
||||||
|
const genDoc = parseXml(genText);
|
||||||
|
const genRoot = genDoc.documentElement;
|
||||||
|
|
||||||
|
const byId = new Map();
|
||||||
|
for (const s of kids(genRoot, W_NS, "style")) {
|
||||||
|
byId.set(s.getAttributeNS(W_NS, "styleId"), s);
|
||||||
|
}
|
||||||
|
let merged = 0;
|
||||||
|
for (const s of descendants(origDoc, W_NS, "style")) {
|
||||||
|
const id = s.getAttributeNS(W_NS, "styleId");
|
||||||
|
if (!id) continue;
|
||||||
|
const imported = genDoc.importNode(s, true);
|
||||||
|
const existing = byId.get(id);
|
||||||
|
if (existing) genRoot.replaceChild(imported, existing);
|
||||||
|
else genRoot.appendChild(imported);
|
||||||
|
byId.set(id, imported);
|
||||||
|
merged++;
|
||||||
|
}
|
||||||
|
// docDefaults carries the document's base font and spacing; without it a
|
||||||
|
// grafted style catalogue sits on the builder's defaults and every
|
||||||
|
// unstyled paragraph shifts.
|
||||||
|
const origDefaults = descendants(origDoc, W_NS, "docDefaults")[0];
|
||||||
|
if (origDefaults) {
|
||||||
|
const genDefaults = kids(genRoot, W_NS, "docDefaults")[0];
|
||||||
|
const imported = genDoc.importNode(origDefaults, true);
|
||||||
|
if (genDefaults) genRoot.replaceChild(imported, genDefaults);
|
||||||
|
else genRoot.insertBefore(imported, genRoot.firstChild);
|
||||||
|
}
|
||||||
|
gen.file("word/styles.xml", serializeXml(genDoc));
|
||||||
|
return { merged };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header/footer references have to be the FIRST children of sectPr — the
|
||||||
|
// schema is order-sensitive and Word refuses a file that gets it wrong.
|
||||||
|
function injectSectionRefs(docDoc, refs, titlePg) {
|
||||||
|
const sect = descendants(docDoc, W_NS, "sectPr")[0];
|
||||||
|
if (!sect) return 0;
|
||||||
|
let n = 0;
|
||||||
|
const anchor = sect.firstChild;
|
||||||
|
for (const ref of refs) {
|
||||||
|
const el = docDoc.createElementNS(W_NS, "w:" + ref.kind + "Reference");
|
||||||
|
el.setAttributeNS(W_NS, "w:type", ref.type);
|
||||||
|
el.setAttributeNS(R_NS, "r:id", ref.newRId);
|
||||||
|
sect.insertBefore(el, anchor);
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
if (titlePg && !firstKid(sect, W_NS, "titlePg")) {
|
||||||
|
// titlePg sits after the references but before pgSz; appending is fine
|
||||||
|
// because Word tolerates it at the tail of sectPr in practice, and the
|
||||||
|
// references above are the order-critical part.
|
||||||
|
sect.appendChild(docDoc.createElementNS(W_NS, "w:titlePg"));
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carry preserved parts from the original package into the generated one.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} originalBytes the .docx the user opened
|
||||||
|
* @param {Uint8Array} generatedBytes what the docx builder just produced
|
||||||
|
* @param {object} opts { headers, footers, notes, styles, theme }
|
||||||
|
* @returns {Promise<{bytes: Uint8Array, carried: string[]}>}
|
||||||
|
*/
|
||||||
|
async function graft(originalBytes, generatedBytes, opts) {
|
||||||
|
const o = Object.assign({ headers: true, footers: true, notes: true, styles: true, theme: true }, opts || {});
|
||||||
|
const orig = await loadZip(originalBytes);
|
||||||
|
const gen = await loadZip(generatedBytes);
|
||||||
|
const carried = [];
|
||||||
|
|
||||||
|
const ctText = await textOf(gen, "[Content_Types].xml");
|
||||||
|
const ctDoc = parseXml(ctText);
|
||||||
|
const relsText = await textOf(gen, "word/_rels/document.xml.rels");
|
||||||
|
const relsDoc = parseXml(relsText);
|
||||||
|
const mkId = nextRelId(relsDoc);
|
||||||
|
let idN = 0;
|
||||||
|
const usedMedia = new Map();
|
||||||
|
|
||||||
|
// --- headers and footers -------------------------------------------
|
||||||
|
const setup = await readSectionSetup(orig);
|
||||||
|
const origRels = await textOf(orig, "word/_rels/document.xml.rels");
|
||||||
|
const origRelsDoc = origRels ? parseXml(origRels) : null;
|
||||||
|
const targetOf = (rId) => {
|
||||||
|
if (!origRelsDoc) return null;
|
||||||
|
const hit = descendants(origRelsDoc, PR_NS, "Relationship")
|
||||||
|
.find((r) => r.getAttribute("Id") === rId);
|
||||||
|
return hit ? "word/" + (hit.getAttribute("Target") || "").replace(/^\.\//, "") : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const newRefs = [];
|
||||||
|
if (setup && setup.refs.length) {
|
||||||
|
for (const ref of setup.refs) {
|
||||||
|
if (ref.kind === "header" && !o.headers) continue;
|
||||||
|
if (ref.kind === "footer" && !o.footers) continue;
|
||||||
|
const part = targetOf(ref.rId);
|
||||||
|
if (!part || !orig.file(part)) continue;
|
||||||
|
await copyPartWithRels(orig, gen, part, usedMedia);
|
||||||
|
const newRId = mkId(++idN);
|
||||||
|
addRelationship(relsDoc, newRId, REL_TYPES[ref.kind], part.replace(/^word\//, ""));
|
||||||
|
addOverride(ctDoc, "/" + part, CONTENT_TYPES[ref.kind]);
|
||||||
|
newRefs.push({ kind: ref.kind, type: ref.type, newRId });
|
||||||
|
}
|
||||||
|
if (newRefs.length) carried.push(`${newRefs.length} header/footer part(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- footnotes and endnotes -----------------------------------------
|
||||||
|
// The body keeps its footnote references (see read.js), so the note text
|
||||||
|
// has to come across with the same ids the references use — which is
|
||||||
|
// exactly what copying the original part wholesale gives us. The builder
|
||||||
|
// writes its own footnotes.xml only when the document declares notes, so
|
||||||
|
// in practice this replaces an absent or separator-only part.
|
||||||
|
if (o.notes) {
|
||||||
|
for (const kind of ["footnotes", "endnotes"]) {
|
||||||
|
const part = `word/${kind}.xml`;
|
||||||
|
if (!orig.file(part)) continue;
|
||||||
|
await copyPartWithRels(orig, gen, part, usedMedia);
|
||||||
|
addOverride(ctDoc, "/" + part, CONTENT_TYPES[kind]);
|
||||||
|
const has = descendants(relsDoc, PR_NS, "Relationship")
|
||||||
|
.some((r) => r.getAttribute("Type") === REL_TYPES[kind]);
|
||||||
|
if (!has) addRelationship(relsDoc, mkId(++idN), REL_TYPES[kind], `${kind}.xml`);
|
||||||
|
carried.push(kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- styles and theme ------------------------------------------------
|
||||||
|
if (o.styles) {
|
||||||
|
const r = await mergeStyles(orig, gen);
|
||||||
|
if (r.merged) carried.push(`${r.merged} style definition(s)`);
|
||||||
|
}
|
||||||
|
if (o.theme) {
|
||||||
|
const themeFile = orig.file(/^word\/theme\/theme\d*\.xml$/)[0];
|
||||||
|
if (themeFile) {
|
||||||
|
const genTheme = gen.file(/^word\/theme\/theme\d*\.xml$/)[0];
|
||||||
|
const dest = genTheme ? genTheme.name : "word/theme/theme1.xml";
|
||||||
|
gen.file(dest, await themeFile.async("uint8array"));
|
||||||
|
if (!genTheme) {
|
||||||
|
addOverride(ctDoc, "/" + dest, "application/vnd.openxmlformats-officedocument.theme+xml");
|
||||||
|
addRelationship(relsDoc, mkId(++idN), R_NS + "/theme", dest.replace(/^word\//, ""));
|
||||||
|
}
|
||||||
|
carried.push("theme");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any media extension the carried parts brought with them needs a Default
|
||||||
|
// entry or Word rejects the package.
|
||||||
|
for (const dest of usedMedia.values()) {
|
||||||
|
const ext = (dest.split(".").pop() || "").toLowerCase();
|
||||||
|
const mime = { png: "image/png", jpeg: "image/jpeg", jpg: "image/jpeg", gif: "image/gif",
|
||||||
|
bmp: "image/bmp", tiff: "image/tiff", svg: "image/svg+xml",
|
||||||
|
emf: "image/x-emf", wmf: "image/x-wmf" }[ext];
|
||||||
|
if (mime) addDefaultExt(ctDoc, ext, mime);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- rewrite the parts we changed ------------------------------------
|
||||||
|
if (newRefs.length || (setup && setup.titlePg)) {
|
||||||
|
const docText = await textOf(gen, "word/document.xml");
|
||||||
|
const docDoc = parseXml(docText);
|
||||||
|
injectSectionRefs(docDoc, newRefs, setup && setup.titlePg);
|
||||||
|
gen.file("word/document.xml", serializeXml(docDoc));
|
||||||
|
}
|
||||||
|
gen.file("[Content_Types].xml", serializeXml(ctDoc));
|
||||||
|
gen.file("word/_rels/document.xml.rels", serializeXml(relsDoc));
|
||||||
|
|
||||||
|
const bytes = await gen.generateAsync({
|
||||||
|
type: "uint8array",
|
||||||
|
compression: "DEFLATE",
|
||||||
|
compressionOptions: { level: 6 },
|
||||||
|
mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
});
|
||||||
|
return { bytes, carried };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { loadZip, scan, graft, readSectionSetup, readCoreProps, parseXml, serializeXml, FEATURES };
|
||||||
|
});
|
||||||
502
bundled-addons/docx-editor/lib/read.js
Normal file
502
bundled-addons/docx-editor/lib/read.js
Normal file
|
|
@ -0,0 +1,502 @@
|
||||||
|
// .docx -> editor model.
|
||||||
|
//
|
||||||
|
// mammoth does the hard part of reading OOXML: resolving style inheritance,
|
||||||
|
// numbering definitions, relationship targets, merged table cells. What it
|
||||||
|
// is designed to produce, though, is semantic HTML — and HTML has nowhere to
|
||||||
|
// put a run's colour or a paragraph's line spacing, so its converter throws
|
||||||
|
// them away.
|
||||||
|
//
|
||||||
|
// So we don't use its HTML at all. `transformDocument` hands us mammoth's
|
||||||
|
// parsed document model on the way past, and we walk THAT into ProseMirror
|
||||||
|
// JSON. Everything the model carries survives; see addon-build/docx-editor/
|
||||||
|
// patches.mjs for the handful of properties we taught it to carry.
|
||||||
|
(function (root, factory) {
|
||||||
|
const api = factory();
|
||||||
|
if (typeof module === "object" && module.exports) module.exports = api;
|
||||||
|
root.DocxEditor = Object.assign(root.DocxEditor || {}, { read: api });
|
||||||
|
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const V = () => globalThis.DOCXV;
|
||||||
|
|
||||||
|
const PAGE_CONTENT_PT = 468; // 6.5in of text between 1in margins
|
||||||
|
const DEFAULT_IMAGE_PT = 300;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- images ---
|
||||||
|
|
||||||
|
// Natural pixel size, straight out of the file header. Decoding through an
|
||||||
|
// <img> would work in the editor but not in the round-trip tests, and a
|
||||||
|
// size that depends on which half of the codebase is asking is a bug
|
||||||
|
// waiting to happen.
|
||||||
|
function imagePixelSize(bytes) {
|
||||||
|
const b = bytes;
|
||||||
|
const u16 = (i, le) => le ? b[i] | (b[i + 1] << 8) : (b[i] << 8) | b[i + 1];
|
||||||
|
const u32 = (i, le) => le
|
||||||
|
? (b[i] | (b[i + 1] << 8) | (b[i + 2] << 16) | (b[i + 3] << 24)) >>> 0
|
||||||
|
: ((b[i] << 24) | (b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3]) >>> 0;
|
||||||
|
|
||||||
|
if (b.length > 24 && b[0] === 0x89 && b[1] === 0x50) { // PNG
|
||||||
|
return { w: u32(16, false), h: u32(20, false) };
|
||||||
|
}
|
||||||
|
if (b.length > 10 && b[0] === 0x47 && b[1] === 0x49) { // GIF
|
||||||
|
return { w: u16(6, true), h: u16(8, true) };
|
||||||
|
}
|
||||||
|
if (b.length > 26 && b[0] === 0x42 && b[1] === 0x4d) { // BMP
|
||||||
|
return { w: u32(18, true), h: u32(22, true) };
|
||||||
|
}
|
||||||
|
if (b.length > 4 && b[0] === 0xff && b[1] === 0xd8) { // JPEG
|
||||||
|
let i = 2;
|
||||||
|
while (i + 9 < b.length) {
|
||||||
|
if (b[i] !== 0xff) { i++; continue; }
|
||||||
|
const marker = b[i + 1];
|
||||||
|
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { i += 2; continue; }
|
||||||
|
const len = u16(i + 2, false);
|
||||||
|
// SOF0..SOF15, skipping the four that aren't start-of-frame.
|
||||||
|
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
||||||
|
return { h: u16(i + 5, false), w: u16(i + 7, false) };
|
||||||
|
}
|
||||||
|
i += 2 + len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageSizePt(bytes, declared) {
|
||||||
|
// What Word was drawing it at always wins — the author chose it.
|
||||||
|
if (declared && declared.widthPt) {
|
||||||
|
return { width: declared.widthPt, height: declared.heightPt || null };
|
||||||
|
}
|
||||||
|
const px = imagePixelSize(bytes);
|
||||||
|
if (!px || !px.w) return { width: DEFAULT_IMAGE_PT, height: null };
|
||||||
|
const ratio = px.h / px.w;
|
||||||
|
let w = px.w * 0.75; // 96dpi pixels to points
|
||||||
|
if (w > PAGE_CONTENT_PT) w = PAGE_CONTENT_PT;
|
||||||
|
return { width: Math.round(w * 100) / 100, height: Math.round(w * ratio * 100) / 100 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToBase64(bytes) {
|
||||||
|
if (typeof Buffer !== "undefined") return Buffer.from(bytes).toString("base64");
|
||||||
|
let s = "";
|
||||||
|
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||||
|
s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
|
||||||
|
}
|
||||||
|
return btoa(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- marks ---
|
||||||
|
|
||||||
|
const HIGHLIGHT_NAMES = new Set([
|
||||||
|
"yellow", "green", "cyan", "magenta", "blue", "red", "darkBlue", "darkCyan",
|
||||||
|
"darkGreen", "darkMagenta", "darkRed", "darkYellow", "darkGray", "lightGray", "black",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function runMarks(run, inherited) {
|
||||||
|
const marks = inherited ? inherited.slice() : [];
|
||||||
|
const add = (type, attrs) => marks.push(attrs ? { type, attrs } : { type });
|
||||||
|
if (run.isBold) add("strong");
|
||||||
|
if (run.isItalic) add("em");
|
||||||
|
if (run.isUnderline) add("underline");
|
||||||
|
if (run.isStrikethrough) add("strike");
|
||||||
|
if (run.isAllCaps) add("caps");
|
||||||
|
if (run.isSmallCaps) add("smallcaps");
|
||||||
|
if (run.verticalAlignment === "superscript") add("sup");
|
||||||
|
if (run.verticalAlignment === "subscript") add("sub");
|
||||||
|
if (run.font) add("font", { family: run.font });
|
||||||
|
if (run.fontSize) add("fsize", { pt: run.fontSize });
|
||||||
|
if (run.color && run.color !== "000000") add("color", { hex: run.color });
|
||||||
|
if (run.highlight && HIGHLIGHT_NAMES.has(run.highlight)) add("highlight", { name: run.highlight });
|
||||||
|
return marks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------- paragraphs ---
|
||||||
|
|
||||||
|
const HEADING_RE = /^heading\s*([1-6])$/i;
|
||||||
|
|
||||||
|
// Which block a paragraph becomes, from its Word style. styleName is what
|
||||||
|
// the user sees in the styles gallery; styleId is the internal one, and
|
||||||
|
// documents from non-Word producers often set only one of the two.
|
||||||
|
function classifyParagraph(p) {
|
||||||
|
const name = (p.styleName || "").trim();
|
||||||
|
const id = (p.styleId || "").trim();
|
||||||
|
const m = HEADING_RE.exec(name) || /^Heading([1-6])$/.exec(id);
|
||||||
|
if (m) return { kind: "heading", level: parseInt(m[1], 10) };
|
||||||
|
if (/^title$/i.test(name) || id === "Title") return { kind: "heading", level: 1 };
|
||||||
|
if (/^subtitle$/i.test(name) || id === "Subtitle") return { kind: "heading", level: 2 };
|
||||||
|
if (/quote$/i.test(name) || /Quote$/.test(id)) return { kind: "blockquote" };
|
||||||
|
if (/^(source code|html preformatted|code|plain text|preformatted text)$/i.test(name) ||
|
||||||
|
/^(SourceCode|HTMLPreformatted|PlainText)$/.test(id)) return { kind: "code_block" };
|
||||||
|
return { kind: "paragraph" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function alignmentOf(p) {
|
||||||
|
const a = (p.alignment || "").toLowerCase();
|
||||||
|
if (a === "center") return "center";
|
||||||
|
if (a === "right" || a === "end") return "right";
|
||||||
|
if (a === "both" || a === "justify" || a === "distribute") return "justify";
|
||||||
|
if (a === "left" || a === "start") return "left";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function indentLevelOf(p) {
|
||||||
|
const twips = parseInt((p.indent && (p.indent.start)) || "0", 10);
|
||||||
|
if (!Number.isFinite(twips) || twips <= 0) return 0;
|
||||||
|
return Math.min(8, Math.round(twips / 720));
|
||||||
|
}
|
||||||
|
|
||||||
|
function spacingOf(p) {
|
||||||
|
const s = p.spacing;
|
||||||
|
if (!s) return { lineHeight: null, spaceBefore: null, spaceAfter: null };
|
||||||
|
// w:line is 240ths of a line under the "auto" rule; under exact/atLeast
|
||||||
|
// it's twips, which the editor has no control for, so it is left alone
|
||||||
|
// (and reported as lossy).
|
||||||
|
const lineHeight = s.line && (!s.lineRule || s.lineRule === "auto")
|
||||||
|
? Math.round((s.line / 240) * 100) / 100 : null;
|
||||||
|
const pt = (twips) => (twips == null ? null : Math.round((twips / 20) * 10) / 10);
|
||||||
|
return { lineHeight, spaceBefore: pt(s.before), spaceAfter: pt(s.after) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function paragraphAttrs(p) {
|
||||||
|
return Object.assign({ align: alignmentOf(p), indent: indentLevelOf(p) }, spacingOf(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- walker ---
|
||||||
|
|
||||||
|
function Reader(options) {
|
||||||
|
this.warnings = [];
|
||||||
|
this.options = options || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Reader.prototype.warn = function (msg) {
|
||||||
|
if (!this.warnings.includes(msg)) this.warnings.push(msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inline children of a paragraph or table cell. Returns
|
||||||
|
// {inline: [...], breaks: [...]} — a page break inside a paragraph has to
|
||||||
|
// become a sibling block, so it is reported up rather than inlined.
|
||||||
|
Reader.prototype.inlineChildren = async function (children, marks) {
|
||||||
|
const out = [];
|
||||||
|
let sawPageBreak = false;
|
||||||
|
for (const child of children) {
|
||||||
|
switch (child.type) {
|
||||||
|
case "run": {
|
||||||
|
const sub = await this.inlineChildren(child.children, runMarks(child, marks));
|
||||||
|
out.push(...sub.inline);
|
||||||
|
sawPageBreak = sawPageBreak || sub.sawPageBreak;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "text": {
|
||||||
|
if (child.value) out.push({ type: "text", text: child.value, marks: marks.length ? marks : undefined });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "tab": {
|
||||||
|
out.push({ type: "text", text: "\t", marks: marks.length ? marks : undefined });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "checkbox": {
|
||||||
|
out.push({ type: "text", text: child.checked ? "☒" : "☐", marks: marks.length ? marks : undefined });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "break": {
|
||||||
|
if (child.breakType === "line") out.push({ type: "hard_break" });
|
||||||
|
else if (child.breakType === "page") sawPageBreak = true;
|
||||||
|
else if (child.breakType === "column") { sawPageBreak = true; this.warn("columns"); }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "hyperlink": {
|
||||||
|
const href = child.href || (child.anchor ? "#" + child.anchor : "");
|
||||||
|
const linkMark = { type: "link", attrs: { href, title: null, anchor: child.anchor || null } };
|
||||||
|
const sub = await this.inlineChildren(child.children, marks.concat([linkMark]));
|
||||||
|
out.push(...sub.inline);
|
||||||
|
sawPageBreak = sawPageBreak || sub.sawPageBreak;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "image": {
|
||||||
|
const node = await this.imageNode(child);
|
||||||
|
if (node) out.push(node);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "noteReference": {
|
||||||
|
out.push({
|
||||||
|
type: "note_ref",
|
||||||
|
attrs: {
|
||||||
|
noteType: child.noteType === "endnote" ? "endnote" : "footnote",
|
||||||
|
noteId: String(child.noteId),
|
||||||
|
label: child.noteType === "endnote" ? "†" : "*",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "commentReference":
|
||||||
|
this.warn("comments");
|
||||||
|
break;
|
||||||
|
case "bookmarkStart":
|
||||||
|
// Anchors for internal links. Dropped, but only worth mentioning
|
||||||
|
// when it isn't Word's own cursor-position bookmark.
|
||||||
|
if (child.name && !String(child.name).startsWith("_GoBack")) this.warn("bookmarks");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { inline: out, sawPageBreak };
|
||||||
|
};
|
||||||
|
|
||||||
|
Reader.prototype.imageNode = async function (image) {
|
||||||
|
let bytes;
|
||||||
|
try {
|
||||||
|
const buf = await image.readAsArrayBuffer();
|
||||||
|
bytes = new Uint8Array(buf);
|
||||||
|
} catch (e) {
|
||||||
|
this.warn("unreadable-image");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const size = imageSizePt(bytes, image);
|
||||||
|
const type = image.contentType || "image/png";
|
||||||
|
return {
|
||||||
|
type: "image",
|
||||||
|
attrs: {
|
||||||
|
src: `data:${type};base64,${bytesToBase64(bytes)}`,
|
||||||
|
alt: image.altText || null,
|
||||||
|
title: null,
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ----------------------------------------------------------- list stack ---
|
||||||
|
|
||||||
|
// Word has no list elements: every list item is a paragraph carrying a
|
||||||
|
// numbering id and a level. Rebuilding the nesting is on us.
|
||||||
|
function ListStack(out) {
|
||||||
|
this.out = out; // the block array lists get appended to
|
||||||
|
this.stack = []; // [{level, ordered, node}]
|
||||||
|
}
|
||||||
|
|
||||||
|
ListStack.prototype.flush = function () { this.stack.length = 0; };
|
||||||
|
|
||||||
|
ListStack.prototype.push = function (numbering, itemBlocks) {
|
||||||
|
const level = Math.max(0, Math.min(8, parseInt(numbering.level, 10) || 0));
|
||||||
|
const ordered = !!numbering.isOrdered;
|
||||||
|
const format = numbering.numFmt || (ordered ? "decimal" : null);
|
||||||
|
const numId = numbering.numId == null ? null : String(numbering.numId);
|
||||||
|
|
||||||
|
// Leaving a deeper level.
|
||||||
|
while (this.stack.length && this.stack[this.stack.length - 1].level > level) this.stack.pop();
|
||||||
|
|
||||||
|
let top = this.stack[this.stack.length - 1];
|
||||||
|
// Same level, but a different list. Word marks the boundary between two
|
||||||
|
// adjacent lists with a change of numbering id — it's the difference
|
||||||
|
// between "4. 5. 6." and a second list starting again at 1 — and a
|
||||||
|
// change of bullet-versus-number means the same thing.
|
||||||
|
if (top && top.level === level &&
|
||||||
|
(top.ordered !== ordered || (numId !== null && top.numId !== null && top.numId !== numId))) {
|
||||||
|
this.stack.pop();
|
||||||
|
top = this.stack[this.stack.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!top || top.level < level) {
|
||||||
|
const node = ordered
|
||||||
|
? { type: "ordered_list", attrs: { order: 1, format: format || "decimal" }, content: [] }
|
||||||
|
: { type: "bullet_list", content: [] };
|
||||||
|
if (top) {
|
||||||
|
// Nested: the sub-list belongs inside the parent's last item.
|
||||||
|
let parentItems = top.node.content;
|
||||||
|
if (!parentItems.length) {
|
||||||
|
parentItems.push({ type: "list_item", content: [{ type: "paragraph", content: [] }] });
|
||||||
|
}
|
||||||
|
parentItems[parentItems.length - 1].content.push(node);
|
||||||
|
} else {
|
||||||
|
this.out.push(node);
|
||||||
|
}
|
||||||
|
this.stack.push({ level, ordered, numId, node });
|
||||||
|
top = this.stack[this.stack.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
top.node.content.push({ type: "list_item", content: itemBlocks });
|
||||||
|
};
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- blocks ---
|
||||||
|
|
||||||
|
Reader.prototype.blocks = async function (children) {
|
||||||
|
const out = [];
|
||||||
|
const lists = new ListStack(out);
|
||||||
|
// Consecutive code-styled paragraphs read as one code block, the way
|
||||||
|
// they were almost certainly written.
|
||||||
|
let codeRun = null;
|
||||||
|
|
||||||
|
const closeCode = () => { codeRun = null; };
|
||||||
|
|
||||||
|
for (const child of children) {
|
||||||
|
if (child.type === "paragraph") {
|
||||||
|
const cls = classifyParagraph(child);
|
||||||
|
const { inline, sawPageBreak } = await this.inlineChildren(child.children, []);
|
||||||
|
|
||||||
|
if (cls.kind === "code_block") {
|
||||||
|
const text = inline.filter((n) => n.type === "text").map((n) => n.text).join("");
|
||||||
|
if (codeRun) codeRun.content.push({ type: "text", text: "\n" + text });
|
||||||
|
else {
|
||||||
|
codeRun = { type: "code_block", content: text ? [{ type: "text", text }] : [] };
|
||||||
|
lists.flush();
|
||||||
|
out.push(codeRun);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
closeCode();
|
||||||
|
|
||||||
|
if (sawPageBreak) {
|
||||||
|
lists.flush();
|
||||||
|
out.push({ type: "page_break" });
|
||||||
|
// A paragraph that held nothing but the break IS the break; keeping
|
||||||
|
// the husk would grow the document by one blank line every save.
|
||||||
|
if (!inline.length) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty paragraph carrying only a bottom border is Word's
|
||||||
|
// horizontal rule (what AutoFormat makes from "---").
|
||||||
|
if (!inline.length && child.hasBottomBorder) {
|
||||||
|
lists.flush();
|
||||||
|
out.push({ type: "horizontal_rule" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attrs = paragraphAttrs(child);
|
||||||
|
let block;
|
||||||
|
if (cls.kind === "heading") {
|
||||||
|
block = { type: "heading", attrs: Object.assign({ level: cls.level }, attrs), content: inline };
|
||||||
|
} else {
|
||||||
|
block = { type: "paragraph", attrs, content: inline };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child.numbering) {
|
||||||
|
// List items don't carry their own indent — the list level owns it.
|
||||||
|
block.attrs = Object.assign({}, block.attrs, { indent: 0 });
|
||||||
|
lists.push(child.numbering, [block]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lists.flush();
|
||||||
|
|
||||||
|
if (cls.kind === "blockquote") {
|
||||||
|
// The blockquote owns the indent; leaving it on the paragraph too
|
||||||
|
// would push the quote one level deeper on every round-trip.
|
||||||
|
block.attrs = Object.assign({}, block.attrs, { indent: 0 });
|
||||||
|
out.push({ type: "blockquote", content: [block] });
|
||||||
|
}
|
||||||
|
else out.push(block);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeCode();
|
||||||
|
lists.flush();
|
||||||
|
|
||||||
|
if (child.type === "table") {
|
||||||
|
const table = await this.table(child);
|
||||||
|
if (table) out.push(table);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Anything else at body level (bookmarks, stray runs) contributes no
|
||||||
|
// block of its own.
|
||||||
|
if (child.type === "bookmarkStart") continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
Reader.prototype.table = async function (table) {
|
||||||
|
const rows = [];
|
||||||
|
for (const row of table.children) {
|
||||||
|
if (row.type !== "tableRow") continue;
|
||||||
|
const cells = [];
|
||||||
|
for (const cell of row.children) {
|
||||||
|
if (cell.type !== "tableCell") continue;
|
||||||
|
let content = await this.blocks(cell.children);
|
||||||
|
if (!content.length) content = [{ type: "paragraph", content: [] }];
|
||||||
|
// OOXML forbids a cell that ends with a table, so every document
|
||||||
|
// with a nested table carries an empty paragraph after it that the
|
||||||
|
// author never typed. Dropping it here keeps the cell stable across
|
||||||
|
// saves; the writer puts it back on the way out.
|
||||||
|
if (content.length > 1) {
|
||||||
|
const last = content[content.length - 1];
|
||||||
|
const prev = content[content.length - 2];
|
||||||
|
if (prev.type === "table" && last.type === "paragraph" && !(last.content || []).length) {
|
||||||
|
content.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cells.push({
|
||||||
|
type: row.isHeader ? "table_header" : "table_cell",
|
||||||
|
attrs: {
|
||||||
|
colspan: cell.colSpan || 1,
|
||||||
|
rowspan: cell.rowSpan || 1,
|
||||||
|
colwidth: null,
|
||||||
|
background: null,
|
||||||
|
},
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// A row whose every column is covered by a merge from above has no
|
||||||
|
// cells of its own, and that's not an empty row to be thrown away —
|
||||||
|
// it's how both this model and HTML represent the middle of a
|
||||||
|
// vertical merge. Dropping it turns a 12-row merge into a 2-row one.
|
||||||
|
rows.push({ type: "table_row", content: cells });
|
||||||
|
}
|
||||||
|
if (!rows.length) return null;
|
||||||
|
return { type: "table", content: rows };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- api ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a .docx into an editor document.
|
||||||
|
*
|
||||||
|
* @param {ArrayBuffer|Uint8Array} bytes
|
||||||
|
* @param {object} schema the ProseMirror schema from schema.js
|
||||||
|
* @returns {Promise<{doc, report, meta, setup, warnings, messages}>}
|
||||||
|
*/
|
||||||
|
async function docxToDoc(bytes, schema) {
|
||||||
|
const { mammoth, pm } = V();
|
||||||
|
const { pkg } = globalThis.DocxEditor;
|
||||||
|
const arrayBuffer = bytes instanceof Uint8Array
|
||||||
|
? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
|
||||||
|
: bytes;
|
||||||
|
const u8 = new Uint8Array(arrayBuffer);
|
||||||
|
|
||||||
|
const zip = await pkg.loadZip(u8);
|
||||||
|
const [report, setup, meta] = await Promise.all([
|
||||||
|
pkg.scan(zip), pkg.readSectionSetup(zip), pkg.readCoreProps(zip),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let captured = null;
|
||||||
|
// mammoth takes {arrayBuffer} in the browser and {buffer} under node;
|
||||||
|
// the round-trip tests run under node against this same file.
|
||||||
|
const underNode = typeof process !== "undefined" && !!(process.versions && process.versions.node) &&
|
||||||
|
typeof Buffer !== "undefined";
|
||||||
|
const input = underNode ? { buffer: Buffer.from(u8) } : { arrayBuffer };
|
||||||
|
// The HTML this produces is thrown away — transformDocument is just the
|
||||||
|
// public seam that hands over the parsed model.
|
||||||
|
const result = await mammoth.convertToHtml(input, {
|
||||||
|
transformDocument: (document) => {
|
||||||
|
captured = document;
|
||||||
|
return document;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!captured) throw new Error("mammoth did not hand back a document model");
|
||||||
|
|
||||||
|
const reader = new Reader();
|
||||||
|
const blocks = await reader.blocks(captured.children);
|
||||||
|
if (!blocks.length) blocks.push({ type: "paragraph", content: [] });
|
||||||
|
|
||||||
|
const doc = pm.model.Node.fromJSON(schema, { type: "doc", content: blocks });
|
||||||
|
doc.check();
|
||||||
|
|
||||||
|
return {
|
||||||
|
doc,
|
||||||
|
report,
|
||||||
|
meta,
|
||||||
|
setup,
|
||||||
|
warnings: reader.warnings,
|
||||||
|
messages: (result.messages || []).map((m) => `${m.type}: ${m.message}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { docxToDoc, imagePixelSize, imageSizePt, classifyParagraph, bytesToBase64 };
|
||||||
|
});
|
||||||
378
bundled-addons/docx-editor/lib/schema.js
Normal file
378
bundled-addons/docx-editor/lib/schema.js
Normal file
|
|
@ -0,0 +1,378 @@
|
||||||
|
// 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 };
|
||||||
|
});
|
||||||
576
bundled-addons/docx-editor/lib/write.js
Normal file
576
bundled-addons/docx-editor/lib/write.js
Normal file
|
|
@ -0,0 +1,576 @@
|
||||||
|
// 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 };
|
||||||
|
});
|
||||||
221
bundled-addons/docx-editor/panel.html
Normal file
221
bundled-addons/docx-editor/panel.html
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Word editor</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light dark;
|
||||||
|
--bg:#0e131c; --panel:#141a24; --panel2:#191f2b; --line:rgba(255,255,255,.09);
|
||||||
|
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; --danger:#ff5b5b; }
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root { --bg:#f8faff; --panel:#ffffff; --panel2:#eff3fb; --line:rgba(0,0,0,.10);
|
||||||
|
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; --acid:#0AC18E; }
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; height: 100%; }
|
||||||
|
body { background: var(--bg); color: var(--ink);
|
||||||
|
font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
display: flex; flex-direction: column; }
|
||||||
|
h1 { font-size: 13px; font-weight: 600; margin: 0; }
|
||||||
|
.head { display: flex; align-items: center; gap: 8px; padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--line); background: var(--panel); }
|
||||||
|
.head .ico { font-size: 15px; }
|
||||||
|
.body { flex: 1; overflow: auto; padding: 12px; }
|
||||||
|
.lede { color: var(--mut); font-size: 12px; margin: 0 0 12px; }
|
||||||
|
.btn { border: 1px solid var(--line); background: var(--panel2); color: var(--ink);
|
||||||
|
border-radius: 7px; cursor: pointer; padding: 8px 10px; font: inherit;
|
||||||
|
display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; }
|
||||||
|
.btn:hover { border-color: rgb(from var(--acid) r g b / .55); }
|
||||||
|
.btn.primary { background: var(--acid); color: #101418; border-color: transparent; font-weight: 600; }
|
||||||
|
.btn.primary:hover { filter: brightness(1.06); }
|
||||||
|
.btn + .btn { margin-top: 8px; }
|
||||||
|
.btn .sub { display: block; font-size: 11px; color: var(--dim); font-weight: 400; }
|
||||||
|
.btn.primary .sub { color: rgba(16,20,24,.7); }
|
||||||
|
.drop { border: 1.5px dashed var(--line); border-radius: 8px; padding: 18px 12px;
|
||||||
|
text-align: center; color: var(--dim); font-size: 12px; margin: 12px 0; }
|
||||||
|
.drop.over { border-color: var(--acid); color: var(--ink); }
|
||||||
|
h2 { font-size: 11px; text-transform: uppercase; letter-spacing: .05em;
|
||||||
|
color: var(--dim); margin: 18px 0 8px; font-weight: 600; }
|
||||||
|
.doc { display: flex; align-items: center; gap: 8px; padding: 7px 8px;
|
||||||
|
border: 1px solid var(--line); border-radius: 7px; background: var(--panel);
|
||||||
|
margin-bottom: 6px; cursor: pointer; }
|
||||||
|
.doc:hover { border-color: rgb(from var(--acid) r g b / .5); }
|
||||||
|
.doc .meta { flex: 1; min-width: 0; }
|
||||||
|
.doc .name { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.doc .when { font-size: 10.5px; color: var(--dim); }
|
||||||
|
.doc .x { border: 0; background: transparent; color: var(--dim); cursor: pointer;
|
||||||
|
font: inherit; padding: 2px 5px; border-radius: 5px; }
|
||||||
|
.doc .x:hover { color: var(--danger); background: var(--panel2); }
|
||||||
|
.tag { font-size: 9.5px; padding: 1px 5px; border-radius: 99px; border: 1px solid var(--line); color: var(--dim); }
|
||||||
|
.empty { color: var(--dim); font-size: 12px; padding: 6px 0; }
|
||||||
|
.foot { border-top: 1px solid var(--line); padding: 8px 12px; background: var(--panel);
|
||||||
|
font-size: 11px; color: var(--dim); display: flex; gap: 8px; align-items: center; }
|
||||||
|
.foot button { border: 1px solid var(--line); background: var(--panel2); color: var(--ink);
|
||||||
|
border-radius: 5px; cursor: pointer; padding: 3px 7px; font: inherit; font-size: 11px; }
|
||||||
|
.foot button:hover { border-color: rgb(from var(--acid) r g b / .55); }
|
||||||
|
.msg { min-height: 16px; font-size: 11.5px; color: var(--mut); margin-top: 8px; }
|
||||||
|
.msg.err { color: var(--danger); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="head"><span class="ico">📝</span><h1>Word editor</h1></div>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
<p class="lede">Open a .docx in a full tab. Headers, footers, footnotes, page setup and
|
||||||
|
the document's own styles survive a save; a few Word features don't, and the editor says
|
||||||
|
which before you start.</p>
|
||||||
|
|
||||||
|
<button class="btn primary" id="new">
|
||||||
|
<span>📄</span><span>New document<span class="sub">A blank page</span></span>
|
||||||
|
</button>
|
||||||
|
<button class="btn" id="open">
|
||||||
|
<span>📂</span><span>Open a .docx…<span class="sub">From this computer</span></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="drop" id="drop">or drop a .docx here</div>
|
||||||
|
|
||||||
|
<h2>Recent</h2>
|
||||||
|
<div id="recent"><div class="empty">Nothing yet.</div></div>
|
||||||
|
|
||||||
|
<div class="msg" id="msg"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="foot">
|
||||||
|
<span style="flex:1">Documents are kept in a scratch folder.</span>
|
||||||
|
<button id="folder">Folder</button>
|
||||||
|
<button id="clear">Clear</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="file" id="file" accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document" hidden>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const SM = () => window.silentmode;
|
||||||
|
|
||||||
|
function say(text, err) {
|
||||||
|
const el = $("msg");
|
||||||
|
el.textContent = text || "";
|
||||||
|
el.classList.toggle("err", !!err);
|
||||||
|
clearTimeout(say._t);
|
||||||
|
if (text) say._t = setTimeout(() => { el.textContent = ""; el.classList.remove("err"); }, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ago(ts) {
|
||||||
|
const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
|
||||||
|
if (s < 60) return "just now";
|
||||||
|
if (s < 3600) return `${Math.round(s / 60)} min ago`;
|
||||||
|
if (s < 86400) return `${Math.round(s / 3600)} h ago`;
|
||||||
|
return new Date(ts).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesLabel(n) {
|
||||||
|
return n > 1048576 ? `${(n / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toBase64(file) {
|
||||||
|
const buf = new Uint8Array(await file.arrayBuffer());
|
||||||
|
let s = "";
|
||||||
|
for (let i = 0; i < buf.length; i += 0x8000) {
|
||||||
|
s += String.fromCharCode.apply(null, buf.subarray(i, i + 0x8000));
|
||||||
|
}
|
||||||
|
return btoa(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
let list = [];
|
||||||
|
try { list = await SM().invoke("listRecent", {}); }
|
||||||
|
catch (e) { say("Couldn't read the recent list.", true); return; }
|
||||||
|
const host = $("recent");
|
||||||
|
host.innerHTML = "";
|
||||||
|
if (!list.length) {
|
||||||
|
host.innerHTML = '<div class="empty">Nothing yet.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const d of list) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "doc";
|
||||||
|
row.title = "Open in the editor";
|
||||||
|
const meta = document.createElement("div");
|
||||||
|
meta.className = "meta";
|
||||||
|
const name = document.createElement("div");
|
||||||
|
name.className = "name";
|
||||||
|
name.textContent = d.name;
|
||||||
|
const when = document.createElement("div");
|
||||||
|
when.className = "when";
|
||||||
|
when.textContent = `${ago(d.at)} · ${bytesLabel(d.bytes)}`;
|
||||||
|
if (d.kind === "saved") {
|
||||||
|
const tag = document.createElement("span");
|
||||||
|
tag.className = "tag";
|
||||||
|
tag.textContent = "autosaved";
|
||||||
|
when.append(" ", tag);
|
||||||
|
}
|
||||||
|
meta.append(name, when);
|
||||||
|
const x = document.createElement("button");
|
||||||
|
x.className = "x";
|
||||||
|
x.textContent = "✕";
|
||||||
|
x.title = "Forget this document";
|
||||||
|
x.addEventListener("click", async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try { await SM().invoke("clearRecent", { id: d.id }); await refresh(); }
|
||||||
|
catch (err) { say("Couldn't remove it.", true); }
|
||||||
|
});
|
||||||
|
row.append(document.createTextNode("📄"), meta, x);
|
||||||
|
row.addEventListener("click", () => openEditor(d.id));
|
||||||
|
host.append(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditor(id) {
|
||||||
|
try { await SM().invoke("openEditor", id ? { id } : {}); }
|
||||||
|
catch (e) { say("Couldn't open the editor: " + (e && e.message || e), true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFile(file) {
|
||||||
|
if (!/\.docx$/i.test(file.name)) { say("That isn't a .docx file.", true); return; }
|
||||||
|
say("Opening…");
|
||||||
|
try {
|
||||||
|
const res = await SM().invoke("stash", { name: file.name, base64: await toBase64(file), kind: "opened" });
|
||||||
|
await openEditor(res.id);
|
||||||
|
await refresh();
|
||||||
|
say("");
|
||||||
|
} catch (e) {
|
||||||
|
say((e && e.message || e).toString(), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$("new").addEventListener("click", () => openEditor(""));
|
||||||
|
$("open").addEventListener("click", () => $("file").click());
|
||||||
|
$("file").addEventListener("change", async (e) => {
|
||||||
|
const f = e.target.files && e.target.files[0];
|
||||||
|
e.target.value = "";
|
||||||
|
if (f) await handleFile(f);
|
||||||
|
});
|
||||||
|
$("folder").addEventListener("click", async () => {
|
||||||
|
try { await SM().invoke("openFolder", {}); } catch (e) { say("Couldn't open the folder.", true); }
|
||||||
|
});
|
||||||
|
$("clear").addEventListener("click", async () => {
|
||||||
|
try { await SM().invoke("clearRecent", {}); await refresh(); say("Cleared."); }
|
||||||
|
catch (e) { say("Couldn't clear the list.", true); }
|
||||||
|
});
|
||||||
|
|
||||||
|
const drop = $("drop");
|
||||||
|
for (const type of ["dragenter", "dragover"]) {
|
||||||
|
drop.addEventListener(type, (e) => { e.preventDefault(); drop.classList.add("over"); });
|
||||||
|
}
|
||||||
|
for (const type of ["dragleave", "drop"]) {
|
||||||
|
drop.addEventListener(type, () => drop.classList.remove("over"));
|
||||||
|
}
|
||||||
|
drop.addEventListener("drop", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const f = Array.from(e.dataTransfer?.files || [])[0];
|
||||||
|
if (f) await handleFile(f);
|
||||||
|
});
|
||||||
|
window.addEventListener("dragover", (e) => e.preventDefault());
|
||||||
|
window.addEventListener("drop", (e) => e.preventDefault());
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); });
|
||||||
|
refresh();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
416
bundled-addons/docx-editor/vendor/LICENSES.txt
vendored
Normal file
416
bundled-addons/docx-editor/vendor/LICENSES.txt
vendored
Normal file
|
|
@ -0,0 +1,416 @@
|
||||||
|
Third-party code bundled into vendor/docx-vendor.js
|
||||||
|
===================================================
|
||||||
|
|
||||||
|
mammoth is shipped with small local patches (colour, paragraph spacing,
|
||||||
|
numbering format); see addon-build/docx-editor/patches.mjs.
|
||||||
|
|
||||||
|
--- mammoth 1.12.3 — BSD-2-Clause ---
|
||||||
|
Copyright (c) 2013, Michael Williamson
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||||
|
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||||
|
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
||||||
|
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||||
|
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||||
|
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
|
||||||
|
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||||
|
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
||||||
|
--- docx 9.7.1 — MIT ---
|
||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2016 Dolan
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
--- jszip 3.10.2 — (MIT OR GPL-3.0-or-later) ---
|
||||||
|
(no licence file in the package; see https://github.com/Stuk/jszip.git)
|
||||||
|
|
||||||
|
--- underscore 1.13.8 — MIT ---
|
||||||
|
Copyright (c) 2009-2022 Jeremy Ashkenas, Julian Gonggrijp, and DocumentCloud and Investigative Reporters & Editors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person
|
||||||
|
obtaining a copy of this software and associated documentation
|
||||||
|
files (the "Software"), to deal in the Software without
|
||||||
|
restriction, including without limitation the rights to use,
|
||||||
|
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the
|
||||||
|
Software is furnished to do so, subject to the following
|
||||||
|
conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be
|
||||||
|
included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||||
|
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||||
|
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||||
|
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||||
|
OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
--- orderedmap 2.1.1 — MIT ---
|
||||||
|
Copyright (C) 2016 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
--- w3c-keyname 2.2.8 — MIT ---
|
||||||
|
Copyright (C) 2016 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
--- rope-sequence 1.3.4 — MIT ---
|
||||||
|
Copyright (C) 2016 by Marijn Haverbeke <marijn@haverbeke.berlin>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
--- prosemirror-state 1.4.4 — MIT ---
|
||||||
|
Copyright (C) 2015-2017 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
--- prosemirror-view 1.42.4 — MIT ---
|
||||||
|
Copyright (C) 2015-2017 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
--- prosemirror-model 1.25.11 — MIT ---
|
||||||
|
Copyright (C) 2015-2017 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
--- prosemirror-schema-basic 1.2.4 — MIT ---
|
||||||
|
Copyright (C) 2015-2017 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
--- prosemirror-schema-list 1.5.1 — MIT ---
|
||||||
|
Copyright (C) 2015-2017 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
--- prosemirror-tables 1.8.5 — MIT ---
|
||||||
|
Copyright (C) 2015-2016 by Marijn Haverbeke <marijnh@gmail.com> and others
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
--- prosemirror-history 1.5.0 — MIT ---
|
||||||
|
Copyright (C) 2015-2017 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
--- prosemirror-commands 1.7.2 — MIT ---
|
||||||
|
Copyright (C) 2015-2017 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
--- prosemirror-keymap 1.2.3 — MIT ---
|
||||||
|
Copyright (C) 2015-2017 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
--- prosemirror-inputrules 1.5.1 — MIT ---
|
||||||
|
Copyright (C) 2015-2017 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
--- prosemirror-dropcursor 1.8.3 — MIT ---
|
||||||
|
Copyright (C) 2015-2017 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
--- prosemirror-gapcursor 1.4.1 — MIT ---
|
||||||
|
Copyright (C) 2015-2017 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
--- prosemirror-transform 1.12.1 — MIT ---
|
||||||
|
Copyright (C) 2015-2017 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
253
bundled-addons/docx-editor/vendor/docx-vendor.js
vendored
Normal file
253
bundled-addons/docx-editor/vendor/docx-vendor.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Reference in a new issue