@sametalmdr/word-editor 0.2.0-alpha → 0.3.0-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +108 -70
- package/LICENSE +21 -21
- package/README.md +286 -247
- package/dist/canvas/box-clipboard.js +238 -238
- package/dist/canvas/box-selection.js +80 -80
- package/dist/canvas/canvas-editor.js +502 -502
- package/dist/canvas/controller.js +717 -717
- package/dist/canvas/geometry.js +131 -131
- package/dist/canvas/gesture.d.ts +0 -1
- package/dist/canvas/gesture.js +289 -289
- package/dist/canvas/handles.js +212 -212
- package/dist/canvas/menu.js +82 -82
- package/dist/canvas/mode-controller.js +81 -81
- package/dist/canvas/nudge.js +108 -108
- package/dist/canvas/overlay.js +149 -149
- package/dist/canvas/snap.js +132 -132
- package/dist/canvas/viewport.js +112 -112
- package/dist/canvas/z-order.js +171 -171
- package/dist/commands/alignment.js +150 -150
- package/dist/commands/box.js +272 -272
- package/dist/commands/clipboard.js +93 -93
- package/dist/commands/graphemes.js +112 -112
- package/dist/commands/marks.js +153 -153
- package/dist/commands/paragraph-props.js +176 -176
- package/dist/commands/size.js +368 -368
- package/dist/commands/text.js +291 -291
- package/dist/docx.d.ts +1 -0
- package/dist/docx.js +15 -13
- package/dist/editor/editing-host.js +434 -434
- package/dist/element.js +41 -41
- package/dist/fonts/OFL-Carlito.txt +94 -0
- package/dist/fonts/calibri-metrics.d.ts +27 -0
- package/dist/fonts/calibri-metrics.js +88 -0
- package/dist/history/history.js +433 -433
- package/dist/index.js +63 -63
- package/dist/input/beforeinput.js +224 -224
- package/dist/input/clipboard.js +107 -107
- package/dist/input/composition.js +341 -341
- package/dist/input/keymap.js +54 -54
- package/dist/io/ooxml/custgeom.js +72 -72
- package/dist/io/ooxml/drawingml.js +232 -232
- package/dist/io/ooxml/edits.js +67 -67
- package/dist/io/ooxml/export-splice.js +880 -880
- package/dist/io/ooxml/export.js +365 -365
- package/dist/io/ooxml/fill.d.ts +93 -8
- package/dist/io/ooxml/fill.js +618 -562
- package/dist/io/ooxml/fit.d.ts +252 -0
- package/dist/io/ooxml/fit.js +598 -0
- package/dist/io/ooxml/import-gate.js +200 -200
- package/dist/io/ooxml/import.js +691 -691
- package/dist/io/ooxml/inflate.js +126 -126
- package/dist/io/ooxml/media.js +87 -87
- package/dist/io/ooxml/ns.d.ts +2 -0
- package/dist/io/ooxml/ns.js +26 -21
- package/dist/io/ooxml/numbering.js +290 -290
- package/dist/io/ooxml/opc-write.js +171 -171
- package/dist/io/ooxml/provenance.js +93 -93
- package/dist/io/ooxml/recipe.js +708 -708
- package/dist/io/ooxml/relationships.js +59 -59
- package/dist/io/ooxml/run-fonts.d.ts +89 -0
- package/dist/io/ooxml/run-fonts.js +378 -0
- package/dist/io/ooxml/slots.d.ts +4 -0
- package/dist/io/ooxml/slots.js +582 -581
- package/dist/io/ooxml/styles-resolve.js +257 -257
- package/dist/io/ooxml/units.js +43 -43
- package/dist/io/ooxml/value.js +120 -120
- package/dist/io/ooxml/xml-scan.js +616 -616
- package/dist/io/ooxml/zip-container.js +228 -228
- package/dist/io/ooxml/zip-reject.js +106 -106
- package/dist/measure/placement.js +83 -83
- package/dist/measure/report.js +75 -75
- package/dist/model/ids.js +70 -70
- package/dist/model/normalize.js +146 -146
- package/dist/model/position.js +283 -283
- package/dist/model/transaction.js +209 -209
- package/dist/platform/capabilities.js +25 -25
- package/dist/react/index.js +21 -21
- package/dist/selection/controller.js +315 -315
- package/dist/selection/dom-map.js +334 -334
- package/dist/state/editor-state.js +109 -109
- package/dist/styles.css +66 -66
- package/package.json +94 -93
package/dist/io/ooxml/recipe.js
CHANGED
|
@@ -1,708 +1,708 @@
|
|
|
1
|
-
// Template recipes (FR3, ADR-0040): a form becomes a `{tag}` template only
|
|
2
|
-
// through a recipe — a JSON pinned to the SHA-256 of the parts it reads —
|
|
3
|
-
// applied as ONE byte patch of word/document.xml, the same edit in both copies
|
|
4
|
-
// of every box (ADR-0038). Never by saving in Word: Word 2010+ writes a
|
|
5
|
-
// w14:paraId into every paragraph and the copies would part (ADR-0037
|
|
6
|
-
// DOĞRULANMALI 5).
|
|
7
|
-
//
|
|
8
|
-
// Four ops, each addressed by its box's wp:docPr id and, where it matters, a
|
|
9
|
-
// paragraph and a run index, each carrying an expectation that catches an
|
|
10
|
-
// authoring mistake and keeps the recipe readable:
|
|
11
|
-
// setTag the box's one empty value paragraph gets a tag run at its end
|
|
12
|
-
// replaceRuns a contiguous run range becomes one tag run, a named run's rPr kept
|
|
13
|
-
// tagText a substring of one run's text becomes a tag run, the run split
|
|
14
|
-
// fillParagraph the paragraph `expect` names, byte for byte (empty or not), is
|
|
15
|
-
// replaced by a literal, validated paragraph
|
|
16
|
-
//
|
|
17
|
-
// Problems are collected stage by stage — the recipe's shape, the pins, every op
|
|
18
|
-
// and their overlaps, the written template's scan — and each stage's are thrown
|
|
19
|
-
// together; a later stage does not run when an earlier one failed. Nothing is written
|
|
20
|
-
// while any remains. The output is then READ BACK: its w:t + w:br token stream
|
|
21
|
-
// must be the input's with exactly each op's declared change, every touched box's
|
|
22
|
-
// copies are compared again, and the template scan must find exactly the
|
|
23
|
-
// recipe's tags — no problem, no warning. A read-back failure is a bug here, a
|
|
24
|
-
// plain Error; a scan finding is the recipe's, a problem.
|
|
25
|
-
//
|
|
26
|
-
// Internal: not exported from /docx (ADR-0040 Karar 2). It imports neither the
|
|
27
|
-
// importer's entry (import.js) nor the exporter, the style resolver or the model —
|
|
28
|
-
// their gates can refuse a form that templates fine. slots.js's section count
|
|
29
|
-
// reaches the importer's helpers (sections.js -> drawingml.js -> import-gate.js),
|
|
30
|
-
// but no gate is called on that path.
|
|
31
|
-
|
|
32
|
-
import { scanXml, getAttr, isElement, findChild } from "./xml-scan.js";
|
|
33
|
-
import { scanTemplate, compareCopies, collectAlternateContents, textTokens } from "./slots.js";
|
|
34
|
-
import { applyEdits } from "./edits.js";
|
|
35
|
-
import { parseZipContainer, resolveMainDocumentPart } from "./zip-container.js";
|
|
36
|
-
import { inflateEntry } from "./inflate.js";
|
|
37
|
-
import { writePackage } from "./opc-write.js";
|
|
38
|
-
import { tagInfo } from "./fill.js";
|
|
39
|
-
import { W_NS, WP_NS, XML_NS } from "./ns.js";
|
|
40
|
-
|
|
41
|
-
const encoder = new TextEncoder();
|
|
42
|
-
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
43
|
-
const TAG_NAME = /^[a-z][a-z0-9_]*$/;
|
|
44
|
-
const TAG_TEXT = /^\{([a-z][a-z0-9_]*)\}$/;
|
|
45
|
-
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
46
|
-
|
|
47
|
-
/** Elements a fillParagraph fragment may hold (WordprocessingML, local names). */
|
|
48
|
-
const FRAGMENT_ELEMENTS = new Set([
|
|
49
|
-
"p", "pPr", "pStyle", "spacing", "ind", "jc", "tabs", "tab", "rPr", "rFonts", "sz", "szCs", "b", "bCs", "i", "iCs", "color", "position", "r", "t",
|
|
50
|
-
]);
|
|
51
|
-
|
|
52
|
-
const RECIPE_KEYS = new Set(["recipe", "id", "title", "source", "constants", "ops"]);
|
|
53
|
-
/** @type {Record<string, string[]>} */
|
|
54
|
-
const OP_KEYS = {
|
|
55
|
-
setTag: ["op", "docPrId", "tag", "label"],
|
|
56
|
-
replaceRuns: ["op", "docPrId", "paragraph", "runs", "expectTexts", "keepRPrOf", "tag"],
|
|
57
|
-
tagText: ["op", "docPrId", "paragraph", "run", "find", "tag"],
|
|
58
|
-
fillParagraph: ["op", "docPrId", "paragraph", "expect", "paragraphXml"],
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* @typedef {object} RecipeProblem
|
|
63
|
-
* @property {string} code
|
|
64
|
-
* @property {string} message
|
|
65
|
-
* @property {number} [op] index of the op in `ops`
|
|
66
|
-
* @property {number} [docPrId]
|
|
67
|
-
* @property {string} [tag]
|
|
68
|
-
* @property {string} [part]
|
|
69
|
-
*/
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* @typedef {object} Recipe
|
|
73
|
-
* @property {1} recipe
|
|
74
|
-
* @property {string} id
|
|
75
|
-
* @property {string} [title]
|
|
76
|
-
* @property {{parts: Record<string, string>}} source part name -> SHA-256 (hex)
|
|
77
|
-
* @property {{emptyValueParagraph?: string, valueRunRPr?: string}} [constants]
|
|
78
|
-
* @property {any[]} ops
|
|
79
|
-
*/
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* @typedef {object} RecipeReport
|
|
83
|
-
* @property {{id: string, sha256: string}} recipe sha256 of the recipe's canonical JSON
|
|
84
|
-
* @property {import("./fill.js").TagInfo[]} tags the template's tags, as the fill engine sees them
|
|
85
|
-
* @property {Record<string, number>} ops how many of each op
|
|
86
|
-
* @property {{input: string, output: string}} documentXml SHA-256 of word/document.xml before and after
|
|
87
|
-
*/
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* @param {string} message
|
|
91
|
-
* @param {RecipeProblem[]} problems
|
|
92
|
-
* @returns {Error & {problems: RecipeProblem[]}}
|
|
93
|
-
*/
|
|
94
|
-
function recipeError(message, problems) {
|
|
95
|
-
const shown = problems.slice(0, 5).map((p) => (p.op === undefined ? p.code : `${p.code} (op ${p.op})`)).join("; ");
|
|
96
|
-
const more = problems.length > 5 ? `; and ${problems.length - 5} more` : "";
|
|
97
|
-
return Object.assign(new Error(`${message}: ${problems.length} problem(s) — ${shown}${more}`), { problems });
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* JSON with every object's keys sorted, arrays in order — the recipe's identity
|
|
102
|
-
* does not depend on key order or on the line endings a checkout gives the file.
|
|
103
|
-
* @param {unknown} value
|
|
104
|
-
* @returns {string}
|
|
105
|
-
*/
|
|
106
|
-
export function canonicalJson(value) {
|
|
107
|
-
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
108
|
-
if (value !== null && typeof value === "object") {
|
|
109
|
-
const obj = /** @type {Record<string, unknown>} */ (value);
|
|
110
|
-
return `{${Object.keys(obj).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
|
|
111
|
-
}
|
|
112
|
-
return JSON.stringify(value);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* SHA-256 of `bytes` as lowercase hex, through Web Crypto — touched only when
|
|
117
|
-
* called (invariant #5). Node 20+, Deno and browsers on a secure context
|
|
118
|
-
* (127.0.0.1 is one) all have it.
|
|
119
|
-
* @param {Uint8Array} bytes
|
|
120
|
-
* @returns {Promise<string>}
|
|
121
|
-
*/
|
|
122
|
-
export async function sha256Hex(bytes) {
|
|
123
|
-
const subtle = globalThis.crypto?.subtle;
|
|
124
|
-
if (subtle === undefined) throw new Error("recipe: Web Crypto (crypto.subtle) is not available here");
|
|
125
|
-
const digest = new Uint8Array(await subtle.digest("SHA-256", /** @type {BufferSource} */ (bytes)));
|
|
126
|
-
return [...digest].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* @param {Recipe} recipe
|
|
131
|
-
* @returns {Promise<string>} the recipe's identity: SHA-256 of its canonical JSON
|
|
132
|
-
*/
|
|
133
|
-
export function recipeSha256(recipe) {
|
|
134
|
-
return sha256Hex(encoder.encode(canonicalJson(recipe)));
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// --- the recipe's shape ---------------------------------------------------------------
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* @param {unknown} v
|
|
141
|
-
* @returns {v is number}
|
|
142
|
-
*/
|
|
143
|
-
const isIndex = (v) => typeof v === "number" && Number.isInteger(v) && v >= 0;
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Every shape problem of a recipe, in one list (strict: an unknown key is one).
|
|
147
|
-
* @param {unknown} recipe
|
|
148
|
-
* @returns {RecipeProblem[]}
|
|
149
|
-
*/
|
|
150
|
-
export function recipeShapeProblems(recipe) {
|
|
151
|
-
/** @type {RecipeProblem[]} */
|
|
152
|
-
const problems = [];
|
|
153
|
-
/**
|
|
154
|
-
* @param {string} message
|
|
155
|
-
* @param {number} [op]
|
|
156
|
-
*/
|
|
157
|
-
const bad = (message, op) => {
|
|
158
|
-
/** @type {RecipeProblem} */
|
|
159
|
-
const p = { code: "RECIPE_SHAPE", message };
|
|
160
|
-
if (op !== undefined) p.op = op;
|
|
161
|
-
problems.push(p);
|
|
162
|
-
};
|
|
163
|
-
if (recipe === null || typeof recipe !== "object" || Array.isArray(recipe)) {
|
|
164
|
-
bad("a recipe is a JSON object");
|
|
165
|
-
return problems;
|
|
166
|
-
}
|
|
167
|
-
const r = /** @type {Record<string, any>} */ (recipe);
|
|
168
|
-
for (const key of Object.keys(r)) if (!RECIPE_KEYS.has(key)) bad(`unknown key "${key}"`);
|
|
169
|
-
if (r.recipe !== 1) bad("recipe must be 1 (the format version)");
|
|
170
|
-
if (typeof r.id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(r.id)) bad("id must be a lowercase name");
|
|
171
|
-
if (r.title !== undefined && typeof r.title !== "string") bad("title must be a string");
|
|
172
|
-
const parts = r.source?.parts;
|
|
173
|
-
if (r.source === null || typeof r.source !== "object" || Object.keys(r.source).some((k) => k !== "parts")) bad("source must be {parts}");
|
|
174
|
-
if (parts === null || typeof parts !== "object" || !("word/document.xml" in parts)) bad("source.parts must pin word/document.xml");
|
|
175
|
-
else for (const [name, sha] of Object.entries(parts)) if (typeof sha !== "string" || !SHA256_HEX.test(sha)) bad(`source.parts["${name}"] must be a SHA-256 in lowercase hex`);
|
|
176
|
-
if (r.constants !== undefined) {
|
|
177
|
-
if (r.constants === null || typeof r.constants !== "object") bad("constants must be an object");
|
|
178
|
-
else {
|
|
179
|
-
for (const key of Object.keys(r.constants)) if (key !== "emptyValueParagraph" && key !== "valueRunRPr") bad(`unknown constant "${key}"`);
|
|
180
|
-
for (const [key, v] of Object.entries(r.constants)) if (typeof v !== "string" || v === "") bad(`constants.${key} must be a non-empty string`);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
if (!Array.isArray(r.ops) || r.ops.length === 0) {
|
|
184
|
-
bad("ops must be a non-empty list");
|
|
185
|
-
return problems;
|
|
186
|
-
}
|
|
187
|
-
r.ops.forEach((/** @type {any} */ op, /** @type {number} */ i) => {
|
|
188
|
-
const allowed = op !== null && typeof op === "object" && Object.hasOwn(OP_KEYS, op.op) ? OP_KEYS[op.op] : undefined;
|
|
189
|
-
if (allowed === undefined) {
|
|
190
|
-
bad(`unknown op ${JSON.stringify(op?.op)}`, i);
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
for (const key of Object.keys(op)) if (!allowed.includes(key)) bad(`${op.op}: unknown key "${key}"`, i);
|
|
194
|
-
if (!isIndex(op.docPrId)) bad(`${op.op}: docPrId must be a non-negative integer`, i);
|
|
195
|
-
if (op.op !== "fillParagraph") {
|
|
196
|
-
if (typeof op.tag !== "string" || !TAG_NAME.test(op.tag)) bad(`${op.op}: tag must match [a-z][a-z0-9_]*`, i);
|
|
197
|
-
}
|
|
198
|
-
if (op.op !== "setTag" && !isIndex(op.paragraph)) bad(`${op.op}: paragraph must be a non-negative integer`, i);
|
|
199
|
-
if (op.op === "setTag") {
|
|
200
|
-
if (!isIndex(op.label)) bad("setTag: label must be the docPr id of the neighbouring label box", i);
|
|
201
|
-
if (typeof r.constants?.emptyValueParagraph !== "string" || typeof r.constants?.valueRunRPr !== "string") {
|
|
202
|
-
bad("setTag needs constants.emptyValueParagraph and constants.valueRunRPr", i);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
if (op.op === "replaceRuns") {
|
|
206
|
-
const [from, to] = Array.isArray(op.runs) ? op.runs : [];
|
|
207
|
-
if (!Array.isArray(op.runs) || op.runs.length !== 2 || !isIndex(from) || !isIndex(to) || !(from < to)) bad("replaceRuns: runs must be [from, to) with from < to", i);
|
|
208
|
-
else {
|
|
209
|
-
if (!Array.isArray(op.expectTexts) || op.expectTexts.length !== to - from || !op.expectTexts.every((/** @type {unknown} */ t) => typeof t === "string")) {
|
|
210
|
-
bad("replaceRuns: expectTexts must hold one string per run in the range", i);
|
|
211
|
-
}
|
|
212
|
-
if (!isIndex(op.keepRPrOf) || op.keepRPrOf < from || op.keepRPrOf >= to) bad("replaceRuns: keepRPrOf must be a run index inside the range", i);
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
if (op.op === "tagText") {
|
|
216
|
-
if (!isIndex(op.run)) bad("tagText: run must be a non-negative integer", i);
|
|
217
|
-
if (typeof op.find !== "string" || op.find === "") bad("tagText: find must be a non-empty string", i);
|
|
218
|
-
}
|
|
219
|
-
if (op.op === "fillParagraph") {
|
|
220
|
-
if (typeof op.expect !== "string" || op.expect === "") bad("fillParagraph: expect must be the paragraph's exact XML", i);
|
|
221
|
-
if (typeof op.paragraphXml !== "string" || op.paragraphXml === "") bad("fillParagraph: paragraphXml must be a paragraph's XML", i);
|
|
222
|
-
}
|
|
223
|
-
});
|
|
224
|
-
return problems;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// --- locating boxes, paragraphs and runs ----------------------------------------------
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* @typedef {object} Box
|
|
231
|
-
* @property {number} acIndex
|
|
232
|
-
* @property {import("./xml-scan.js").XmlElement} choice the Choice copy's w:txbxContent
|
|
233
|
-
* @property {import("./xml-scan.js").XmlElement} fallback the Fallback copy's w:txbxContent
|
|
234
|
-
*/
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* Every box (an AlternateContent in the fill engine's shape) by its docPr id,
|
|
238
|
-
* with how many boxes carry that id.
|
|
239
|
-
* @param {import("./xml-scan.js").XmlElement} root
|
|
240
|
-
* @returns {{boxes: Map<number, Box>, counts: Map<number, number>, malformed: Set<number>}}
|
|
241
|
-
*/
|
|
242
|
-
function locateBoxes(root) {
|
|
243
|
-
/** @type {Map<number, Box>} */
|
|
244
|
-
const boxes = new Map();
|
|
245
|
-
/** @type {Map<number, number>} */
|
|
246
|
-
const counts = new Map();
|
|
247
|
-
/** @type {Set<number>} */
|
|
248
|
-
const malformed = new Set();
|
|
249
|
-
for (const ac of collectAlternateContents(root)) {
|
|
250
|
-
for (const anchor of ac.choiceAnchors) {
|
|
251
|
-
const docPr = findChild(anchor, WP_NS, "docPr");
|
|
252
|
-
const raw = docPr ? getAttr(docPr, "", "id") : undefined;
|
|
253
|
-
if (raw === undefined || !/^[0-9]+$/.test(raw)) continue;
|
|
254
|
-
const id = Number(raw);
|
|
255
|
-
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
256
|
-
const shaped =
|
|
257
|
-
!ac.nested && ac.choices.length === 1 && ac.fallbacks.length === 1 && ac.choiceTx.length === 1 && ac.fallbackTx.length === 1 &&
|
|
258
|
-
ac.choiceAnchors.length === 1 && isElement(anchor, WP_NS, "anchor");
|
|
259
|
-
if (shaped) boxes.set(id, { acIndex: ac.index, choice: ac.choiceTx[0], fallback: ac.fallbackTx[0] });
|
|
260
|
-
else malformed.add(id);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
return { boxes, counts, malformed };
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* @param {import("./xml-scan.js").XmlElement} el
|
|
268
|
-
* @param {string} localName
|
|
269
|
-
* @returns {import("./xml-scan.js").XmlElement[]}
|
|
270
|
-
*/
|
|
271
|
-
function wChildren(el, localName) {
|
|
272
|
-
return /** @type {import("./xml-scan.js").XmlElement[]} */ (el.children.filter((c) => c.kind === "element" && c.uri === W_NS && c.localName === localName));
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* The text a run shows: its w:t texts, "\t" for a w:tab. Null when it holds
|
|
277
|
-
* anything else (a break, a drawing, a field) — not a run a recipe rewrites.
|
|
278
|
-
* @param {import("./xml-scan.js").XmlElement} run
|
|
279
|
-
* @returns {string | null}
|
|
280
|
-
*/
|
|
281
|
-
function runText(run) {
|
|
282
|
-
let text = "";
|
|
283
|
-
for (const c of run.children) {
|
|
284
|
-
if (c.kind === "text" && c.value.trim() === "") continue;
|
|
285
|
-
if (c.kind !== "element" || c.uri !== W_NS) return null;
|
|
286
|
-
if (c.localName === "rPr") continue;
|
|
287
|
-
if (c.localName === "t") text += c.children.map((x) => (x.kind === "text" || x.kind === "cdata" ? x.value : "")).join("");
|
|
288
|
-
else if (c.localName === "tab") text += "\t";
|
|
289
|
-
else return null;
|
|
290
|
-
}
|
|
291
|
-
return text;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* A run's rPr as a comparable key with every w:spacing left out — the PDF
|
|
296
|
-
* converter wrote one per word to imitate justification (ADR-0040 Bağlam).
|
|
297
|
-
* @param {import("./xml-scan.js").XmlElement | undefined} rPr
|
|
298
|
-
* @returns {string}
|
|
299
|
-
*/
|
|
300
|
-
function rPrKeyWithoutSpacing(rPr) {
|
|
301
|
-
if (rPr === undefined) return "";
|
|
302
|
-
/** @param {import("./xml-scan.js").XmlElement} el @returns {string} */
|
|
303
|
-
const key = (el) =>
|
|
304
|
-
`${el.uri}|${el.localName}|${el.attributes.map((a) => `${a.uri}|${a.localName}=${a.value}`).sort().join(",")}|` +
|
|
305
|
-
el.children.filter((c) => c.kind === "element").map((c) => key(/** @type {import("./xml-scan.js").XmlElement} */ (c))).join(";");
|
|
306
|
-
return rPr.children
|
|
307
|
-
.filter((c) => c.kind === "element" && !(c.uri === W_NS && c.localName === "spacing"))
|
|
308
|
-
.map((c) => key(/** @type {import("./xml-scan.js").XmlElement} */ (c)))
|
|
309
|
-
.join(";");
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/**
|
|
313
|
-
* Whether an element's start tag declares a namespace. The recipe drops the
|
|
314
|
-
* start tags it replaces (a paragraph, a run), so a prefix bound there would
|
|
315
|
-
* come loose in the bytes it keeps or writes.
|
|
316
|
-
* @param {import("./xml-scan.js").XmlElement} el
|
|
317
|
-
* @param {Uint8Array} xmlBytes
|
|
318
|
-
* @returns {boolean}
|
|
319
|
-
*/
|
|
320
|
-
function declaresNamespace(el, xmlBytes) {
|
|
321
|
-
return /\sxmlns(:|\s*=)/.test(decoder.decode(xmlBytes.subarray(el.start, el.contentStart)));
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/** @typedef {{kind: "t" | "br", text?: string}} Token a w:t (with its text) or a w:br */
|
|
325
|
-
|
|
326
|
-
/**
|
|
327
|
-
* @typedef {object} PlannedEdit
|
|
328
|
-
* @property {number} start
|
|
329
|
-
* @property {number} end
|
|
330
|
-
* @property {Uint8Array} bytes
|
|
331
|
-
* @property {Token[]} tokens the w:t / w:br tokens the new bytes hold
|
|
332
|
-
* @property {number} op
|
|
333
|
-
*/
|
|
334
|
-
|
|
335
|
-
/**
|
|
336
|
-
* The tokens (w:t texts in order) of a well-formed XML fragment.
|
|
337
|
-
* @param {import("./xml-scan.js").XmlElement} el
|
|
338
|
-
* @returns {Token[]}
|
|
339
|
-
*/
|
|
340
|
-
function fragmentTokens(el) {
|
|
341
|
-
/** @type {Token[]} */
|
|
342
|
-
const out = [];
|
|
343
|
-
for (const c of el.children) {
|
|
344
|
-
if (c.kind !== "element") continue;
|
|
345
|
-
if (c.uri === W_NS && c.localName === "t") out.push({ kind: "t", text: c.children.map((x) => (x.kind === "text" ? x.value : "")).join("") });
|
|
346
|
-
else if (c.uri === W_NS && c.localName === "br") out.push({ kind: "br" });
|
|
347
|
-
else out.push(...fragmentTokens(c));
|
|
348
|
-
}
|
|
349
|
-
return out;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
/**
|
|
353
|
-
* Checks a fillParagraph fragment: one w:p of allowed elements, text only inside
|
|
354
|
-
* a w:t, every w:t plain text that is either a whole `{tag}` or holds no brace.
|
|
355
|
-
* Returns its tags and tokens.
|
|
356
|
-
* @param {string} xml
|
|
357
|
-
* @returns {{problem: string} | {tags: string[], tokens: Token[]}}
|
|
358
|
-
*/
|
|
359
|
-
function readFragment(xml) {
|
|
360
|
-
let root;
|
|
361
|
-
try {
|
|
362
|
-
root = scanXml(encoder.encode(`<w:fragment xmlns:w="${W_NS}">${xml}</w:fragment>`)).root;
|
|
363
|
-
} catch (err) {
|
|
364
|
-
return { problem: `the fragment does not parse — ${/** @type {Error} */ (err).message}` };
|
|
365
|
-
}
|
|
366
|
-
const kids = root.children.filter((c) => !(c.kind === "text" && c.value.trim() === ""));
|
|
367
|
-
if (kids.length !== 1 || kids[0].kind !== "element" || !isElement(kids[0], W_NS, "p")) return { problem: "the fragment must be exactly one w:p" };
|
|
368
|
-
/** @type {string[]} */
|
|
369
|
-
const tags = [];
|
|
370
|
-
/** @type {string | null} */
|
|
371
|
-
let problem = null;
|
|
372
|
-
/** @param {import("./xml-scan.js").XmlElement} el */
|
|
373
|
-
const visit = (el) => {
|
|
374
|
-
const name = `${el.prefix ? `${el.prefix}:` : ""}${el.localName}`;
|
|
375
|
-
if (el.uri !== W_NS || !FRAGMENT_ELEMENTS.has(el.localName)) problem ??= `<${name}> is not allowed in a fillParagraph fragment`;
|
|
376
|
-
if (el.localName === "t" && el.uri === W_NS) {
|
|
377
|
-
if (el.children.some((x) => x.kind !== "text")) problem ??= "a fragment w:t holds something other than plain text";
|
|
378
|
-
const text = el.children.map((x) => (x.kind === "text" ? x.value : "")).join("");
|
|
379
|
-
const tag = TAG_TEXT.exec(text);
|
|
380
|
-
if (tag !== null) tags.push(tag[1]);
|
|
381
|
-
else if (text.includes("{") || text.includes("}")) problem ??= "a fragment w:t holds a brace that is not a whole {tag}";
|
|
382
|
-
} else if (el.children.some((x) => x.kind === "cdata" || (x.kind === "text" && x.value.trim() !== ""))) {
|
|
383
|
-
problem ??= `<${name}> holds text outside a w:t`;
|
|
384
|
-
}
|
|
385
|
-
for (const c of el.children) if (c.kind === "element") visit(c);
|
|
386
|
-
};
|
|
387
|
-
visit(/** @type {import("./xml-scan.js").XmlElement} */ (kids[0]));
|
|
388
|
-
if (problem !== null) return { problem };
|
|
389
|
-
if (tags.length === 0) return { problem: "the fragment holds no {tag}" };
|
|
390
|
-
return { tags, tokens: fragmentTokens(root) };
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
/**
|
|
394
|
-
* @typedef {object} Target
|
|
395
|
-
* @property {import("./xml-scan.js").XmlElement} p the addressed paragraph
|
|
396
|
-
* @property {import("./xml-scan.js").XmlElement[]} runs the addressed runs (replaceRuns, tagText)
|
|
397
|
-
* @property {number} start the bytes the op reads, compared between the copies
|
|
398
|
-
* @property {number} end
|
|
399
|
-
* @property {number} editStart the bytes the op replaces; an empty range inserts
|
|
400
|
-
* @property {number} editEnd
|
|
401
|
-
*/
|
|
402
|
-
|
|
403
|
-
/**
|
|
404
|
-
* @typedef {{code: string, message: string}} Miss a problem without its op's address
|
|
405
|
-
*/
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* Finds what an op addresses in one copy of its box — structure only.
|
|
409
|
-
* @param {any} op
|
|
410
|
-
* @param {import("./xml-scan.js").XmlElement} txbx
|
|
411
|
-
* @returns {Target | Miss}
|
|
412
|
-
*/
|
|
413
|
-
function locate(op, txbx) {
|
|
414
|
-
const paragraphs = wChildren(txbx, "p");
|
|
415
|
-
if (op.op === "setTag" && paragraphs.length !== 1) {
|
|
416
|
-
return { code: "EXPECT_MISMATCH", message: `the box holds ${paragraphs.length} paragraphs; setTag expects one, constants.emptyValueParagraph` };
|
|
417
|
-
}
|
|
418
|
-
const p = paragraphs[op.op === "setTag" ? 0 : op.paragraph];
|
|
419
|
-
if (p === undefined) return { code: "PARAGRAPH_NOT_FOUND", message: `the box has ${paragraphs.length} paragraph(s), no index ${op.paragraph}` };
|
|
420
|
-
// Every literal the recipe writes says w:, bound to WordprocessingML above it.
|
|
421
|
-
if (p.prefix !== "w") return { code: "PREFIX_UNSUPPORTED", message: "the recipe writes the w: prefix, the paragraph does not use it" };
|
|
422
|
-
if (op.op === "setTag") return { p, runs: [], start: p.start, end: p.end, editStart: p.contentEnd, editEnd: p.contentEnd };
|
|
423
|
-
if (op.op === "fillParagraph") return { p, runs: [], start: p.start, end: p.end, editStart: p.start, editEnd: p.end };
|
|
424
|
-
const runs = wChildren(p, "r");
|
|
425
|
-
const [from, to] = op.op === "replaceRuns" ? op.runs : [op.run, op.run + 1];
|
|
426
|
-
if (to > runs.length) return { code: "RUN_RANGE", message: `the paragraph has ${runs.length} run(s), the op needs run ${to - 1}` };
|
|
427
|
-
const range = runs.slice(from, to);
|
|
428
|
-
const start = range[0].start;
|
|
429
|
-
const end = range[range.length - 1].end;
|
|
430
|
-
return { p, runs: range, start, end, editStart: start, editEnd: end };
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
/**
|
|
434
|
-
* What an op writes, read from one copy — its addressed bytes are the other
|
|
435
|
-
* copy's too. Checks every expectation the op carries.
|
|
436
|
-
* @param {any} op
|
|
437
|
-
* @param {Target} target
|
|
438
|
-
* @param {Uint8Array} xmlBytes
|
|
439
|
-
* @param {{emptyValueParagraph?: string, valueRunRPr?: string}} constants
|
|
440
|
-
* @returns {{bytes: Uint8Array, tokens: Token[], tags: string[]} | Miss}
|
|
441
|
-
*/
|
|
442
|
-
function plan(op, { p, runs }, xmlBytes, constants) {
|
|
443
|
-
const source = (/** @type {{start: number, end: number}} */ el) => decoder.decode(xmlBytes.subarray(el.start, el.end));
|
|
444
|
-
const tagRun = (/** @type {string} */ rPr) => `<w:r>${rPr}<w:t>{${op.tag}}</w:t></w:r>`;
|
|
445
|
-
/** @type {Token[]} */
|
|
446
|
-
const tagToken = [{ kind: "t", text: `{${op.tag}}` }];
|
|
447
|
-
|
|
448
|
-
if (op.op === "setTag") {
|
|
449
|
-
if (source(p) !== constants.emptyValueParagraph) return { code: "EXPECT_MISMATCH", message: "the box's paragraph is not constants.emptyValueParagraph" };
|
|
450
|
-
return { bytes: encoder.encode(tagRun(/** @type {string} */ (constants.valueRunRPr))), tokens: tagToken, tags: [op.tag] };
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
if (op.op === "fillParagraph") {
|
|
454
|
-
if (source(p) !== op.expect) return { code: "EXPECT_MISMATCH", message: "the paragraph is not the one `expect` names" };
|
|
455
|
-
if (declaresNamespace(p, xmlBytes)) return { code: "PREFIX_UNSUPPORTED", message: "the paragraph declares a namespace, which the new paragraph would leave out" };
|
|
456
|
-
const fragment = readFragment(op.paragraphXml);
|
|
457
|
-
if ("problem" in fragment) return { code: "FRAGMENT_INVALID", message: fragment.problem };
|
|
458
|
-
return { bytes: encoder.encode(op.paragraphXml), tokens: fragment.tokens, tags: fragment.tags };
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
if (op.op === "replaceRuns") {
|
|
462
|
-
const from = op.runs[0];
|
|
463
|
-
for (let k = 0; k + 1 < runs.length; k += 1) {
|
|
464
|
-
if (runs[k].end !== runs[k + 1].start) return { code: "RUNS_NOT_CONTIGUOUS", message: `something stands between run ${from + k} and run ${from + k + 1}` };
|
|
465
|
-
}
|
|
466
|
-
const texts = runs.map(runText);
|
|
467
|
-
if (texts.some((t) => t === null)) return { code: "RUN_CONTENT_UNSUPPORTED", message: "a run in the range holds more than rPr, w:t and w:tab" };
|
|
468
|
-
if (texts.some((t, k) => t !== op.expectTexts[k])) return { code: "EXPECT_MISMATCH", message: "the runs' texts are not expectTexts" };
|
|
469
|
-
const keys = runs.map((r) => rPrKeyWithoutSpacing(findChild(r, W_NS, "rPr")));
|
|
470
|
-
if (new Set(keys).size !== 1) return { code: "RPR_DIFFER", message: "the runs' rPr differ beyond w:spacing — one tag run would change their look" };
|
|
471
|
-
const keptRun = runs[op.keepRPrOf - from];
|
|
472
|
-
if (declaresNamespace(keptRun, xmlBytes)) return { code: "PREFIX_UNSUPPORTED", message: "the run whose rPr is kept declares a namespace, which its rPr would leave behind" };
|
|
473
|
-
const kept = findChild(keptRun, W_NS, "rPr");
|
|
474
|
-
return { bytes: encoder.encode(tagRun(kept === undefined ? "" : source(kept))), tokens: tagToken, tags: [op.tag] };
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
// tagText: the run becomes [before] {tag} [after], every piece in the run's own rPr.
|
|
478
|
-
const run = runs[0];
|
|
479
|
-
const ts = wChildren(run, "t");
|
|
480
|
-
const other = run.children.filter((c) => c.kind === "element" && !(c.uri === W_NS && (c.localName === "t" || c.localName === "rPr")));
|
|
481
|
-
if (ts.length !== 1 || other.length > 0) return { code: "RUN_CONTENT_UNSUPPORTED", message: "tagText splits a run holding rPr and exactly one w:t" };
|
|
482
|
-
const t = ts[0];
|
|
483
|
-
if (t.attributes.some((a) => !(a.uri === XML_NS && a.localName === "space" && a.value === "preserve"))) {
|
|
484
|
-
return { code: "RUN_CONTENT_UNSUPPORTED", message: "the run's w:t carries attributes other than xml:space=\"preserve\"" };
|
|
485
|
-
}
|
|
486
|
-
const only = t.children.length === 1 ? t.children[0] : null;
|
|
487
|
-
const text = only !== null && only.kind === "text" ? only.value : null;
|
|
488
|
-
if (text === null || decoder.decode(xmlBytes.subarray(t.contentStart, t.contentEnd)) !== text) {
|
|
489
|
-
return { code: "TEXT_ENCODED", message: "the run's text is not one plain text node written as it reads (no entity, no character reference)" };
|
|
490
|
-
}
|
|
491
|
-
const at = text.indexOf(op.find);
|
|
492
|
-
if (at < 0 || text.indexOf(op.find, at + 1) >= 0) return { code: "TEXT_NOT_FOUND", message: "`find` must occur exactly once in the run's text" };
|
|
493
|
-
if (declaresNamespace(run, xmlBytes)) return { code: "PREFIX_UNSUPPORTED", message: "the run declares a namespace, which its rPr would leave behind" };
|
|
494
|
-
const kept = findChild(run, W_NS, "rPr");
|
|
495
|
-
const rPr = kept === undefined ? "" : source(kept);
|
|
496
|
-
const piece = (/** @type {string} */ s) => `<w:r>${rPr}<w:t${/^\s|\s$/.test(s) ? ' xml:space="preserve"' : ""}>${s}</w:t></w:r>`;
|
|
497
|
-
const before = text.slice(0, at);
|
|
498
|
-
const after = text.slice(at + op.find.length);
|
|
499
|
-
/** @type {Token[]} */
|
|
500
|
-
const tokens = [];
|
|
501
|
-
let xml = "";
|
|
502
|
-
if (before !== "") {
|
|
503
|
-
xml += piece(before);
|
|
504
|
-
tokens.push({ kind: "t", text: before });
|
|
505
|
-
}
|
|
506
|
-
xml += tagRun(rPr);
|
|
507
|
-
tokens.push(...tagToken);
|
|
508
|
-
if (after !== "") {
|
|
509
|
-
xml += piece(after);
|
|
510
|
-
tokens.push({ kind: "t", text: after });
|
|
511
|
-
}
|
|
512
|
-
return { bytes: encoder.encode(xml), tokens, tags: [op.tag] };
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
// --- applying ---------------------------------------------------------------------------
|
|
516
|
-
|
|
517
|
-
/**
|
|
518
|
-
* Applies a recipe to word/document.xml. Every problem — the recipe's shape, the
|
|
519
|
-
* pins, every op's target and expectation — is collected and thrown together;
|
|
520
|
-
* otherwise the patched part comes back with a report. `parts` supplies the
|
|
521
|
-
* other parts the recipe pins (e.g. word/styles.xml), by name.
|
|
522
|
-
* @param {Uint8Array} xmlBytes
|
|
523
|
-
* @param {Recipe} recipe
|
|
524
|
-
* @param {{parts?: Record<string, Uint8Array>}} [options]
|
|
525
|
-
* @returns {Promise<{xml: Uint8Array, report: RecipeReport}>}
|
|
526
|
-
*/
|
|
527
|
-
export async function applyRecipeToDocumentXml(xmlBytes, recipe, { parts = {} } = {}) {
|
|
528
|
-
const shape = recipeShapeProblems(recipe);
|
|
529
|
-
if (shape.length > 0) throw recipeError("recipe", shape);
|
|
530
|
-
|
|
531
|
-
/** @type {RecipeProblem[]} */
|
|
532
|
-
const problems = [];
|
|
533
|
-
const inputSha = await sha256Hex(xmlBytes);
|
|
534
|
-
for (const [name, want] of Object.entries(recipe.source.parts)) {
|
|
535
|
-
const bytes = name === "word/document.xml" ? xmlBytes : parts[name];
|
|
536
|
-
if (bytes === undefined) {
|
|
537
|
-
problems.push({ code: "PART_MISSING", message: `the recipe pins ${name}, which was not supplied`, part: name });
|
|
538
|
-
continue;
|
|
539
|
-
}
|
|
540
|
-
const got = name === "word/document.xml" ? inputSha : await sha256Hex(bytes);
|
|
541
|
-
if (got !== want) problems.push({ code: "PART_PIN_MISMATCH", message: `${name} is not the part the recipe was written for`, part: name });
|
|
542
|
-
}
|
|
543
|
-
if (problems.length > 0) throw recipeError("recipe", problems);
|
|
544
|
-
|
|
545
|
-
const { root } = scanXml(xmlBytes);
|
|
546
|
-
const { boxes, counts, malformed } = locateBoxes(root);
|
|
547
|
-
const raw = (/** @type {{start: number, end: number}} */ el) => xmlBytes.subarray(el.start, el.end);
|
|
548
|
-
const same = (/** @type {Uint8Array} */ a, /** @type {Uint8Array} */ b) => a.length === b.length && a.every((x, i) => x === b[i]);
|
|
549
|
-
const constants = recipe.constants ?? {};
|
|
550
|
-
|
|
551
|
-
/** @type {PlannedEdit[]} */
|
|
552
|
-
const edits = [];
|
|
553
|
-
/** @type {{tag: string, docPrId: number}[]} */
|
|
554
|
-
const expectedTags = [];
|
|
555
|
-
/** @type {Set<number>} */
|
|
556
|
-
const touchedAcs = new Set();
|
|
557
|
-
/** @type {Record<string, number>} */
|
|
558
|
-
const opCounts = { setTag: 0, replaceRuns: 0, tagText: 0, fillParagraph: 0 };
|
|
559
|
-
|
|
560
|
-
recipe.ops.forEach((/** @type {any} */ op, /** @type {number} */ i) => {
|
|
561
|
-
opCounts[op.op] += 1;
|
|
562
|
-
const problem = (/** @type {string} */ code, /** @type {string} */ message) => problems.push({ code, message, op: i, docPrId: op.docPrId });
|
|
563
|
-
const n = counts.get(op.docPrId) ?? 0;
|
|
564
|
-
if (n !== 1) return problem(n === 0 ? "BOX_NOT_FOUND" : "BOX_NOT_UNIQUE", `${n} boxes carry wp:docPr id ${op.docPrId}`);
|
|
565
|
-
const box = boxes.get(op.docPrId);
|
|
566
|
-
if (box === undefined || malformed.has(op.docPrId)) return problem("BOX_SHAPE", "the box is not one Choice + one Fallback with one w:txbxContent each and a wp:anchor");
|
|
567
|
-
if (op.op === "setTag" && (counts.get(op.label) ?? 0) !== 1) return problem("LABEL_NOT_FOUND", `no single box carries the label's wp:docPr id ${op.label}`);
|
|
568
|
-
if (!compareCopies(box.choice, box.fallback).equal) return problem("COPIES_DIFFER", "the box's two copies are not structurally equal");
|
|
569
|
-
|
|
570
|
-
/** @type {Target[]} */
|
|
571
|
-
const targets = [];
|
|
572
|
-
for (const txbx of [box.choice, box.fallback]) {
|
|
573
|
-
const target = locate(op, txbx);
|
|
574
|
-
if ("code" in target) return problem(target.code, target.message);
|
|
575
|
-
targets.push(target);
|
|
576
|
-
}
|
|
577
|
-
// Structurally equal copies can still be spelled differently; the op reads one and writes both.
|
|
578
|
-
const [c, f] = targets;
|
|
579
|
-
if (!same(raw(c), raw(f))) return problem("COPIES_NOT_IDENTICAL", "the addressed bytes differ between the Choice and the Fallback copy");
|
|
580
|
-
const planned = plan(op, c, xmlBytes, constants);
|
|
581
|
-
if ("code" in planned) return problem(planned.code, planned.message);
|
|
582
|
-
for (const t of targets) edits.push({ start: t.editStart, end: t.editEnd, bytes: planned.bytes, tokens: planned.tokens, op: i });
|
|
583
|
-
touchedAcs.add(box.acIndex);
|
|
584
|
-
for (const tag of planned.tags) expectedTags.push({ tag, docPrId: op.docPrId });
|
|
585
|
-
});
|
|
586
|
-
|
|
587
|
-
// Ops are disjoint — checked here, so the error names the ops, not applyEdits' offsets.
|
|
588
|
-
// Both copies overlap alike; a pair of ops is named once.
|
|
589
|
-
const sorted = [...edits].sort((a, b) => a.start - b.start || a.end - b.end);
|
|
590
|
-
/** @type {Set<string>} */
|
|
591
|
-
const named = new Set();
|
|
592
|
-
for (let k = 0; k + 1 < sorted.length; k += 1) {
|
|
593
|
-
const a = sorted[k];
|
|
594
|
-
const b = sorted[k + 1];
|
|
595
|
-
if (b.start < a.end || (a.start === b.start && a.end === b.end)) {
|
|
596
|
-
const [x, y] = [a.op, b.op].sort((m, n) => m - n);
|
|
597
|
-
if (named.has(`${x},${y}`)) continue;
|
|
598
|
-
named.add(`${x},${y}`);
|
|
599
|
-
problems.push({ code: "OPS_OVERLAP", message: `ops ${x} and ${y} touch the same bytes`, op: y });
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
if (problems.length > 0) throw recipeError("recipe", problems);
|
|
603
|
-
|
|
604
|
-
const xml = applyEdits(xmlBytes, edits.map(({ start, end, bytes }) => ({ start, end, bytes })));
|
|
605
|
-
readBackRecipe(root, xml, edits, touchedAcs);
|
|
606
|
-
|
|
607
|
-
// The template the engine will fill: exactly the recipe's tags, nothing to refuse, nothing to warn about.
|
|
608
|
-
const outRoot = scanXml(xml).root;
|
|
609
|
-
const scan = scanTemplate(outRoot, xml);
|
|
610
|
-
/** @type {RecipeProblem[]} */
|
|
611
|
-
const after = [];
|
|
612
|
-
for (const p of [...scan.problems, ...scan.warnings]) {
|
|
613
|
-
/** @type {RecipeProblem} */
|
|
614
|
-
const q = { code: `TEMPLATE_${p.code}`, message: `the template the recipe writes: ${p.message}` };
|
|
615
|
-
if (p.tag !== undefined) q.tag = p.tag;
|
|
616
|
-
if (typeof p.docPrId === "number") q.docPrId = p.docPrId;
|
|
617
|
-
after.push(q);
|
|
618
|
-
}
|
|
619
|
-
const want = expectedTags.map((x) => `${x.tag}@${x.docPrId}`).sort();
|
|
620
|
-
const got = scan.occurrences.map((o) => `${o.name}@${o.docPrId}`).sort();
|
|
621
|
-
if (want.join("\n") !== got.join("\n")) {
|
|
622
|
-
after.push({ code: "TEMPLATE_TAGS_DIFFER", message: `the template holds ${got.length} tag occurrence(s), the recipe declares ${want.length}` });
|
|
623
|
-
}
|
|
624
|
-
if (after.length > 0) throw recipeError("recipe: the written template", after);
|
|
625
|
-
|
|
626
|
-
return {
|
|
627
|
-
xml,
|
|
628
|
-
report: {
|
|
629
|
-
recipe: { id: recipe.id, sha256: await recipeSha256(recipe) },
|
|
630
|
-
tags: tagInfo(scan),
|
|
631
|
-
ops: opCounts,
|
|
632
|
-
documentXml: { input: inputSha, output: await sha256Hex(xml) },
|
|
633
|
-
},
|
|
634
|
-
};
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
/**
|
|
638
|
-
* Reads the patched part back. A failure is a bug in this module, a plain Error:
|
|
639
|
-
* the output's w:t + w:br tokens must be the input's with exactly the planned
|
|
640
|
-
* edits' tokens in place of the ones they covered, and every touched box's two
|
|
641
|
-
* copies must still be equal (ADR-0038). Exported for its own tests.
|
|
642
|
-
* @param {import("./xml-scan.js").XmlElement} inputRoot
|
|
643
|
-
* @param {Uint8Array} out
|
|
644
|
-
* @param {PlannedEdit[]} edits
|
|
645
|
-
* @param {Set<number>} touchedAcs
|
|
646
|
-
* @returns {void}
|
|
647
|
-
*/
|
|
648
|
-
export function readBackRecipe(inputRoot, out, edits, touchedAcs) {
|
|
649
|
-
/** @type {import("./xml-scan.js").XmlElement} */
|
|
650
|
-
let outRoot;
|
|
651
|
-
try {
|
|
652
|
-
outRoot = scanXml(out).root;
|
|
653
|
-
} catch (err) {
|
|
654
|
-
throw new Error(`recipe: the patched document.xml does not parse — ${/** @type {Error} */ (err).message}`, { cause: err });
|
|
655
|
-
}
|
|
656
|
-
/** @type {{at: number, tokens: {kind: string, text?: string}[]}[]} */
|
|
657
|
-
const segments = [];
|
|
658
|
-
for (const token of textTokens(inputRoot)) {
|
|
659
|
-
const covered = edits.some((e) => token.el.start >= e.start && token.el.start < e.end);
|
|
660
|
-
if (!covered) segments.push({ at: token.el.start, tokens: [token.kind === "t" ? { kind: "t", text: token.text } : { kind: "br" }] });
|
|
661
|
-
}
|
|
662
|
-
for (const e of edits) segments.push({ at: e.start, tokens: e.tokens });
|
|
663
|
-
segments.sort((a, b) => a.at - b.at);
|
|
664
|
-
const expected = segments.flatMap((s) => s.tokens);
|
|
665
|
-
const after = textTokens(outRoot);
|
|
666
|
-
if (after.length !== expected.length) {
|
|
667
|
-
throw new Error(`recipe: the patched document.xml holds ${after.length} w:t/w:br token(s), the recipe accounts for ${expected.length}`);
|
|
668
|
-
}
|
|
669
|
-
for (let k = 0; k < expected.length; k += 1) {
|
|
670
|
-
const e = expected[k];
|
|
671
|
-
const a = after[k];
|
|
672
|
-
if (a.kind !== e.kind || (a.kind === "t" && a.text !== e.text)) {
|
|
673
|
-
throw new Error(`recipe: token #${k + 1} does not read back as the recipe wrote it`);
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
const acs = collectAlternateContents(outRoot);
|
|
677
|
-
for (const index of touchedAcs) {
|
|
678
|
-
const ac = acs[index];
|
|
679
|
-
if (ac === undefined || ac.choiceTx.length !== 1 || ac.fallbackTx.length !== 1) throw new Error(`recipe: AlternateContent #${index} lost its shape`);
|
|
680
|
-
const copies = compareCopies(ac.choiceTx[0], ac.fallbackTx[0]);
|
|
681
|
-
if (!copies.equal) throw new Error(`recipe: the two copies of AlternateContent #${index} differ after the recipe, at ${copies.path}`);
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
/**
|
|
686
|
-
* Applies a recipe to a .docx: word/document.xml patched, every other part's
|
|
687
|
-
* bytes copied verbatim (invariant #10). The parts the recipe pins are read
|
|
688
|
-
* from the package for the pin check.
|
|
689
|
-
* @param {Uint8Array} docx
|
|
690
|
-
* @param {Recipe} recipe
|
|
691
|
-
* @returns {Promise<{bytes: Uint8Array, documentXml: Uint8Array, report: RecipeReport}>}
|
|
692
|
-
*/
|
|
693
|
-
export async function applyRecipe(docx, recipe) {
|
|
694
|
-
if (!(docx instanceof Uint8Array)) throw new TypeError("applyRecipe: docx must be a Uint8Array (a .docx file's bytes)");
|
|
695
|
-
const entries = parseZipContainer(docx);
|
|
696
|
-
const main = resolveMainDocumentPart(entries);
|
|
697
|
-
const xml = await inflateEntry(docx, main);
|
|
698
|
-
/** @type {Record<string, Uint8Array>} */
|
|
699
|
-
const parts = {};
|
|
700
|
-
for (const name of Object.keys(recipe?.source?.parts ?? {})) {
|
|
701
|
-
if (name === main.name) continue;
|
|
702
|
-
const entry = entries.find((e) => e.name === name);
|
|
703
|
-
if (entry !== undefined) parts[name] = await inflateEntry(docx, entry);
|
|
704
|
-
}
|
|
705
|
-
const { xml: documentXml, report } = await applyRecipeToDocumentXml(xml, recipe, { parts });
|
|
706
|
-
const bytes = await writePackage(docx, entries, { [main.name]: documentXml });
|
|
707
|
-
return { bytes, documentXml, report };
|
|
708
|
-
}
|
|
1
|
+
// Template recipes (FR3, ADR-0040): a form becomes a `{tag}` template only
|
|
2
|
+
// through a recipe — a JSON pinned to the SHA-256 of the parts it reads —
|
|
3
|
+
// applied as ONE byte patch of word/document.xml, the same edit in both copies
|
|
4
|
+
// of every box (ADR-0038). Never by saving in Word: Word 2010+ writes a
|
|
5
|
+
// w14:paraId into every paragraph and the copies would part (ADR-0037
|
|
6
|
+
// DOĞRULANMALI 5).
|
|
7
|
+
//
|
|
8
|
+
// Four ops, each addressed by its box's wp:docPr id and, where it matters, a
|
|
9
|
+
// paragraph and a run index, each carrying an expectation that catches an
|
|
10
|
+
// authoring mistake and keeps the recipe readable:
|
|
11
|
+
// setTag the box's one empty value paragraph gets a tag run at its end
|
|
12
|
+
// replaceRuns a contiguous run range becomes one tag run, a named run's rPr kept
|
|
13
|
+
// tagText a substring of one run's text becomes a tag run, the run split
|
|
14
|
+
// fillParagraph the paragraph `expect` names, byte for byte (empty or not), is
|
|
15
|
+
// replaced by a literal, validated paragraph
|
|
16
|
+
//
|
|
17
|
+
// Problems are collected stage by stage — the recipe's shape, the pins, every op
|
|
18
|
+
// and their overlaps, the written template's scan — and each stage's are thrown
|
|
19
|
+
// together; a later stage does not run when an earlier one failed. Nothing is written
|
|
20
|
+
// while any remains. The output is then READ BACK: its w:t + w:br token stream
|
|
21
|
+
// must be the input's with exactly each op's declared change, every touched box's
|
|
22
|
+
// copies are compared again, and the template scan must find exactly the
|
|
23
|
+
// recipe's tags — no problem, no warning. A read-back failure is a bug here, a
|
|
24
|
+
// plain Error; a scan finding is the recipe's, a problem.
|
|
25
|
+
//
|
|
26
|
+
// Internal: not exported from /docx (ADR-0040 Karar 2). It imports neither the
|
|
27
|
+
// importer's entry (import.js) nor the exporter, the style resolver or the model —
|
|
28
|
+
// their gates can refuse a form that templates fine. slots.js's section count
|
|
29
|
+
// reaches the importer's helpers (sections.js -> drawingml.js -> import-gate.js),
|
|
30
|
+
// but no gate is called on that path.
|
|
31
|
+
|
|
32
|
+
import { scanXml, getAttr, isElement, findChild } from "./xml-scan.js";
|
|
33
|
+
import { scanTemplate, compareCopies, collectAlternateContents, textTokens } from "./slots.js";
|
|
34
|
+
import { applyEdits } from "./edits.js";
|
|
35
|
+
import { parseZipContainer, resolveMainDocumentPart } from "./zip-container.js";
|
|
36
|
+
import { inflateEntry } from "./inflate.js";
|
|
37
|
+
import { writePackage } from "./opc-write.js";
|
|
38
|
+
import { tagInfo } from "./fill.js";
|
|
39
|
+
import { W_NS, WP_NS, XML_NS } from "./ns.js";
|
|
40
|
+
|
|
41
|
+
const encoder = new TextEncoder();
|
|
42
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
43
|
+
const TAG_NAME = /^[a-z][a-z0-9_]*$/;
|
|
44
|
+
const TAG_TEXT = /^\{([a-z][a-z0-9_]*)\}$/;
|
|
45
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
46
|
+
|
|
47
|
+
/** Elements a fillParagraph fragment may hold (WordprocessingML, local names). */
|
|
48
|
+
const FRAGMENT_ELEMENTS = new Set([
|
|
49
|
+
"p", "pPr", "pStyle", "spacing", "ind", "jc", "tabs", "tab", "rPr", "rFonts", "sz", "szCs", "b", "bCs", "i", "iCs", "color", "position", "r", "t",
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
const RECIPE_KEYS = new Set(["recipe", "id", "title", "source", "constants", "ops"]);
|
|
53
|
+
/** @type {Record<string, string[]>} */
|
|
54
|
+
const OP_KEYS = {
|
|
55
|
+
setTag: ["op", "docPrId", "tag", "label"],
|
|
56
|
+
replaceRuns: ["op", "docPrId", "paragraph", "runs", "expectTexts", "keepRPrOf", "tag"],
|
|
57
|
+
tagText: ["op", "docPrId", "paragraph", "run", "find", "tag"],
|
|
58
|
+
fillParagraph: ["op", "docPrId", "paragraph", "expect", "paragraphXml"],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @typedef {object} RecipeProblem
|
|
63
|
+
* @property {string} code
|
|
64
|
+
* @property {string} message
|
|
65
|
+
* @property {number} [op] index of the op in `ops`
|
|
66
|
+
* @property {number} [docPrId]
|
|
67
|
+
* @property {string} [tag]
|
|
68
|
+
* @property {string} [part]
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @typedef {object} Recipe
|
|
73
|
+
* @property {1} recipe
|
|
74
|
+
* @property {string} id
|
|
75
|
+
* @property {string} [title]
|
|
76
|
+
* @property {{parts: Record<string, string>}} source part name -> SHA-256 (hex)
|
|
77
|
+
* @property {{emptyValueParagraph?: string, valueRunRPr?: string}} [constants]
|
|
78
|
+
* @property {any[]} ops
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @typedef {object} RecipeReport
|
|
83
|
+
* @property {{id: string, sha256: string}} recipe sha256 of the recipe's canonical JSON
|
|
84
|
+
* @property {import("./fill.js").TagInfo[]} tags the template's tags, as the fill engine sees them
|
|
85
|
+
* @property {Record<string, number>} ops how many of each op
|
|
86
|
+
* @property {{input: string, output: string}} documentXml SHA-256 of word/document.xml before and after
|
|
87
|
+
*/
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {string} message
|
|
91
|
+
* @param {RecipeProblem[]} problems
|
|
92
|
+
* @returns {Error & {problems: RecipeProblem[]}}
|
|
93
|
+
*/
|
|
94
|
+
function recipeError(message, problems) {
|
|
95
|
+
const shown = problems.slice(0, 5).map((p) => (p.op === undefined ? p.code : `${p.code} (op ${p.op})`)).join("; ");
|
|
96
|
+
const more = problems.length > 5 ? `; and ${problems.length - 5} more` : "";
|
|
97
|
+
return Object.assign(new Error(`${message}: ${problems.length} problem(s) — ${shown}${more}`), { problems });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* JSON with every object's keys sorted, arrays in order — the recipe's identity
|
|
102
|
+
* does not depend on key order or on the line endings a checkout gives the file.
|
|
103
|
+
* @param {unknown} value
|
|
104
|
+
* @returns {string}
|
|
105
|
+
*/
|
|
106
|
+
export function canonicalJson(value) {
|
|
107
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
108
|
+
if (value !== null && typeof value === "object") {
|
|
109
|
+
const obj = /** @type {Record<string, unknown>} */ (value);
|
|
110
|
+
return `{${Object.keys(obj).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
|
|
111
|
+
}
|
|
112
|
+
return JSON.stringify(value);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* SHA-256 of `bytes` as lowercase hex, through Web Crypto — touched only when
|
|
117
|
+
* called (invariant #5). Node 20+, Deno and browsers on a secure context
|
|
118
|
+
* (127.0.0.1 is one) all have it.
|
|
119
|
+
* @param {Uint8Array} bytes
|
|
120
|
+
* @returns {Promise<string>}
|
|
121
|
+
*/
|
|
122
|
+
export async function sha256Hex(bytes) {
|
|
123
|
+
const subtle = globalThis.crypto?.subtle;
|
|
124
|
+
if (subtle === undefined) throw new Error("recipe: Web Crypto (crypto.subtle) is not available here");
|
|
125
|
+
const digest = new Uint8Array(await subtle.digest("SHA-256", /** @type {BufferSource} */ (bytes)));
|
|
126
|
+
return [...digest].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {Recipe} recipe
|
|
131
|
+
* @returns {Promise<string>} the recipe's identity: SHA-256 of its canonical JSON
|
|
132
|
+
*/
|
|
133
|
+
export function recipeSha256(recipe) {
|
|
134
|
+
return sha256Hex(encoder.encode(canonicalJson(recipe)));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// --- the recipe's shape ---------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @param {unknown} v
|
|
141
|
+
* @returns {v is number}
|
|
142
|
+
*/
|
|
143
|
+
const isIndex = (v) => typeof v === "number" && Number.isInteger(v) && v >= 0;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Every shape problem of a recipe, in one list (strict: an unknown key is one).
|
|
147
|
+
* @param {unknown} recipe
|
|
148
|
+
* @returns {RecipeProblem[]}
|
|
149
|
+
*/
|
|
150
|
+
export function recipeShapeProblems(recipe) {
|
|
151
|
+
/** @type {RecipeProblem[]} */
|
|
152
|
+
const problems = [];
|
|
153
|
+
/**
|
|
154
|
+
* @param {string} message
|
|
155
|
+
* @param {number} [op]
|
|
156
|
+
*/
|
|
157
|
+
const bad = (message, op) => {
|
|
158
|
+
/** @type {RecipeProblem} */
|
|
159
|
+
const p = { code: "RECIPE_SHAPE", message };
|
|
160
|
+
if (op !== undefined) p.op = op;
|
|
161
|
+
problems.push(p);
|
|
162
|
+
};
|
|
163
|
+
if (recipe === null || typeof recipe !== "object" || Array.isArray(recipe)) {
|
|
164
|
+
bad("a recipe is a JSON object");
|
|
165
|
+
return problems;
|
|
166
|
+
}
|
|
167
|
+
const r = /** @type {Record<string, any>} */ (recipe);
|
|
168
|
+
for (const key of Object.keys(r)) if (!RECIPE_KEYS.has(key)) bad(`unknown key "${key}"`);
|
|
169
|
+
if (r.recipe !== 1) bad("recipe must be 1 (the format version)");
|
|
170
|
+
if (typeof r.id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(r.id)) bad("id must be a lowercase name");
|
|
171
|
+
if (r.title !== undefined && typeof r.title !== "string") bad("title must be a string");
|
|
172
|
+
const parts = r.source?.parts;
|
|
173
|
+
if (r.source === null || typeof r.source !== "object" || Object.keys(r.source).some((k) => k !== "parts")) bad("source must be {parts}");
|
|
174
|
+
if (parts === null || typeof parts !== "object" || !("word/document.xml" in parts)) bad("source.parts must pin word/document.xml");
|
|
175
|
+
else for (const [name, sha] of Object.entries(parts)) if (typeof sha !== "string" || !SHA256_HEX.test(sha)) bad(`source.parts["${name}"] must be a SHA-256 in lowercase hex`);
|
|
176
|
+
if (r.constants !== undefined) {
|
|
177
|
+
if (r.constants === null || typeof r.constants !== "object") bad("constants must be an object");
|
|
178
|
+
else {
|
|
179
|
+
for (const key of Object.keys(r.constants)) if (key !== "emptyValueParagraph" && key !== "valueRunRPr") bad(`unknown constant "${key}"`);
|
|
180
|
+
for (const [key, v] of Object.entries(r.constants)) if (typeof v !== "string" || v === "") bad(`constants.${key} must be a non-empty string`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (!Array.isArray(r.ops) || r.ops.length === 0) {
|
|
184
|
+
bad("ops must be a non-empty list");
|
|
185
|
+
return problems;
|
|
186
|
+
}
|
|
187
|
+
r.ops.forEach((/** @type {any} */ op, /** @type {number} */ i) => {
|
|
188
|
+
const allowed = op !== null && typeof op === "object" && Object.hasOwn(OP_KEYS, op.op) ? OP_KEYS[op.op] : undefined;
|
|
189
|
+
if (allowed === undefined) {
|
|
190
|
+
bad(`unknown op ${JSON.stringify(op?.op)}`, i);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
for (const key of Object.keys(op)) if (!allowed.includes(key)) bad(`${op.op}: unknown key "${key}"`, i);
|
|
194
|
+
if (!isIndex(op.docPrId)) bad(`${op.op}: docPrId must be a non-negative integer`, i);
|
|
195
|
+
if (op.op !== "fillParagraph") {
|
|
196
|
+
if (typeof op.tag !== "string" || !TAG_NAME.test(op.tag)) bad(`${op.op}: tag must match [a-z][a-z0-9_]*`, i);
|
|
197
|
+
}
|
|
198
|
+
if (op.op !== "setTag" && !isIndex(op.paragraph)) bad(`${op.op}: paragraph must be a non-negative integer`, i);
|
|
199
|
+
if (op.op === "setTag") {
|
|
200
|
+
if (!isIndex(op.label)) bad("setTag: label must be the docPr id of the neighbouring label box", i);
|
|
201
|
+
if (typeof r.constants?.emptyValueParagraph !== "string" || typeof r.constants?.valueRunRPr !== "string") {
|
|
202
|
+
bad("setTag needs constants.emptyValueParagraph and constants.valueRunRPr", i);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (op.op === "replaceRuns") {
|
|
206
|
+
const [from, to] = Array.isArray(op.runs) ? op.runs : [];
|
|
207
|
+
if (!Array.isArray(op.runs) || op.runs.length !== 2 || !isIndex(from) || !isIndex(to) || !(from < to)) bad("replaceRuns: runs must be [from, to) with from < to", i);
|
|
208
|
+
else {
|
|
209
|
+
if (!Array.isArray(op.expectTexts) || op.expectTexts.length !== to - from || !op.expectTexts.every((/** @type {unknown} */ t) => typeof t === "string")) {
|
|
210
|
+
bad("replaceRuns: expectTexts must hold one string per run in the range", i);
|
|
211
|
+
}
|
|
212
|
+
if (!isIndex(op.keepRPrOf) || op.keepRPrOf < from || op.keepRPrOf >= to) bad("replaceRuns: keepRPrOf must be a run index inside the range", i);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (op.op === "tagText") {
|
|
216
|
+
if (!isIndex(op.run)) bad("tagText: run must be a non-negative integer", i);
|
|
217
|
+
if (typeof op.find !== "string" || op.find === "") bad("tagText: find must be a non-empty string", i);
|
|
218
|
+
}
|
|
219
|
+
if (op.op === "fillParagraph") {
|
|
220
|
+
if (typeof op.expect !== "string" || op.expect === "") bad("fillParagraph: expect must be the paragraph's exact XML", i);
|
|
221
|
+
if (typeof op.paragraphXml !== "string" || op.paragraphXml === "") bad("fillParagraph: paragraphXml must be a paragraph's XML", i);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
return problems;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// --- locating boxes, paragraphs and runs ----------------------------------------------
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* @typedef {object} Box
|
|
231
|
+
* @property {number} acIndex
|
|
232
|
+
* @property {import("./xml-scan.js").XmlElement} choice the Choice copy's w:txbxContent
|
|
233
|
+
* @property {import("./xml-scan.js").XmlElement} fallback the Fallback copy's w:txbxContent
|
|
234
|
+
*/
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Every box (an AlternateContent in the fill engine's shape) by its docPr id,
|
|
238
|
+
* with how many boxes carry that id.
|
|
239
|
+
* @param {import("./xml-scan.js").XmlElement} root
|
|
240
|
+
* @returns {{boxes: Map<number, Box>, counts: Map<number, number>, malformed: Set<number>}}
|
|
241
|
+
*/
|
|
242
|
+
function locateBoxes(root) {
|
|
243
|
+
/** @type {Map<number, Box>} */
|
|
244
|
+
const boxes = new Map();
|
|
245
|
+
/** @type {Map<number, number>} */
|
|
246
|
+
const counts = new Map();
|
|
247
|
+
/** @type {Set<number>} */
|
|
248
|
+
const malformed = new Set();
|
|
249
|
+
for (const ac of collectAlternateContents(root)) {
|
|
250
|
+
for (const anchor of ac.choiceAnchors) {
|
|
251
|
+
const docPr = findChild(anchor, WP_NS, "docPr");
|
|
252
|
+
const raw = docPr ? getAttr(docPr, "", "id") : undefined;
|
|
253
|
+
if (raw === undefined || !/^[0-9]+$/.test(raw)) continue;
|
|
254
|
+
const id = Number(raw);
|
|
255
|
+
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
256
|
+
const shaped =
|
|
257
|
+
!ac.nested && ac.choices.length === 1 && ac.fallbacks.length === 1 && ac.choiceTx.length === 1 && ac.fallbackTx.length === 1 &&
|
|
258
|
+
ac.choiceAnchors.length === 1 && isElement(anchor, WP_NS, "anchor");
|
|
259
|
+
if (shaped) boxes.set(id, { acIndex: ac.index, choice: ac.choiceTx[0], fallback: ac.fallbackTx[0] });
|
|
260
|
+
else malformed.add(id);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return { boxes, counts, malformed };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* @param {import("./xml-scan.js").XmlElement} el
|
|
268
|
+
* @param {string} localName
|
|
269
|
+
* @returns {import("./xml-scan.js").XmlElement[]}
|
|
270
|
+
*/
|
|
271
|
+
function wChildren(el, localName) {
|
|
272
|
+
return /** @type {import("./xml-scan.js").XmlElement[]} */ (el.children.filter((c) => c.kind === "element" && c.uri === W_NS && c.localName === localName));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The text a run shows: its w:t texts, "\t" for a w:tab. Null when it holds
|
|
277
|
+
* anything else (a break, a drawing, a field) — not a run a recipe rewrites.
|
|
278
|
+
* @param {import("./xml-scan.js").XmlElement} run
|
|
279
|
+
* @returns {string | null}
|
|
280
|
+
*/
|
|
281
|
+
function runText(run) {
|
|
282
|
+
let text = "";
|
|
283
|
+
for (const c of run.children) {
|
|
284
|
+
if (c.kind === "text" && c.value.trim() === "") continue;
|
|
285
|
+
if (c.kind !== "element" || c.uri !== W_NS) return null;
|
|
286
|
+
if (c.localName === "rPr") continue;
|
|
287
|
+
if (c.localName === "t") text += c.children.map((x) => (x.kind === "text" || x.kind === "cdata" ? x.value : "")).join("");
|
|
288
|
+
else if (c.localName === "tab") text += "\t";
|
|
289
|
+
else return null;
|
|
290
|
+
}
|
|
291
|
+
return text;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* A run's rPr as a comparable key with every w:spacing left out — the PDF
|
|
296
|
+
* converter wrote one per word to imitate justification (ADR-0040 Bağlam).
|
|
297
|
+
* @param {import("./xml-scan.js").XmlElement | undefined} rPr
|
|
298
|
+
* @returns {string}
|
|
299
|
+
*/
|
|
300
|
+
function rPrKeyWithoutSpacing(rPr) {
|
|
301
|
+
if (rPr === undefined) return "";
|
|
302
|
+
/** @param {import("./xml-scan.js").XmlElement} el @returns {string} */
|
|
303
|
+
const key = (el) =>
|
|
304
|
+
`${el.uri}|${el.localName}|${el.attributes.map((a) => `${a.uri}|${a.localName}=${a.value}`).sort().join(",")}|` +
|
|
305
|
+
el.children.filter((c) => c.kind === "element").map((c) => key(/** @type {import("./xml-scan.js").XmlElement} */ (c))).join(";");
|
|
306
|
+
return rPr.children
|
|
307
|
+
.filter((c) => c.kind === "element" && !(c.uri === W_NS && c.localName === "spacing"))
|
|
308
|
+
.map((c) => key(/** @type {import("./xml-scan.js").XmlElement} */ (c)))
|
|
309
|
+
.join(";");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Whether an element's start tag declares a namespace. The recipe drops the
|
|
314
|
+
* start tags it replaces (a paragraph, a run), so a prefix bound there would
|
|
315
|
+
* come loose in the bytes it keeps or writes.
|
|
316
|
+
* @param {import("./xml-scan.js").XmlElement} el
|
|
317
|
+
* @param {Uint8Array} xmlBytes
|
|
318
|
+
* @returns {boolean}
|
|
319
|
+
*/
|
|
320
|
+
function declaresNamespace(el, xmlBytes) {
|
|
321
|
+
return /\sxmlns(:|\s*=)/.test(decoder.decode(xmlBytes.subarray(el.start, el.contentStart)));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** @typedef {{kind: "t" | "br", text?: string}} Token a w:t (with its text) or a w:br */
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* @typedef {object} PlannedEdit
|
|
328
|
+
* @property {number} start
|
|
329
|
+
* @property {number} end
|
|
330
|
+
* @property {Uint8Array} bytes
|
|
331
|
+
* @property {Token[]} tokens the w:t / w:br tokens the new bytes hold
|
|
332
|
+
* @property {number} op
|
|
333
|
+
*/
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The tokens (w:t texts in order) of a well-formed XML fragment.
|
|
337
|
+
* @param {import("./xml-scan.js").XmlElement} el
|
|
338
|
+
* @returns {Token[]}
|
|
339
|
+
*/
|
|
340
|
+
function fragmentTokens(el) {
|
|
341
|
+
/** @type {Token[]} */
|
|
342
|
+
const out = [];
|
|
343
|
+
for (const c of el.children) {
|
|
344
|
+
if (c.kind !== "element") continue;
|
|
345
|
+
if (c.uri === W_NS && c.localName === "t") out.push({ kind: "t", text: c.children.map((x) => (x.kind === "text" ? x.value : "")).join("") });
|
|
346
|
+
else if (c.uri === W_NS && c.localName === "br") out.push({ kind: "br" });
|
|
347
|
+
else out.push(...fragmentTokens(c));
|
|
348
|
+
}
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Checks a fillParagraph fragment: one w:p of allowed elements, text only inside
|
|
354
|
+
* a w:t, every w:t plain text that is either a whole `{tag}` or holds no brace.
|
|
355
|
+
* Returns its tags and tokens.
|
|
356
|
+
* @param {string} xml
|
|
357
|
+
* @returns {{problem: string} | {tags: string[], tokens: Token[]}}
|
|
358
|
+
*/
|
|
359
|
+
function readFragment(xml) {
|
|
360
|
+
let root;
|
|
361
|
+
try {
|
|
362
|
+
root = scanXml(encoder.encode(`<w:fragment xmlns:w="${W_NS}">${xml}</w:fragment>`)).root;
|
|
363
|
+
} catch (err) {
|
|
364
|
+
return { problem: `the fragment does not parse — ${/** @type {Error} */ (err).message}` };
|
|
365
|
+
}
|
|
366
|
+
const kids = root.children.filter((c) => !(c.kind === "text" && c.value.trim() === ""));
|
|
367
|
+
if (kids.length !== 1 || kids[0].kind !== "element" || !isElement(kids[0], W_NS, "p")) return { problem: "the fragment must be exactly one w:p" };
|
|
368
|
+
/** @type {string[]} */
|
|
369
|
+
const tags = [];
|
|
370
|
+
/** @type {string | null} */
|
|
371
|
+
let problem = null;
|
|
372
|
+
/** @param {import("./xml-scan.js").XmlElement} el */
|
|
373
|
+
const visit = (el) => {
|
|
374
|
+
const name = `${el.prefix ? `${el.prefix}:` : ""}${el.localName}`;
|
|
375
|
+
if (el.uri !== W_NS || !FRAGMENT_ELEMENTS.has(el.localName)) problem ??= `<${name}> is not allowed in a fillParagraph fragment`;
|
|
376
|
+
if (el.localName === "t" && el.uri === W_NS) {
|
|
377
|
+
if (el.children.some((x) => x.kind !== "text")) problem ??= "a fragment w:t holds something other than plain text";
|
|
378
|
+
const text = el.children.map((x) => (x.kind === "text" ? x.value : "")).join("");
|
|
379
|
+
const tag = TAG_TEXT.exec(text);
|
|
380
|
+
if (tag !== null) tags.push(tag[1]);
|
|
381
|
+
else if (text.includes("{") || text.includes("}")) problem ??= "a fragment w:t holds a brace that is not a whole {tag}";
|
|
382
|
+
} else if (el.children.some((x) => x.kind === "cdata" || (x.kind === "text" && x.value.trim() !== ""))) {
|
|
383
|
+
problem ??= `<${name}> holds text outside a w:t`;
|
|
384
|
+
}
|
|
385
|
+
for (const c of el.children) if (c.kind === "element") visit(c);
|
|
386
|
+
};
|
|
387
|
+
visit(/** @type {import("./xml-scan.js").XmlElement} */ (kids[0]));
|
|
388
|
+
if (problem !== null) return { problem };
|
|
389
|
+
if (tags.length === 0) return { problem: "the fragment holds no {tag}" };
|
|
390
|
+
return { tags, tokens: fragmentTokens(root) };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* @typedef {object} Target
|
|
395
|
+
* @property {import("./xml-scan.js").XmlElement} p the addressed paragraph
|
|
396
|
+
* @property {import("./xml-scan.js").XmlElement[]} runs the addressed runs (replaceRuns, tagText)
|
|
397
|
+
* @property {number} start the bytes the op reads, compared between the copies
|
|
398
|
+
* @property {number} end
|
|
399
|
+
* @property {number} editStart the bytes the op replaces; an empty range inserts
|
|
400
|
+
* @property {number} editEnd
|
|
401
|
+
*/
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* @typedef {{code: string, message: string}} Miss a problem without its op's address
|
|
405
|
+
*/
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Finds what an op addresses in one copy of its box — structure only.
|
|
409
|
+
* @param {any} op
|
|
410
|
+
* @param {import("./xml-scan.js").XmlElement} txbx
|
|
411
|
+
* @returns {Target | Miss}
|
|
412
|
+
*/
|
|
413
|
+
function locate(op, txbx) {
|
|
414
|
+
const paragraphs = wChildren(txbx, "p");
|
|
415
|
+
if (op.op === "setTag" && paragraphs.length !== 1) {
|
|
416
|
+
return { code: "EXPECT_MISMATCH", message: `the box holds ${paragraphs.length} paragraphs; setTag expects one, constants.emptyValueParagraph` };
|
|
417
|
+
}
|
|
418
|
+
const p = paragraphs[op.op === "setTag" ? 0 : op.paragraph];
|
|
419
|
+
if (p === undefined) return { code: "PARAGRAPH_NOT_FOUND", message: `the box has ${paragraphs.length} paragraph(s), no index ${op.paragraph}` };
|
|
420
|
+
// Every literal the recipe writes says w:, bound to WordprocessingML above it.
|
|
421
|
+
if (p.prefix !== "w") return { code: "PREFIX_UNSUPPORTED", message: "the recipe writes the w: prefix, the paragraph does not use it" };
|
|
422
|
+
if (op.op === "setTag") return { p, runs: [], start: p.start, end: p.end, editStart: p.contentEnd, editEnd: p.contentEnd };
|
|
423
|
+
if (op.op === "fillParagraph") return { p, runs: [], start: p.start, end: p.end, editStart: p.start, editEnd: p.end };
|
|
424
|
+
const runs = wChildren(p, "r");
|
|
425
|
+
const [from, to] = op.op === "replaceRuns" ? op.runs : [op.run, op.run + 1];
|
|
426
|
+
if (to > runs.length) return { code: "RUN_RANGE", message: `the paragraph has ${runs.length} run(s), the op needs run ${to - 1}` };
|
|
427
|
+
const range = runs.slice(from, to);
|
|
428
|
+
const start = range[0].start;
|
|
429
|
+
const end = range[range.length - 1].end;
|
|
430
|
+
return { p, runs: range, start, end, editStart: start, editEnd: end };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* What an op writes, read from one copy — its addressed bytes are the other
|
|
435
|
+
* copy's too. Checks every expectation the op carries.
|
|
436
|
+
* @param {any} op
|
|
437
|
+
* @param {Target} target
|
|
438
|
+
* @param {Uint8Array} xmlBytes
|
|
439
|
+
* @param {{emptyValueParagraph?: string, valueRunRPr?: string}} constants
|
|
440
|
+
* @returns {{bytes: Uint8Array, tokens: Token[], tags: string[]} | Miss}
|
|
441
|
+
*/
|
|
442
|
+
function plan(op, { p, runs }, xmlBytes, constants) {
|
|
443
|
+
const source = (/** @type {{start: number, end: number}} */ el) => decoder.decode(xmlBytes.subarray(el.start, el.end));
|
|
444
|
+
const tagRun = (/** @type {string} */ rPr) => `<w:r>${rPr}<w:t>{${op.tag}}</w:t></w:r>`;
|
|
445
|
+
/** @type {Token[]} */
|
|
446
|
+
const tagToken = [{ kind: "t", text: `{${op.tag}}` }];
|
|
447
|
+
|
|
448
|
+
if (op.op === "setTag") {
|
|
449
|
+
if (source(p) !== constants.emptyValueParagraph) return { code: "EXPECT_MISMATCH", message: "the box's paragraph is not constants.emptyValueParagraph" };
|
|
450
|
+
return { bytes: encoder.encode(tagRun(/** @type {string} */ (constants.valueRunRPr))), tokens: tagToken, tags: [op.tag] };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (op.op === "fillParagraph") {
|
|
454
|
+
if (source(p) !== op.expect) return { code: "EXPECT_MISMATCH", message: "the paragraph is not the one `expect` names" };
|
|
455
|
+
if (declaresNamespace(p, xmlBytes)) return { code: "PREFIX_UNSUPPORTED", message: "the paragraph declares a namespace, which the new paragraph would leave out" };
|
|
456
|
+
const fragment = readFragment(op.paragraphXml);
|
|
457
|
+
if ("problem" in fragment) return { code: "FRAGMENT_INVALID", message: fragment.problem };
|
|
458
|
+
return { bytes: encoder.encode(op.paragraphXml), tokens: fragment.tokens, tags: fragment.tags };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (op.op === "replaceRuns") {
|
|
462
|
+
const from = op.runs[0];
|
|
463
|
+
for (let k = 0; k + 1 < runs.length; k += 1) {
|
|
464
|
+
if (runs[k].end !== runs[k + 1].start) return { code: "RUNS_NOT_CONTIGUOUS", message: `something stands between run ${from + k} and run ${from + k + 1}` };
|
|
465
|
+
}
|
|
466
|
+
const texts = runs.map(runText);
|
|
467
|
+
if (texts.some((t) => t === null)) return { code: "RUN_CONTENT_UNSUPPORTED", message: "a run in the range holds more than rPr, w:t and w:tab" };
|
|
468
|
+
if (texts.some((t, k) => t !== op.expectTexts[k])) return { code: "EXPECT_MISMATCH", message: "the runs' texts are not expectTexts" };
|
|
469
|
+
const keys = runs.map((r) => rPrKeyWithoutSpacing(findChild(r, W_NS, "rPr")));
|
|
470
|
+
if (new Set(keys).size !== 1) return { code: "RPR_DIFFER", message: "the runs' rPr differ beyond w:spacing — one tag run would change their look" };
|
|
471
|
+
const keptRun = runs[op.keepRPrOf - from];
|
|
472
|
+
if (declaresNamespace(keptRun, xmlBytes)) return { code: "PREFIX_UNSUPPORTED", message: "the run whose rPr is kept declares a namespace, which its rPr would leave behind" };
|
|
473
|
+
const kept = findChild(keptRun, W_NS, "rPr");
|
|
474
|
+
return { bytes: encoder.encode(tagRun(kept === undefined ? "" : source(kept))), tokens: tagToken, tags: [op.tag] };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// tagText: the run becomes [before] {tag} [after], every piece in the run's own rPr.
|
|
478
|
+
const run = runs[0];
|
|
479
|
+
const ts = wChildren(run, "t");
|
|
480
|
+
const other = run.children.filter((c) => c.kind === "element" && !(c.uri === W_NS && (c.localName === "t" || c.localName === "rPr")));
|
|
481
|
+
if (ts.length !== 1 || other.length > 0) return { code: "RUN_CONTENT_UNSUPPORTED", message: "tagText splits a run holding rPr and exactly one w:t" };
|
|
482
|
+
const t = ts[0];
|
|
483
|
+
if (t.attributes.some((a) => !(a.uri === XML_NS && a.localName === "space" && a.value === "preserve"))) {
|
|
484
|
+
return { code: "RUN_CONTENT_UNSUPPORTED", message: "the run's w:t carries attributes other than xml:space=\"preserve\"" };
|
|
485
|
+
}
|
|
486
|
+
const only = t.children.length === 1 ? t.children[0] : null;
|
|
487
|
+
const text = only !== null && only.kind === "text" ? only.value : null;
|
|
488
|
+
if (text === null || decoder.decode(xmlBytes.subarray(t.contentStart, t.contentEnd)) !== text) {
|
|
489
|
+
return { code: "TEXT_ENCODED", message: "the run's text is not one plain text node written as it reads (no entity, no character reference)" };
|
|
490
|
+
}
|
|
491
|
+
const at = text.indexOf(op.find);
|
|
492
|
+
if (at < 0 || text.indexOf(op.find, at + 1) >= 0) return { code: "TEXT_NOT_FOUND", message: "`find` must occur exactly once in the run's text" };
|
|
493
|
+
if (declaresNamespace(run, xmlBytes)) return { code: "PREFIX_UNSUPPORTED", message: "the run declares a namespace, which its rPr would leave behind" };
|
|
494
|
+
const kept = findChild(run, W_NS, "rPr");
|
|
495
|
+
const rPr = kept === undefined ? "" : source(kept);
|
|
496
|
+
const piece = (/** @type {string} */ s) => `<w:r>${rPr}<w:t${/^\s|\s$/.test(s) ? ' xml:space="preserve"' : ""}>${s}</w:t></w:r>`;
|
|
497
|
+
const before = text.slice(0, at);
|
|
498
|
+
const after = text.slice(at + op.find.length);
|
|
499
|
+
/** @type {Token[]} */
|
|
500
|
+
const tokens = [];
|
|
501
|
+
let xml = "";
|
|
502
|
+
if (before !== "") {
|
|
503
|
+
xml += piece(before);
|
|
504
|
+
tokens.push({ kind: "t", text: before });
|
|
505
|
+
}
|
|
506
|
+
xml += tagRun(rPr);
|
|
507
|
+
tokens.push(...tagToken);
|
|
508
|
+
if (after !== "") {
|
|
509
|
+
xml += piece(after);
|
|
510
|
+
tokens.push({ kind: "t", text: after });
|
|
511
|
+
}
|
|
512
|
+
return { bytes: encoder.encode(xml), tokens, tags: [op.tag] };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// --- applying ---------------------------------------------------------------------------
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Applies a recipe to word/document.xml. Every problem — the recipe's shape, the
|
|
519
|
+
* pins, every op's target and expectation — is collected and thrown together;
|
|
520
|
+
* otherwise the patched part comes back with a report. `parts` supplies the
|
|
521
|
+
* other parts the recipe pins (e.g. word/styles.xml), by name.
|
|
522
|
+
* @param {Uint8Array} xmlBytes
|
|
523
|
+
* @param {Recipe} recipe
|
|
524
|
+
* @param {{parts?: Record<string, Uint8Array>}} [options]
|
|
525
|
+
* @returns {Promise<{xml: Uint8Array, report: RecipeReport}>}
|
|
526
|
+
*/
|
|
527
|
+
export async function applyRecipeToDocumentXml(xmlBytes, recipe, { parts = {} } = {}) {
|
|
528
|
+
const shape = recipeShapeProblems(recipe);
|
|
529
|
+
if (shape.length > 0) throw recipeError("recipe", shape);
|
|
530
|
+
|
|
531
|
+
/** @type {RecipeProblem[]} */
|
|
532
|
+
const problems = [];
|
|
533
|
+
const inputSha = await sha256Hex(xmlBytes);
|
|
534
|
+
for (const [name, want] of Object.entries(recipe.source.parts)) {
|
|
535
|
+
const bytes = name === "word/document.xml" ? xmlBytes : parts[name];
|
|
536
|
+
if (bytes === undefined) {
|
|
537
|
+
problems.push({ code: "PART_MISSING", message: `the recipe pins ${name}, which was not supplied`, part: name });
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
const got = name === "word/document.xml" ? inputSha : await sha256Hex(bytes);
|
|
541
|
+
if (got !== want) problems.push({ code: "PART_PIN_MISMATCH", message: `${name} is not the part the recipe was written for`, part: name });
|
|
542
|
+
}
|
|
543
|
+
if (problems.length > 0) throw recipeError("recipe", problems);
|
|
544
|
+
|
|
545
|
+
const { root } = scanXml(xmlBytes);
|
|
546
|
+
const { boxes, counts, malformed } = locateBoxes(root);
|
|
547
|
+
const raw = (/** @type {{start: number, end: number}} */ el) => xmlBytes.subarray(el.start, el.end);
|
|
548
|
+
const same = (/** @type {Uint8Array} */ a, /** @type {Uint8Array} */ b) => a.length === b.length && a.every((x, i) => x === b[i]);
|
|
549
|
+
const constants = recipe.constants ?? {};
|
|
550
|
+
|
|
551
|
+
/** @type {PlannedEdit[]} */
|
|
552
|
+
const edits = [];
|
|
553
|
+
/** @type {{tag: string, docPrId: number}[]} */
|
|
554
|
+
const expectedTags = [];
|
|
555
|
+
/** @type {Set<number>} */
|
|
556
|
+
const touchedAcs = new Set();
|
|
557
|
+
/** @type {Record<string, number>} */
|
|
558
|
+
const opCounts = { setTag: 0, replaceRuns: 0, tagText: 0, fillParagraph: 0 };
|
|
559
|
+
|
|
560
|
+
recipe.ops.forEach((/** @type {any} */ op, /** @type {number} */ i) => {
|
|
561
|
+
opCounts[op.op] += 1;
|
|
562
|
+
const problem = (/** @type {string} */ code, /** @type {string} */ message) => problems.push({ code, message, op: i, docPrId: op.docPrId });
|
|
563
|
+
const n = counts.get(op.docPrId) ?? 0;
|
|
564
|
+
if (n !== 1) return problem(n === 0 ? "BOX_NOT_FOUND" : "BOX_NOT_UNIQUE", `${n} boxes carry wp:docPr id ${op.docPrId}`);
|
|
565
|
+
const box = boxes.get(op.docPrId);
|
|
566
|
+
if (box === undefined || malformed.has(op.docPrId)) return problem("BOX_SHAPE", "the box is not one Choice + one Fallback with one w:txbxContent each and a wp:anchor");
|
|
567
|
+
if (op.op === "setTag" && (counts.get(op.label) ?? 0) !== 1) return problem("LABEL_NOT_FOUND", `no single box carries the label's wp:docPr id ${op.label}`);
|
|
568
|
+
if (!compareCopies(box.choice, box.fallback).equal) return problem("COPIES_DIFFER", "the box's two copies are not structurally equal");
|
|
569
|
+
|
|
570
|
+
/** @type {Target[]} */
|
|
571
|
+
const targets = [];
|
|
572
|
+
for (const txbx of [box.choice, box.fallback]) {
|
|
573
|
+
const target = locate(op, txbx);
|
|
574
|
+
if ("code" in target) return problem(target.code, target.message);
|
|
575
|
+
targets.push(target);
|
|
576
|
+
}
|
|
577
|
+
// Structurally equal copies can still be spelled differently; the op reads one and writes both.
|
|
578
|
+
const [c, f] = targets;
|
|
579
|
+
if (!same(raw(c), raw(f))) return problem("COPIES_NOT_IDENTICAL", "the addressed bytes differ between the Choice and the Fallback copy");
|
|
580
|
+
const planned = plan(op, c, xmlBytes, constants);
|
|
581
|
+
if ("code" in planned) return problem(planned.code, planned.message);
|
|
582
|
+
for (const t of targets) edits.push({ start: t.editStart, end: t.editEnd, bytes: planned.bytes, tokens: planned.tokens, op: i });
|
|
583
|
+
touchedAcs.add(box.acIndex);
|
|
584
|
+
for (const tag of planned.tags) expectedTags.push({ tag, docPrId: op.docPrId });
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// Ops are disjoint — checked here, so the error names the ops, not applyEdits' offsets.
|
|
588
|
+
// Both copies overlap alike; a pair of ops is named once.
|
|
589
|
+
const sorted = [...edits].sort((a, b) => a.start - b.start || a.end - b.end);
|
|
590
|
+
/** @type {Set<string>} */
|
|
591
|
+
const named = new Set();
|
|
592
|
+
for (let k = 0; k + 1 < sorted.length; k += 1) {
|
|
593
|
+
const a = sorted[k];
|
|
594
|
+
const b = sorted[k + 1];
|
|
595
|
+
if (b.start < a.end || (a.start === b.start && a.end === b.end)) {
|
|
596
|
+
const [x, y] = [a.op, b.op].sort((m, n) => m - n);
|
|
597
|
+
if (named.has(`${x},${y}`)) continue;
|
|
598
|
+
named.add(`${x},${y}`);
|
|
599
|
+
problems.push({ code: "OPS_OVERLAP", message: `ops ${x} and ${y} touch the same bytes`, op: y });
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (problems.length > 0) throw recipeError("recipe", problems);
|
|
603
|
+
|
|
604
|
+
const xml = applyEdits(xmlBytes, edits.map(({ start, end, bytes }) => ({ start, end, bytes })));
|
|
605
|
+
readBackRecipe(root, xml, edits, touchedAcs);
|
|
606
|
+
|
|
607
|
+
// The template the engine will fill: exactly the recipe's tags, nothing to refuse, nothing to warn about.
|
|
608
|
+
const outRoot = scanXml(xml).root;
|
|
609
|
+
const scan = scanTemplate(outRoot, xml);
|
|
610
|
+
/** @type {RecipeProblem[]} */
|
|
611
|
+
const after = [];
|
|
612
|
+
for (const p of [...scan.problems, ...scan.warnings]) {
|
|
613
|
+
/** @type {RecipeProblem} */
|
|
614
|
+
const q = { code: `TEMPLATE_${p.code}`, message: `the template the recipe writes: ${p.message}` };
|
|
615
|
+
if (p.tag !== undefined) q.tag = p.tag;
|
|
616
|
+
if (typeof p.docPrId === "number") q.docPrId = p.docPrId;
|
|
617
|
+
after.push(q);
|
|
618
|
+
}
|
|
619
|
+
const want = expectedTags.map((x) => `${x.tag}@${x.docPrId}`).sort();
|
|
620
|
+
const got = scan.occurrences.map((o) => `${o.name}@${o.docPrId}`).sort();
|
|
621
|
+
if (want.join("\n") !== got.join("\n")) {
|
|
622
|
+
after.push({ code: "TEMPLATE_TAGS_DIFFER", message: `the template holds ${got.length} tag occurrence(s), the recipe declares ${want.length}` });
|
|
623
|
+
}
|
|
624
|
+
if (after.length > 0) throw recipeError("recipe: the written template", after);
|
|
625
|
+
|
|
626
|
+
return {
|
|
627
|
+
xml,
|
|
628
|
+
report: {
|
|
629
|
+
recipe: { id: recipe.id, sha256: await recipeSha256(recipe) },
|
|
630
|
+
tags: tagInfo(scan),
|
|
631
|
+
ops: opCounts,
|
|
632
|
+
documentXml: { input: inputSha, output: await sha256Hex(xml) },
|
|
633
|
+
},
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Reads the patched part back. A failure is a bug in this module, a plain Error:
|
|
639
|
+
* the output's w:t + w:br tokens must be the input's with exactly the planned
|
|
640
|
+
* edits' tokens in place of the ones they covered, and every touched box's two
|
|
641
|
+
* copies must still be equal (ADR-0038). Exported for its own tests.
|
|
642
|
+
* @param {import("./xml-scan.js").XmlElement} inputRoot
|
|
643
|
+
* @param {Uint8Array} out
|
|
644
|
+
* @param {PlannedEdit[]} edits
|
|
645
|
+
* @param {Set<number>} touchedAcs
|
|
646
|
+
* @returns {void}
|
|
647
|
+
*/
|
|
648
|
+
export function readBackRecipe(inputRoot, out, edits, touchedAcs) {
|
|
649
|
+
/** @type {import("./xml-scan.js").XmlElement} */
|
|
650
|
+
let outRoot;
|
|
651
|
+
try {
|
|
652
|
+
outRoot = scanXml(out).root;
|
|
653
|
+
} catch (err) {
|
|
654
|
+
throw new Error(`recipe: the patched document.xml does not parse — ${/** @type {Error} */ (err).message}`, { cause: err });
|
|
655
|
+
}
|
|
656
|
+
/** @type {{at: number, tokens: {kind: string, text?: string}[]}[]} */
|
|
657
|
+
const segments = [];
|
|
658
|
+
for (const token of textTokens(inputRoot)) {
|
|
659
|
+
const covered = edits.some((e) => token.el.start >= e.start && token.el.start < e.end);
|
|
660
|
+
if (!covered) segments.push({ at: token.el.start, tokens: [token.kind === "t" ? { kind: "t", text: token.text } : { kind: "br" }] });
|
|
661
|
+
}
|
|
662
|
+
for (const e of edits) segments.push({ at: e.start, tokens: e.tokens });
|
|
663
|
+
segments.sort((a, b) => a.at - b.at);
|
|
664
|
+
const expected = segments.flatMap((s) => s.tokens);
|
|
665
|
+
const after = textTokens(outRoot);
|
|
666
|
+
if (after.length !== expected.length) {
|
|
667
|
+
throw new Error(`recipe: the patched document.xml holds ${after.length} w:t/w:br token(s), the recipe accounts for ${expected.length}`);
|
|
668
|
+
}
|
|
669
|
+
for (let k = 0; k < expected.length; k += 1) {
|
|
670
|
+
const e = expected[k];
|
|
671
|
+
const a = after[k];
|
|
672
|
+
if (a.kind !== e.kind || (a.kind === "t" && a.text !== e.text)) {
|
|
673
|
+
throw new Error(`recipe: token #${k + 1} does not read back as the recipe wrote it`);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
const acs = collectAlternateContents(outRoot);
|
|
677
|
+
for (const index of touchedAcs) {
|
|
678
|
+
const ac = acs[index];
|
|
679
|
+
if (ac === undefined || ac.choiceTx.length !== 1 || ac.fallbackTx.length !== 1) throw new Error(`recipe: AlternateContent #${index} lost its shape`);
|
|
680
|
+
const copies = compareCopies(ac.choiceTx[0], ac.fallbackTx[0]);
|
|
681
|
+
if (!copies.equal) throw new Error(`recipe: the two copies of AlternateContent #${index} differ after the recipe, at ${copies.path}`);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Applies a recipe to a .docx: word/document.xml patched, every other part's
|
|
687
|
+
* bytes copied verbatim (invariant #10). The parts the recipe pins are read
|
|
688
|
+
* from the package for the pin check.
|
|
689
|
+
* @param {Uint8Array} docx
|
|
690
|
+
* @param {Recipe} recipe
|
|
691
|
+
* @returns {Promise<{bytes: Uint8Array, documentXml: Uint8Array, report: RecipeReport}>}
|
|
692
|
+
*/
|
|
693
|
+
export async function applyRecipe(docx, recipe) {
|
|
694
|
+
if (!(docx instanceof Uint8Array)) throw new TypeError("applyRecipe: docx must be a Uint8Array (a .docx file's bytes)");
|
|
695
|
+
const entries = parseZipContainer(docx);
|
|
696
|
+
const main = resolveMainDocumentPart(entries);
|
|
697
|
+
const xml = await inflateEntry(docx, main);
|
|
698
|
+
/** @type {Record<string, Uint8Array>} */
|
|
699
|
+
const parts = {};
|
|
700
|
+
for (const name of Object.keys(recipe?.source?.parts ?? {})) {
|
|
701
|
+
if (name === main.name) continue;
|
|
702
|
+
const entry = entries.find((e) => e.name === name);
|
|
703
|
+
if (entry !== undefined) parts[name] = await inflateEntry(docx, entry);
|
|
704
|
+
}
|
|
705
|
+
const { xml: documentXml, report } = await applyRecipeToDocumentXml(xml, recipe, { parts });
|
|
706
|
+
const bytes = await writePackage(docx, entries, { [main.name]: documentXml });
|
|
707
|
+
return { bytes, documentXml, report };
|
|
708
|
+
}
|