@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.
Files changed (82) hide show
  1. package/CHANGELOG.md +108 -70
  2. package/LICENSE +21 -21
  3. package/README.md +286 -247
  4. package/dist/canvas/box-clipboard.js +238 -238
  5. package/dist/canvas/box-selection.js +80 -80
  6. package/dist/canvas/canvas-editor.js +502 -502
  7. package/dist/canvas/controller.js +717 -717
  8. package/dist/canvas/geometry.js +131 -131
  9. package/dist/canvas/gesture.d.ts +0 -1
  10. package/dist/canvas/gesture.js +289 -289
  11. package/dist/canvas/handles.js +212 -212
  12. package/dist/canvas/menu.js +82 -82
  13. package/dist/canvas/mode-controller.js +81 -81
  14. package/dist/canvas/nudge.js +108 -108
  15. package/dist/canvas/overlay.js +149 -149
  16. package/dist/canvas/snap.js +132 -132
  17. package/dist/canvas/viewport.js +112 -112
  18. package/dist/canvas/z-order.js +171 -171
  19. package/dist/commands/alignment.js +150 -150
  20. package/dist/commands/box.js +272 -272
  21. package/dist/commands/clipboard.js +93 -93
  22. package/dist/commands/graphemes.js +112 -112
  23. package/dist/commands/marks.js +153 -153
  24. package/dist/commands/paragraph-props.js +176 -176
  25. package/dist/commands/size.js +368 -368
  26. package/dist/commands/text.js +291 -291
  27. package/dist/docx.d.ts +1 -0
  28. package/dist/docx.js +15 -13
  29. package/dist/editor/editing-host.js +434 -434
  30. package/dist/element.js +41 -41
  31. package/dist/fonts/OFL-Carlito.txt +94 -0
  32. package/dist/fonts/calibri-metrics.d.ts +27 -0
  33. package/dist/fonts/calibri-metrics.js +88 -0
  34. package/dist/history/history.js +433 -433
  35. package/dist/index.js +63 -63
  36. package/dist/input/beforeinput.js +224 -224
  37. package/dist/input/clipboard.js +107 -107
  38. package/dist/input/composition.js +341 -341
  39. package/dist/input/keymap.js +54 -54
  40. package/dist/io/ooxml/custgeom.js +72 -72
  41. package/dist/io/ooxml/drawingml.js +232 -232
  42. package/dist/io/ooxml/edits.js +67 -67
  43. package/dist/io/ooxml/export-splice.js +880 -880
  44. package/dist/io/ooxml/export.js +365 -365
  45. package/dist/io/ooxml/fill.d.ts +93 -8
  46. package/dist/io/ooxml/fill.js +618 -562
  47. package/dist/io/ooxml/fit.d.ts +252 -0
  48. package/dist/io/ooxml/fit.js +598 -0
  49. package/dist/io/ooxml/import-gate.js +200 -200
  50. package/dist/io/ooxml/import.js +691 -691
  51. package/dist/io/ooxml/inflate.js +126 -126
  52. package/dist/io/ooxml/media.js +87 -87
  53. package/dist/io/ooxml/ns.d.ts +2 -0
  54. package/dist/io/ooxml/ns.js +26 -21
  55. package/dist/io/ooxml/numbering.js +290 -290
  56. package/dist/io/ooxml/opc-write.js +171 -171
  57. package/dist/io/ooxml/provenance.js +93 -93
  58. package/dist/io/ooxml/recipe.js +708 -708
  59. package/dist/io/ooxml/relationships.js +59 -59
  60. package/dist/io/ooxml/run-fonts.d.ts +89 -0
  61. package/dist/io/ooxml/run-fonts.js +378 -0
  62. package/dist/io/ooxml/slots.d.ts +4 -0
  63. package/dist/io/ooxml/slots.js +582 -581
  64. package/dist/io/ooxml/styles-resolve.js +257 -257
  65. package/dist/io/ooxml/units.js +43 -43
  66. package/dist/io/ooxml/value.js +120 -120
  67. package/dist/io/ooxml/xml-scan.js +616 -616
  68. package/dist/io/ooxml/zip-container.js +228 -228
  69. package/dist/io/ooxml/zip-reject.js +106 -106
  70. package/dist/measure/placement.js +83 -83
  71. package/dist/measure/report.js +75 -75
  72. package/dist/model/ids.js +70 -70
  73. package/dist/model/normalize.js +146 -146
  74. package/dist/model/position.js +283 -283
  75. package/dist/model/transaction.js +209 -209
  76. package/dist/platform/capabilities.js +25 -25
  77. package/dist/react/index.js +21 -21
  78. package/dist/selection/controller.js +315 -315
  79. package/dist/selection/dom-map.js +334 -334
  80. package/dist/state/editor-state.js +109 -109
  81. package/dist/styles.css +66 -66
  82. package/package.json +94 -93
@@ -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
+ }