@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,562 +1,618 @@
1
- // The template fill engine (FR2, ADR-0037, ADR-0038): a pure core
2
- // (word/document.xml bytes + data in, filled bytes + a report out) and the
3
- // package layer the public /docx functions use (zip in, zip out — only
4
- // word/document.xml is rewritten, every other part is copied verbatim).
5
- //
6
- // Strict (ADR-0037 Karar 5): every problem — the template's, the expected
7
- // tag set's and the data's — is collected first and thrown together in ONE
8
- // TemplateError; nothing is written while any remains. A missing tag stops
9
- // the fill; an empty value (null, "" or whitespace) does not, it is reported.
10
- //
11
- // Each tag `w:t` is replaced whole, in both copies, by its start tag (given
12
- // `xml:space="preserve"` when it has no attribute), the encoded value and its
13
- // own end tag; every other byte of the document stays where it was, applied
14
- // in one `applyEdits` pass. A tag the caller names in `multiline` (ADR-0039)
15
- // takes LFs: each line gets that same start and end tag, with a `w:br` between
16
- // two lines inside the tag's own run. The output is then READ BACK —
17
- // re-scanned, every `w:t`'s text and every `w:br` compared with what must now
18
- // be there, and every written box's two copies compared again (ADR-0038). A
19
- // read-back failure is a bug in this code, not in the template, so it throws
20
- // a plain Error and no output.
21
- //
22
- // Nothing here — report, problem, error message — ever carries a data value.
23
-
24
- import { scanXml } from "./xml-scan.js";
25
- import { scanTemplate, compareCopies, collectAlternateContents, textTokens } from "./slots.js";
26
- import { checkValue, encodeTextValue } from "./value.js";
27
- import { applyEdits } from "./edits.js";
28
- import { parseZipContainer, resolveMainDocumentPart } from "./zip-container.js";
29
- import { inflateEntry } from "./inflate.js";
30
- import { writePackage } from "./opc-write.js";
31
- import { devFreeze } from "../../dev.js";
32
-
33
- /** The package version this engine ships in; a test holds it equal to package.json. */
34
- export const ENGINE_VERSION = "0.2.0-alpha";
35
-
36
- const encoder = new TextEncoder();
37
- const PRESERVE_ATTRIBUTE = encoder.encode(' xml:space="preserve">');
38
-
39
- /**
40
- * @typedef {import("./slots.js").TemplateProblem & {index?: number, codePoint?: number, count?: number, type?: string}} Problem
41
- */
42
-
43
- /**
44
- * @typedef {object} TagInfo
45
- * @property {string} name
46
- * @property {{docPrId: number | null, page: number}[]} occurrences document order
47
- */
48
-
49
- /**
50
- * @typedef {object} FillOptions
51
- * @property {readonly string[]} [expectedTags] the binding manifest's exact tag set (SK10)
52
- * @property {readonly string[]} [multiline] tags whose values may hold LF line breaks (ADR-0039)
53
- */
54
-
55
- /**
56
- * Tag name -> value. A string is written as given; null, "" and whitespace
57
- * are empty (allowed, reported); an absent key or undefined is MISSING_VALUE.
58
- * @typedef {Record<string, string | null | undefined>} FillData
59
- */
60
-
61
- /**
62
- * @typedef {object} FillReport
63
- * @property {TagInfo[]} tags sorted by name
64
- * @property {string[]} empty tags filled with an empty value (null, "" or whitespace), sorted
65
- * @property {string[]} unused data keys no tag uses, sorted
66
- * @property {Problem[]} warnings
67
- * @property {{tags: number, occurrences: number, textElementsWritten: number}} counts
68
- * `textElementsWritten`: tag w:t elements rewritten — one per occurrence per copy. A
69
- * multi-line value writes more w:t than that; its extra lines are not counted (ADR-0039).
70
- * @property {{version: string}} engine
71
- */
72
-
73
- /**
74
- * Thrown for every problem a caller can fix — the template's, the expected
75
- * tag set's, the data's — all of them at once in `problems`. A deliberate
76
- * exception to this codebase's plain-Error convention (import-gate.js): a
77
- * public API's caller must tell these apart from a bug (ADR-0037 Karar 9).
78
- */
79
- export class TemplateError extends Error {
80
- /**
81
- * @param {string} message
82
- * @param {Problem[]} problems
83
- * @param {{cause?: unknown}} [options]
84
- */
85
- constructor(message, problems, options) {
86
- super(message, options);
87
- this.name = "TemplateError";
88
- /** @type {Problem[]} */
89
- this.problems = problems;
90
- }
91
- }
92
-
93
- /**
94
- * @param {string} where the public function's name, for the message
95
- * @param {Problem[]} problems
96
- * @returns {TemplateError}
97
- */
98
- export function templateError(where, problems) {
99
- const shown = problems
100
- .slice(0, 5)
101
- .map((p) => (p.tag === undefined ? p.code : `${p.code} {${p.tag}}`))
102
- .join("; ");
103
- const more = problems.length > 5 ? `; and ${problems.length - 5} more` : "";
104
- return new TemplateError(`${where}: ${problems.length} problem(s) — ${shown}${more}`, problems);
105
- }
106
-
107
- /**
108
- * The tags of a scan, sorted by name, each with its occurrences in document order.
109
- * @param {import("./slots.js").ScanResult} scan
110
- * @returns {TagInfo[]}
111
- */
112
- export function tagInfo(scan) {
113
- /** @type {Map<string, {docPrId: number | null, page: number}[]>} */
114
- const byName = new Map();
115
- for (const o of scan.occurrences) {
116
- let list = byName.get(o.name);
117
- if (list === undefined) {
118
- list = [];
119
- byName.set(o.name, list);
120
- }
121
- list.push({ docPrId: o.docPrId, page: o.page });
122
- }
123
- return [...byName.keys()].sort().map((name) => ({ name, occurrences: /** @type {{docPrId: number | null, page: number}[]} */ (byName.get(name)) }));
124
- }
125
-
126
- /**
127
- * Exact-set comparison with a binding manifest (SK10): every expected tag
128
- * must be in the template, and the template may hold no other.
129
- * @param {TagInfo[]} tags
130
- * @param {readonly string[]} expectedTags
131
- * @returns {Problem[]}
132
- */
133
- export function expectedTagProblems(tags, expectedTags) {
134
- const present = new Set(tags.map((t) => t.name));
135
- const expected = new Set(expectedTags);
136
- /** @type {Problem[]} */
137
- const problems = [];
138
- for (const name of [...expected].sort()) {
139
- if (!present.has(name)) problems.push({ code: "EXPECTED_TAG_ABSENT", message: "an expected tag is not in the template", tag: name });
140
- }
141
- for (const t of tags) {
142
- if (!expected.has(t.name)) problems.push({ code: "UNEXPECTED_TAG", message: "the template holds a tag the expected set does not", tag: t.name });
143
- }
144
- return problems;
145
- }
146
-
147
- /**
148
- * Every name the `multiline` option lists that the template does not hold
149
- * (ADR-0039 Karar 1) — a misspelled name would otherwise leave its tag
150
- * refusing line breaks, or a stale manifest go unnoticed.
151
- * @param {TagInfo[]} tags
152
- * @param {readonly string[]} multiline
153
- * @returns {Problem[]}
154
- */
155
- export function multilineProblems(tags, multiline) {
156
- const present = new Set(tags.map((t) => t.name));
157
- /** @type {Problem[]} */
158
- const problems = [];
159
- for (const name of [...new Set(multiline)].sort()) {
160
- if (!present.has(name)) problems.push({ code: "UNKNOWN_MULTILINE_TAG", message: "a tag the multiline option names is not in the template", tag: name });
161
- }
162
- return problems;
163
- }
164
-
165
- /**
166
- * @param {string} where
167
- * @param {string} key
168
- * @param {unknown} value
169
- * @returns {string[] | undefined}
170
- */
171
- function stringList(where, key, value) {
172
- if (value === undefined) return undefined;
173
- if (!Array.isArray(value) || !value.every((t) => typeof t === "string")) {
174
- throw new TypeError(`${where}: options.${key} must be an array of strings`);
175
- }
176
- return [...value];
177
- }
178
-
179
- /**
180
- * Validates the options object: `expectedTags` and `multiline`, each an
181
- * array of strings.
182
- * @param {string} where
183
- * @param {unknown} options
184
- * @returns {{expectedTags: string[] | undefined, multiline: string[] | undefined, problems: Problem[]}}
185
- */
186
- export function readOptions(where, options) {
187
- if (options === undefined) return { expectedTags: undefined, multiline: undefined, problems: [] };
188
- if (options === null || typeof options !== "object" || Array.isArray(options)) {
189
- throw new TypeError(`${where}: options must be an object`);
190
- }
191
- /** @type {Problem[]} */
192
- const problems = [];
193
- for (const key of Object.keys(options)) {
194
- if (key !== "expectedTags" && key !== "multiline") {
195
- problems.push({ code: "UNKNOWN_OPTION", message: `unknown option "${key}" (only expectedTags and multiline are accepted)` });
196
- }
197
- }
198
- const { expectedTags, multiline } = /** @type {{expectedTags?: unknown, multiline?: unknown}} */ (options);
199
- return {
200
- expectedTags: stringList(where, "expectedTags", expectedTags),
201
- multiline: stringList(where, "multiline", multiline),
202
- problems,
203
- };
204
- }
205
-
206
- /**
207
- * @param {unknown} data
208
- * @returns {Record<string, unknown>}
209
- */
210
- function requireData(data) {
211
- const proto = data !== null && typeof data === "object" ? Object.getPrototypeOf(data) : undefined;
212
- if (proto !== Object.prototype && proto !== null) {
213
- throw new TypeError("fillTemplate: data must be a plain object (or one created with Object.create(null))");
214
- }
215
- return /** @type {Record<string, unknown>} */ (data);
216
- }
217
-
218
- /**
219
- * The replacement for one tag `w:t`: for each line of the value its own start
220
- * tag (with `xml:space="preserve"` added when it has no attribute — the scan
221
- * refused every other attribute), the encoded line and its own end tag, and a
222
- * `w:br` between two lines (ADR-0039 Karar 3). The break carries the RUN's
223
- * prefix: that one is bound where the break goes, an `xmlns:` declared on the
224
- * `w:t` itself would not reach its sibling. One line is the single-line bytes.
225
- * @param {import("./xml-scan.js").XmlElement} t
226
- * @param {import("./xml-scan.js").XmlElement} run
227
- * @param {Uint8Array} xmlBytes
228
- * @param {Uint8Array[]} lines the value's encoded lines (one unless multi-line)
229
- * @returns {Uint8Array}
230
- */
231
- function rewriteTag(t, run, xmlBytes, lines) {
232
- const start = t.attributes.length === 0
233
- ? [xmlBytes.subarray(t.start, t.contentStart - 1), PRESERVE_ATTRIBUTE]
234
- : [xmlBytes.subarray(t.start, t.contentStart)];
235
- const end = xmlBytes.subarray(t.contentEnd, t.end);
236
- const br = encoder.encode(`<${run.prefix ? `${run.prefix}:` : ""}br/>`);
237
- /** @type {Uint8Array[]} */
238
- const parts = [];
239
- lines.forEach((line, i) => {
240
- if (i > 0) parts.push(br);
241
- parts.push(...start, line, end);
242
- });
243
- const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
244
- let at = 0;
245
- for (const p of parts) {
246
- out.set(p, at);
247
- at += p.length;
248
- }
249
- return out;
250
- }
251
-
252
- /**
253
- * @param {{kind: string}[]} tokens
254
- * @returns {string} "N w:t and M w:br"
255
- */
256
- function tokenCounts(tokens) {
257
- const breaks = tokens.filter((k) => k.kind === "br").length;
258
- return `${tokens.length - breaks} w:t and ${breaks} w:br`;
259
- }
260
-
261
- /**
262
- * Reads the filled output back. Throws a plain Error — a failure here is a
263
- * bug in the engine, never in the template or the data. Exported for its own
264
- * tests, which hand it deliberately wrong outputs.
265
- *
266
- * It compares the whole document's `w:t` + `w:br` token stream (ADR-0039
267
- * Karar 4): the template's stream with every tag site replaced by its value's
268
- * lines — `t(L1) (br t(Li))*`, one line unless the tag is multi-line. Kind
269
- * and text are compared token by token; a count alone would accept lines that
270
- * were never separated.
271
- * @param {import("./xml-scan.js").XmlElement} inputRoot
272
- * @param {Uint8Array} out
273
- * @param {import("./slots.js").ScanResult} scan
274
- * @param {Map<string, string>} valueOf
275
- * @param {ReadonlySet<string>} [multilineSet] tags whose values were split at LF
276
- * @returns {void}
277
- */
278
- export function readBack(inputRoot, out, scan, valueOf, multilineSet = new Set()) {
279
- /** @type {import("./xml-scan.js").XmlElement} */
280
- let outRoot;
281
- try {
282
- outRoot = scanXml(out).root;
283
- } catch (err) {
284
- throw new Error(`fill: the filled document.xml does not parse — ${/** @type {Error} */ (err).message}`, { cause: err });
285
- }
286
- /** @type {Map<number, string>} */
287
- const filledAt = new Map();
288
- for (const o of scan.occurrences) {
289
- filledAt.set(o.choice.t.start, o.name);
290
- filledAt.set(o.fallback.t.start, o.name);
291
- }
292
- /** @type {{kind: "t" | "br", text?: string, name?: string}[]} */
293
- const expected = [];
294
- for (const token of textTokens(inputRoot)) {
295
- const name = token.kind === "t" ? filledAt.get(token.el.start) : undefined;
296
- if (name === undefined) {
297
- expected.push(token.kind === "t" ? { kind: "t", text: token.text } : { kind: "br" });
298
- continue;
299
- }
300
- const value = /** @type {string} */ (valueOf.get(name));
301
- (multilineSet.has(name) ? value.split("\n") : [value]).forEach((line, i) => {
302
- if (i > 0) expected.push({ kind: "br", name });
303
- expected.push({ kind: "t", text: line, name });
304
- });
305
- }
306
- const after = textTokens(outRoot);
307
- if (after.length !== expected.length) {
308
- throw new Error(`fill: the filled document.xml holds ${tokenCounts(after)}, the filled template must hold ${tokenCounts(expected)}`);
309
- }
310
- for (let i = 0; i < expected.length; i += 1) {
311
- const e = expected[i];
312
- const a = after[i];
313
- if (a.kind !== e.kind) {
314
- throw new Error(`fill: token #${i + 1} is a w:${a.kind} where a w:${e.kind} belongs${e.name === undefined ? "" : ` (the value of {${e.name}})`}`);
315
- }
316
- if (a.kind === "t" && a.text !== e.text) {
317
- throw new Error(`fill: w:t #${i + 1} does not read back as ${e.name === undefined ? "the template's text" : `the value of {${e.name}}`}`);
318
- }
319
- }
320
- const acs = collectAlternateContents(outRoot);
321
- for (const index of new Set(scan.occurrences.map((o) => o.acIndex))) {
322
- const ac = acs[index];
323
- if (ac === undefined || ac.choiceTx.length !== 1 || ac.fallbackTx.length !== 1) {
324
- throw new Error(`fill: AlternateContent #${index} lost its shape while filling`);
325
- }
326
- const copies = compareCopies(ac.choiceTx[0], ac.fallbackTx[0]);
327
- if (!copies.equal) throw new Error(`fill: the two copies of AlternateContent #${index} differ after filling, at ${copies.path}`);
328
- }
329
- }
330
-
331
- /**
332
- * Fills word/document.xml. Every problem is collected and thrown together
333
- * (the package layer adds its other-part findings to the same list).
334
- * @param {Uint8Array} xmlBytes
335
- * @param {FillData} data
336
- * @param {FillOptions} [options]
337
- * @returns {{xml: Uint8Array, report: FillReport}}
338
- */
339
- export function fillDocumentXml(xmlBytes, data, options) {
340
- return fillParsed(scanXml(xmlBytes).root, xmlBytes, data, options, []);
341
- }
342
-
343
- /**
344
- * @param {import("./xml-scan.js").XmlElement} root
345
- * @param {Uint8Array} xmlBytes
346
- * @param {unknown} data
347
- * @param {unknown} options
348
- * @param {Problem[]} extraProblems
349
- * @returns {{xml: Uint8Array, report: FillReport}}
350
- */
351
- function fillParsed(root, xmlBytes, data, options, extraProblems) {
352
- const record = requireData(data);
353
- const { expectedTags, multiline, problems: optionProblems } = readOptions("fillTemplate", options);
354
- const scan = scanTemplate(root, xmlBytes);
355
- const tags = tagInfo(scan);
356
- const multilineSet = new Set(multiline ?? []);
357
-
358
- /** @type {Problem[]} */
359
- const problems = [...scan.problems, ...extraProblems, ...optionProblems];
360
- if (tags.length === 0 && scan.problems.length === 0) {
361
- problems.push({ code: "NO_TAGS", message: "the template holds no tag to fill" });
362
- }
363
- if (expectedTags !== undefined) problems.push(...expectedTagProblems(tags, expectedTags));
364
- if (multiline !== undefined) problems.push(...multilineProblems(tags, multiline));
365
-
366
- /** @type {Problem[]} */
367
- const warnings = [...scan.warnings];
368
- /** @type {Map<string, string>} */
369
- const valueOf = new Map();
370
- /** @type {string[]} */
371
- const empty = [];
372
- // Data problems in document order of each tag's first occurrence.
373
- const order = [...new Set(scan.occurrences.map((o) => o.name))];
374
- for (const name of order) {
375
- const value = Object.prototype.hasOwnProperty.call(record, name) ? record[name] : undefined;
376
- if (value === undefined) {
377
- problems.push({ code: "MISSING_VALUE", message: "the data holds no value for this tag", tag: name });
378
- continue;
379
- }
380
- const check = checkValue(value, { multiline: multilineSet.has(name) });
381
- for (const issue of check.errors) {
382
- problems.push({ ...issue, message: `the value for this tag is refused (${issue.code})`, tag: name });
383
- }
384
- for (const issue of check.warnings) {
385
- warnings.push({ ...issue, message: "the value for this tag is not in Unicode NFC; it is written as given", tag: name });
386
- }
387
- if (check.errors.length === 0) {
388
- valueOf.set(name, value === null ? "" : /** @type {string} */ (value));
389
- if (check.empty) empty.push(name);
390
- }
391
- }
392
- if (problems.length > 0) throw templateError("fillTemplate", problems);
393
-
394
- /** @type {Map<string, Uint8Array[]>} */
395
- const encoded = new Map(
396
- [...valueOf].map(([name, value]) => [name, (multilineSet.has(name) ? value.split("\n") : [value]).map(encodeTextValue)]),
397
- );
398
- /** @type {{start: number, end: number, bytes: Uint8Array}[]} */
399
- const edits = [];
400
- for (const o of scan.occurrences) {
401
- const lines = /** @type {Uint8Array[]} */ (encoded.get(o.name));
402
- for (const site of [o.choice, o.fallback]) {
403
- edits.push({ start: site.t.start, end: site.t.end, bytes: rewriteTag(site.t, site.run, xmlBytes, lines) });
404
- }
405
- }
406
- const xml = applyEdits(xmlBytes, edits);
407
- readBack(root, xml, scan, valueOf, multilineSet);
408
-
409
- const used = new Set(order);
410
- /** @type {FillReport} */
411
- const report = {
412
- tags,
413
- empty: [...empty].sort(),
414
- unused: Object.keys(record).filter((key) => !used.has(key)).sort(),
415
- warnings,
416
- counts: { tags: tags.length, occurrences: scan.occurrences.length, textElementsWritten: edits.length },
417
- engine: { version: ENGINE_VERSION },
418
- };
419
- return { xml, report: devFreeze(report) };
420
- }
421
-
422
- // --- package layer ------------------------------------------------------------
423
-
424
- /**
425
- * @typedef {object} TemplatePackage
426
- * @property {import("./zip-container.js").ZipEntry[]} entries
427
- * @property {import("./zip-container.js").ZipEntry} main
428
- * @property {Uint8Array} xml word/document.xml, inflated
429
- * @property {import("./xml-scan.js").XmlElement} root
430
- * @property {Problem[]} otherPartProblems
431
- */
432
-
433
- /**
434
- * @param {string} where
435
- * @param {unknown} err
436
- * @returns {TemplateError}
437
- */
438
- function unreadable(where, err) {
439
- const reason = err instanceof Error ? err.message : String(err);
440
- return new TemplateError(`${where}: the package cannot be read — ${reason}`, [{ code: "PACKAGE_UNREADABLE", message: reason }], { cause: err });
441
- }
442
-
443
- /**
444
- * Whether `el` holds a brace in text a reader shows: in a docProps part every
445
- * text node (docxtemplater fills those), elsewhere the text of every element
446
- * named `t` (w:t, a:t, m:t). Attributes never count — docProps/custom.xml
447
- * carries `fmtid="{…}"` GUIDs in all 46 corpus documents.
448
- * @param {import("./xml-scan.js").XmlElement} el
449
- * @param {boolean} allText
450
- * @returns {boolean}
451
- */
452
- function holdsBrace(el, allText) {
453
- for (const c of el.children) {
454
- if (c.kind === "element") {
455
- if (!allText && c.localName === "t") {
456
- for (const x of c.children) {
457
- if ((x.kind === "text" || x.kind === "cdata") && (x.value.includes("{") || x.value.includes("}"))) return true;
458
- }
459
- } else if (holdsBrace(c, allText)) {
460
- return true;
461
- }
462
- } else if (allText && (c.kind === "text" || c.kind === "cdata") && (c.value.includes("{") || c.value.includes("}"))) {
463
- return true;
464
- }
465
- }
466
- return false;
467
- }
468
-
469
- /**
470
- * Reads the package: word/document.xml parsed, and every other XML part
471
- * checked for braces (ADR-0037 Karar 6 — the engine fills only the main
472
- * document, so a tag anywhere else would ship unfilled). An unreadable zip
473
- * or XML part is PACKAGE_UNREADABLE.
474
- * @param {string} where
475
- * @param {unknown} bytes
476
- * @returns {Promise<TemplatePackage>}
477
- */
478
- async function readTemplatePackage(where, bytes) {
479
- if (!(bytes instanceof Uint8Array)) throw new TypeError(`${where}: bytes must be a Uint8Array (a .docx file's bytes)`);
480
- try {
481
- const entries = parseZipContainer(bytes);
482
- const main = resolveMainDocumentPart(entries);
483
- const xml = await inflateEntry(bytes, main);
484
- const { root } = scanXml(xml);
485
- /** @type {Problem[]} */
486
- const otherPartProblems = [];
487
- for (const entry of entries) {
488
- if (entry === main || entry.name === "[Content_Types].xml" || !entry.name.toLowerCase().endsWith(".xml")) continue;
489
- const part = scanXml(await inflateEntry(bytes, entry)).root;
490
- if (holdsBrace(part, entry.name.startsWith("docProps/"))) {
491
- otherPartProblems.push({
492
- code: "TAG_IN_OTHER_PART",
493
- message: "a brace in a part the engine does not fill (only word/document.xml is filled)",
494
- part: entry.name,
495
- });
496
- }
497
- }
498
- return { entries, main, xml, root, otherPartProblems };
499
- } catch (err) {
500
- throw unreadable(where, err);
501
- }
502
- }
503
-
504
- /**
505
- * What a template holds: its tags with their boxes and pages, every problem
506
- * that would stop a fill, and the warnings. Template problems are reported,
507
- * not thrown; an unreadable package is a TemplateError (PACKAGE_UNREADABLE).
508
- * @param {Uint8Array} bytes a .docx file
509
- * @returns {Promise<{tags: TagInfo[], problems: Problem[], warnings: Problem[]}>}
510
- */
511
- export async function inspectTemplate(bytes) {
512
- const pkg = await readTemplatePackage("inspectTemplate", bytes);
513
- const scan = scanTemplate(pkg.root, pkg.xml);
514
- const tags = tagInfo(scan);
515
- /** @type {Problem[]} */
516
- const warnings = [...scan.warnings];
517
- if (tags.length === 0 && scan.problems.length === 0) {
518
- warnings.push({ code: "NO_TAGS", message: "the template holds no tag to fill" });
519
- }
520
- return devFreeze({ tags, problems: [...scan.problems, ...pkg.otherPartProblems], warnings });
521
- }
522
-
523
- /**
524
- * `inspectTemplate` plus the binding manifest's exact tag set (SK10). `ok` is
525
- * true when nothing would stop a fill with complete data — so a template
526
- * without tags, only a warning to `inspectTemplate`, is a problem here, as it
527
- * is to `fillTemplate` (the FR2 review found the two disagreeing).
528
- * @param {Uint8Array} bytes
529
- * @param {FillOptions} [options]
530
- * @returns {Promise<{ok: boolean, tags: TagInfo[], problems: Problem[], warnings: Problem[]}>}
531
- */
532
- export async function validateTemplate(bytes, options) {
533
- const { expectedTags, multiline, problems: optionProblems } = readOptions("validateTemplate", options);
534
- const info = await inspectTemplate(bytes);
535
- /** @type {Problem[]} */
536
- const problems = [...info.problems, ...optionProblems];
537
- const noTags = info.warnings.find((w) => w.code === "NO_TAGS");
538
- if (noTags !== undefined) problems.push({ ...noTags });
539
- if (expectedTags !== undefined) problems.push(...expectedTagProblems(info.tags, expectedTags));
540
- if (multiline !== undefined) problems.push(...multilineProblems(info.tags, multiline));
541
- const warnings = info.warnings.filter((w) => w.code !== "NO_TAGS");
542
- return devFreeze({ ok: problems.length === 0, tags: [...info.tags], problems, warnings });
543
- }
544
-
545
- /**
546
- * Fills a .docx template. Strict: every problem — the package's other parts,
547
- * the template, the options, the data — arrives in ONE TemplateError and
548
- * nothing is written. Otherwise only word/document.xml is rewritten; every
549
- * other part's bytes are copied verbatim (invariant #10).
550
- * @param {Uint8Array} bytes a .docx file
551
- * @param {FillData} data a plain object (or one made with Object.create(null))
552
- * @param {FillOptions} [options]
553
- * @returns {Promise<{bytes: Uint8Array, documentXml: Uint8Array, report: FillReport}>}
554
- * `documentXml` is the filled part uncompressed — hash it to compare
555
- * runtimes (zip bytes depend on the runtime's CompressionStream; FR1-DENO)
556
- */
557
- export async function fillTemplate(bytes, data, options) {
558
- const pkg = await readTemplatePackage("fillTemplate", bytes);
559
- const { xml, report } = fillParsed(pkg.root, pkg.xml, data, options, pkg.otherPartProblems);
560
- const out = await writePackage(/** @type {Uint8Array} */ (bytes), pkg.entries, { [pkg.main.name]: xml });
561
- return { bytes: out, documentXml: xml, report };
562
- }
1
+ // The template fill engine (FR2, ADR-0037, ADR-0038): a pure core
2
+ // (word/document.xml bytes + data in, filled bytes + a report out) and the
3
+ // package layer the public /docx functions use (zip in, zip out — only
4
+ // word/document.xml is rewritten, every other part is copied verbatim).
5
+ //
6
+ // Strict (ADR-0037 Karar 5): every problem — the template's, the expected
7
+ // tag set's and the data's — is collected first and thrown together in ONE
8
+ // TemplateError; nothing is written while any remains. A missing tag stops
9
+ // the fill; an empty value (null, "" or whitespace) does not, it is reported.
10
+ //
11
+ // Each tag `w:t` is replaced whole, in both copies, by its start tag (given
12
+ // `xml:space="preserve"` when it has no attribute), the encoded value and its
13
+ // own end tag; every other byte of the document stays where it was, applied
14
+ // in one `applyEdits` pass. A tag the caller names in `multiline` (ADR-0039)
15
+ // takes LFs: each line gets that same start and end tag, with a `w:br` between
16
+ // two lines inside the tag's own run. The output is then READ BACK —
17
+ // re-scanned, every `w:t`'s text and every `w:br` compared with what must now
18
+ // be there, and every written box's two copies compared again (ADR-0038). A
19
+ // read-back failure is a bug in this code, not in the template, so it throws
20
+ // a plain Error and no output.
21
+ //
22
+ // Nothing here — report, problem, error message — ever carries a data value.
23
+
24
+ import { scanXml } from "./xml-scan.js";
25
+ import { scanTemplate, compareCopies, collectAlternateContents, textTokens } from "./slots.js";
26
+ import { checkValue, encodeTextValue } from "./value.js";
27
+ import { applyEdits } from "./edits.js";
28
+ import { parseZipContainer, resolveMainDocumentPart } from "./zip-container.js";
29
+ import { inflateEntry } from "./inflate.js";
30
+ import { writePackage } from "./opc-write.js";
31
+ import { devFreeze } from "../../dev.js";
32
+
33
+ /** The package version this engine ships in; a test holds it equal to package.json. */
34
+ export const ENGINE_VERSION = "0.3.0-alpha";
35
+
36
+ const encoder = new TextEncoder();
37
+ const PRESERVE_ATTRIBUTE = encoder.encode(' xml:space="preserve">');
38
+
39
+ /**
40
+ * @typedef {import("./slots.js").TemplateProblem & {index?: number, codePoint?: number, count?: number, type?: string}} Problem
41
+ */
42
+
43
+ /**
44
+ * @typedef {object} TagInfo
45
+ * @property {string} name
46
+ * @property {{docPrId: number | null, page: number}[]} occurrences document order
47
+ */
48
+
49
+ /**
50
+ * @typedef {object} FillOptions
51
+ * @property {readonly string[]} [expectedTags] the binding manifest's exact tag set (SK10)
52
+ * @property {readonly string[]} [multiline] tags whose values may hold LF line breaks (ADR-0039)
53
+ */
54
+
55
+ /**
56
+ * Tag name -> value. A string is written as given; null, "" and whitespace
57
+ * are empty (allowed, reported); an absent key or undefined is MISSING_VALUE.
58
+ * @typedef {Record<string, string | null | undefined>} FillData
59
+ */
60
+
61
+ /**
62
+ * @typedef {object} FillReport
63
+ * @property {TagInfo[]} tags sorted by name
64
+ * @property {string[]} empty tags filled with an empty value (null, "" or whitespace), sorted
65
+ * @property {string[]} unused data keys no tag uses, sorted
66
+ * @property {Problem[]} warnings
67
+ * @property {{tags: number, occurrences: number, textElementsWritten: number}} counts
68
+ * `textElementsWritten`: tag w:t elements rewritten — one per occurrence per copy. A
69
+ * multi-line value writes more w:t than that; its extra lines are not counted (ADR-0039).
70
+ * @property {{version: string}} engine
71
+ */
72
+
73
+ /**
74
+ * Thrown for every problem a caller can fix — the template's, the expected
75
+ * tag set's, the data's — all of them at once in `problems`. A deliberate
76
+ * exception to this codebase's plain-Error convention (import-gate.js): a
77
+ * public API's caller must tell these apart from a bug (ADR-0037 Karar 9).
78
+ */
79
+ export class TemplateError extends Error {
80
+ /**
81
+ * @param {string} message
82
+ * @param {Problem[]} problems
83
+ * @param {{cause?: unknown}} [options]
84
+ */
85
+ constructor(message, problems, options) {
86
+ super(message, options);
87
+ this.name = "TemplateError";
88
+ /** @type {Problem[]} */
89
+ this.problems = problems;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * @param {string} where the public function's name, for the message
95
+ * @param {Problem[]} problems
96
+ * @returns {TemplateError}
97
+ */
98
+ export function templateError(where, problems) {
99
+ const shown = problems
100
+ .slice(0, 5)
101
+ .map((p) => (p.tag === undefined ? p.code : `${p.code} {${p.tag}}`))
102
+ .join("; ");
103
+ const more = problems.length > 5 ? `; and ${problems.length - 5} more` : "";
104
+ return new TemplateError(`${where}: ${problems.length} problem(s) — ${shown}${more}`, problems);
105
+ }
106
+
107
+ /**
108
+ * The tags of a scan, sorted by name, each with its occurrences in document order.
109
+ * @param {import("./slots.js").ScanResult} scan
110
+ * @returns {TagInfo[]}
111
+ */
112
+ export function tagInfo(scan) {
113
+ /** @type {Map<string, {docPrId: number | null, page: number}[]>} */
114
+ const byName = new Map();
115
+ for (const o of scan.occurrences) {
116
+ let list = byName.get(o.name);
117
+ if (list === undefined) {
118
+ list = [];
119
+ byName.set(o.name, list);
120
+ }
121
+ list.push({ docPrId: o.docPrId, page: o.page });
122
+ }
123
+ return [...byName.keys()].sort().map((name) => ({ name, occurrences: /** @type {{docPrId: number | null, page: number}[]} */ (byName.get(name)) }));
124
+ }
125
+
126
+ /**
127
+ * Exact-set comparison with a binding manifest (SK10): every expected tag
128
+ * must be in the template, and the template may hold no other.
129
+ * @param {TagInfo[]} tags
130
+ * @param {readonly string[]} expectedTags
131
+ * @returns {Problem[]}
132
+ */
133
+ export function expectedTagProblems(tags, expectedTags) {
134
+ const present = new Set(tags.map((t) => t.name));
135
+ const expected = new Set(expectedTags);
136
+ /** @type {Problem[]} */
137
+ const problems = [];
138
+ for (const name of [...expected].sort()) {
139
+ if (!present.has(name)) problems.push({ code: "EXPECTED_TAG_ABSENT", message: "an expected tag is not in the template", tag: name });
140
+ }
141
+ for (const t of tags) {
142
+ if (!expected.has(t.name)) problems.push({ code: "UNEXPECTED_TAG", message: "the template holds a tag the expected set does not", tag: t.name });
143
+ }
144
+ return problems;
145
+ }
146
+
147
+ /**
148
+ * Every name the `multiline` option lists that the template does not hold
149
+ * (ADR-0039 Karar 1) — a misspelled name would otherwise leave its tag
150
+ * refusing line breaks, or a stale manifest go unnoticed.
151
+ * @param {TagInfo[]} tags
152
+ * @param {readonly string[]} multiline
153
+ * @returns {Problem[]}
154
+ */
155
+ export function multilineProblems(tags, multiline) {
156
+ const present = new Set(tags.map((t) => t.name));
157
+ /** @type {Problem[]} */
158
+ const problems = [];
159
+ for (const name of [...new Set(multiline)].sort()) {
160
+ if (!present.has(name)) problems.push({ code: "UNKNOWN_MULTILINE_TAG", message: "a tag the multiline option names is not in the template", tag: name });
161
+ }
162
+ return problems;
163
+ }
164
+
165
+ /**
166
+ * @param {string} where
167
+ * @param {string} key
168
+ * @param {unknown} value
169
+ * @returns {string[] | undefined}
170
+ */
171
+ function stringList(where, key, value) {
172
+ if (value === undefined) return undefined;
173
+ if (!Array.isArray(value) || !value.every((t) => typeof t === "string")) {
174
+ throw new TypeError(`${where}: options.${key} must be an array of strings`);
175
+ }
176
+ return [...value];
177
+ }
178
+
179
+ /**
180
+ * @typedef {object} ReadOptions
181
+ * @property {string[] | undefined} expectedTags
182
+ * @property {string[] | undefined} multiline
183
+ * @property {Record<string, unknown>} given the options object as passed ({} when undefined), for keys a caller adds
184
+ * @property {Problem[]} problems
185
+ */
186
+
187
+ /**
188
+ * Validates the options object: `expectedTags` and `multiline`, each an
189
+ * array of strings, plus whatever other keys the caller accepts (checkFit's
190
+ * `capacity`, which it reads itself); any other key is UNKNOWN_OPTION.
191
+ * @param {string} where
192
+ * @param {unknown} options
193
+ * @param {readonly string[]} [accepted]
194
+ * @returns {ReadOptions}
195
+ */
196
+ export function readOptions(where, options, accepted = ["expectedTags", "multiline"]) {
197
+ if (options === undefined) return { expectedTags: undefined, multiline: undefined, given: {}, problems: [] };
198
+ if (options === null || typeof options !== "object" || Array.isArray(options)) {
199
+ throw new TypeError(`${where}: options must be an object`);
200
+ }
201
+ const list = `${accepted.slice(0, -1).join(", ")} and ${accepted[accepted.length - 1]}`;
202
+ /** @type {Problem[]} */
203
+ const problems = [];
204
+ for (const key of Object.keys(options)) {
205
+ if (!accepted.includes(key)) {
206
+ problems.push({ code: "UNKNOWN_OPTION", message: `unknown option "${key}" (only ${list} are accepted)` });
207
+ }
208
+ }
209
+ const given = /** @type {Record<string, unknown>} */ (options);
210
+ return {
211
+ expectedTags: stringList(where, "expectedTags", given.expectedTags),
212
+ multiline: stringList(where, "multiline", given.multiline),
213
+ given,
214
+ problems,
215
+ };
216
+ }
217
+
218
+ /**
219
+ * @param {string} where
220
+ * @param {unknown} data
221
+ * @returns {Record<string, unknown>}
222
+ */
223
+ function requireData(where, data) {
224
+ const proto = data !== null && typeof data === "object" ? Object.getPrototypeOf(data) : undefined;
225
+ if (proto !== Object.prototype && proto !== null) {
226
+ throw new TypeError(`${where}: data must be a plain object (or one created with Object.create(null))`);
227
+ }
228
+ return /** @type {Record<string, unknown>} */ (data);
229
+ }
230
+
231
+ /**
232
+ * The replacement for one tag `w:t`: for each line of the value its own start
233
+ * tag (with `xml:space="preserve"` added when it has no attribute — the scan
234
+ * refused every other attribute), the encoded line and its own end tag, and a
235
+ * `w:br` between two lines (ADR-0039 Karar 3). The break carries the RUN's
236
+ * prefix: that one is bound where the break goes, an `xmlns:` declared on the
237
+ * `w:t` itself would not reach its sibling. One line is the single-line bytes.
238
+ * @param {import("./xml-scan.js").XmlElement} t
239
+ * @param {import("./xml-scan.js").XmlElement} run
240
+ * @param {Uint8Array} xmlBytes
241
+ * @param {Uint8Array[]} lines the value's encoded lines (one unless multi-line)
242
+ * @returns {Uint8Array}
243
+ */
244
+ function rewriteTag(t, run, xmlBytes, lines) {
245
+ const start = t.attributes.length === 0
246
+ ? [xmlBytes.subarray(t.start, t.contentStart - 1), PRESERVE_ATTRIBUTE]
247
+ : [xmlBytes.subarray(t.start, t.contentStart)];
248
+ const end = xmlBytes.subarray(t.contentEnd, t.end);
249
+ const br = encoder.encode(`<${run.prefix ? `${run.prefix}:` : ""}br/>`);
250
+ /** @type {Uint8Array[]} */
251
+ const parts = [];
252
+ lines.forEach((line, i) => {
253
+ if (i > 0) parts.push(br);
254
+ parts.push(...start, line, end);
255
+ });
256
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
257
+ let at = 0;
258
+ for (const p of parts) {
259
+ out.set(p, at);
260
+ at += p.length;
261
+ }
262
+ return out;
263
+ }
264
+
265
+ /**
266
+ * @param {{kind: string}[]} tokens
267
+ * @returns {string} "N w:t and M w:br"
268
+ */
269
+ function tokenCounts(tokens) {
270
+ const breaks = tokens.filter((k) => k.kind === "br").length;
271
+ return `${tokens.length - breaks} w:t and ${breaks} w:br`;
272
+ }
273
+
274
+ /**
275
+ * Reads the filled output back. Throws a plain Error — a failure here is a
276
+ * bug in the engine, never in the template or the data. Exported for its own
277
+ * tests, which hand it deliberately wrong outputs.
278
+ *
279
+ * It compares the whole document's `w:t` + `w:br` token stream (ADR-0039
280
+ * Karar 4): the template's stream with every tag site replaced by its value's
281
+ * lines — `t(L1) (br t(Li))*`, one line unless the tag is multi-line. Kind
282
+ * and text are compared token by token; a count alone would accept lines that
283
+ * were never separated.
284
+ * @param {import("./xml-scan.js").XmlElement} inputRoot
285
+ * @param {Uint8Array} out
286
+ * @param {import("./slots.js").ScanResult} scan
287
+ * @param {Map<string, string>} valueOf
288
+ * @param {ReadonlySet<string>} [multilineSet] tags whose values were split at LF
289
+ * @returns {void}
290
+ */
291
+ export function readBack(inputRoot, out, scan, valueOf, multilineSet = new Set()) {
292
+ /** @type {import("./xml-scan.js").XmlElement} */
293
+ let outRoot;
294
+ try {
295
+ outRoot = scanXml(out).root;
296
+ } catch (err) {
297
+ throw new Error(`fill: the filled document.xml does not parse — ${/** @type {Error} */ (err).message}`, { cause: err });
298
+ }
299
+ /** @type {Map<number, string>} */
300
+ const filledAt = new Map();
301
+ for (const o of scan.occurrences) {
302
+ filledAt.set(o.choice.t.start, o.name);
303
+ filledAt.set(o.fallback.t.start, o.name);
304
+ }
305
+ /** @type {{kind: "t" | "br", text?: string, name?: string}[]} */
306
+ const expected = [];
307
+ for (const token of textTokens(inputRoot)) {
308
+ const name = token.kind === "t" ? filledAt.get(token.el.start) : undefined;
309
+ if (name === undefined) {
310
+ expected.push(token.kind === "t" ? { kind: "t", text: token.text } : { kind: "br" });
311
+ continue;
312
+ }
313
+ const value = /** @type {string} */ (valueOf.get(name));
314
+ (multilineSet.has(name) ? value.split("\n") : [value]).forEach((line, i) => {
315
+ if (i > 0) expected.push({ kind: "br", name });
316
+ expected.push({ kind: "t", text: line, name });
317
+ });
318
+ }
319
+ const after = textTokens(outRoot);
320
+ if (after.length !== expected.length) {
321
+ throw new Error(`fill: the filled document.xml holds ${tokenCounts(after)}, the filled template must hold ${tokenCounts(expected)}`);
322
+ }
323
+ for (let i = 0; i < expected.length; i += 1) {
324
+ const e = expected[i];
325
+ const a = after[i];
326
+ if (a.kind !== e.kind) {
327
+ throw new Error(`fill: token #${i + 1} is a w:${a.kind} where a w:${e.kind} belongs${e.name === undefined ? "" : ` (the value of {${e.name}})`}`);
328
+ }
329
+ if (a.kind === "t" && a.text !== e.text) {
330
+ throw new Error(`fill: w:t #${i + 1} does not read back as ${e.name === undefined ? "the template's text" : `the value of {${e.name}}`}`);
331
+ }
332
+ }
333
+ const acs = collectAlternateContents(outRoot);
334
+ for (const index of new Set(scan.occurrences.map((o) => o.acIndex))) {
335
+ const ac = acs[index];
336
+ if (ac === undefined || ac.choiceTx.length !== 1 || ac.fallbackTx.length !== 1) {
337
+ throw new Error(`fill: AlternateContent #${index} lost its shape while filling`);
338
+ }
339
+ const copies = compareCopies(ac.choiceTx[0], ac.fallbackTx[0]);
340
+ if (!copies.equal) throw new Error(`fill: the two copies of AlternateContent #${index} differ after filling, at ${copies.path}`);
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Fills word/document.xml. Every problem is collected and thrown together
346
+ * (the package layer adds its other-part findings to the same list).
347
+ * @param {Uint8Array} xmlBytes
348
+ * @param {FillData} data
349
+ * @param {FillOptions} [options]
350
+ * @returns {{xml: Uint8Array, report: FillReport}}
351
+ */
352
+ export function fillDocumentXml(xmlBytes, data, options) {
353
+ return fillParsed(scanXml(xmlBytes).root, xmlBytes, data, options, []);
354
+ }
355
+
356
+ /**
357
+ * @typedef {object} PreparedFill everything a fill decides before it writes a byte
358
+ * @property {Record<string, unknown>} record the data
359
+ * @property {import("./slots.js").ScanResult} scan
360
+ * @property {TagInfo[]} tags
361
+ * @property {Set<string>} multilineSet
362
+ * @property {Map<string, string>} valueOf tag -> value as written ("" for null)
363
+ * @property {string[]} empty tags with an empty value, in document order
364
+ * @property {string[]} order tag names in document order of their first occurrence
365
+ * @property {Problem[]} warnings
366
+ * @property {ReadOptions} options
367
+ */
368
+
369
+ /**
370
+ * Every check a fill makes before writing: the data's shape, the template's
371
+ * problems, the options', the expected tag set's, each value's. Throws ONE
372
+ * TemplateError (named for `where`) with all of them. fillTemplate and
373
+ * checkFit both go through here, so they refuse exactly the same inputs
374
+ * (ADR-0042 Karar 2).
375
+ * @param {string} where the public function's name
376
+ * @param {import("./xml-scan.js").XmlElement} root
377
+ * @param {Uint8Array} xmlBytes
378
+ * @param {unknown} data
379
+ * @param {unknown} rawOptions
380
+ * @param {Problem[]} extraProblems the package layer's other-part findings
381
+ * @param {readonly string[]} [accepted] option keys the caller accepts (readOptions)
382
+ * @param {(options: ReadOptions) => Problem[]} [ownOptionProblems] the caller's check of its own keys, run
383
+ * right after the shared ones so its problems join the same TemplateError
384
+ * @returns {PreparedFill}
385
+ */
386
+ export function prepareFill(where, root, xmlBytes, data, rawOptions, extraProblems, accepted, ownOptionProblems) {
387
+ const record = requireData(where, data);
388
+ const options = readOptions(where, rawOptions, accepted);
389
+ const { expectedTags, multiline } = options;
390
+ const optionProblems = [...options.problems, ...(ownOptionProblems ? ownOptionProblems(options) : [])];
391
+ const scan = scanTemplate(root, xmlBytes);
392
+ const tags = tagInfo(scan);
393
+ const multilineSet = new Set(multiline ?? []);
394
+
395
+ /** @type {Problem[]} */
396
+ const problems = [...scan.problems, ...extraProblems, ...optionProblems];
397
+ if (tags.length === 0 && scan.problems.length === 0) {
398
+ problems.push({ code: "NO_TAGS", message: "the template holds no tag to fill" });
399
+ }
400
+ if (expectedTags !== undefined) problems.push(...expectedTagProblems(tags, expectedTags));
401
+ if (multiline !== undefined) problems.push(...multilineProblems(tags, multiline));
402
+
403
+ /** @type {Problem[]} */
404
+ const warnings = [...scan.warnings];
405
+ /** @type {Map<string, string>} */
406
+ const valueOf = new Map();
407
+ /** @type {string[]} */
408
+ const empty = [];
409
+ // Data problems in document order of each tag's first occurrence.
410
+ const order = [...new Set(scan.occurrences.map((o) => o.name))];
411
+ for (const name of order) {
412
+ const value = Object.prototype.hasOwnProperty.call(record, name) ? record[name] : undefined;
413
+ if (value === undefined) {
414
+ problems.push({ code: "MISSING_VALUE", message: "the data holds no value for this tag", tag: name });
415
+ continue;
416
+ }
417
+ const check = checkValue(value, { multiline: multilineSet.has(name) });
418
+ for (const issue of check.errors) {
419
+ problems.push({ ...issue, message: `the value for this tag is refused (${issue.code})`, tag: name });
420
+ }
421
+ for (const issue of check.warnings) {
422
+ warnings.push({ ...issue, message: "the value for this tag is not in Unicode NFC; it is written as given", tag: name });
423
+ }
424
+ if (check.errors.length === 0) {
425
+ valueOf.set(name, value === null ? "" : /** @type {string} */ (value));
426
+ if (check.empty) empty.push(name);
427
+ }
428
+ }
429
+ if (problems.length > 0) throw templateError(where, problems);
430
+ return { record, scan, tags, multilineSet, valueOf, empty, order, warnings, options };
431
+ }
432
+
433
+ /**
434
+ * @param {import("./xml-scan.js").XmlElement} root
435
+ * @param {Uint8Array} xmlBytes
436
+ * @param {unknown} data
437
+ * @param {unknown} options
438
+ * @param {Problem[]} extraProblems
439
+ * @returns {{xml: Uint8Array, report: FillReport}}
440
+ */
441
+ function fillParsed(root, xmlBytes, data, options, extraProblems) {
442
+ const { record, scan, tags, multilineSet, valueOf, empty, order, warnings } = prepareFill("fillTemplate", root, xmlBytes, data, options, extraProblems);
443
+
444
+ /** @type {Map<string, Uint8Array[]>} */
445
+ const encoded = new Map(
446
+ [...valueOf].map(([name, value]) => [name, (multilineSet.has(name) ? value.split("\n") : [value]).map(encodeTextValue)]),
447
+ );
448
+ /** @type {{start: number, end: number, bytes: Uint8Array}[]} */
449
+ const edits = [];
450
+ for (const o of scan.occurrences) {
451
+ const lines = /** @type {Uint8Array[]} */ (encoded.get(o.name));
452
+ for (const site of [o.choice, o.fallback]) {
453
+ edits.push({ start: site.t.start, end: site.t.end, bytes: rewriteTag(site.t, site.run, xmlBytes, lines) });
454
+ }
455
+ }
456
+ const xml = applyEdits(xmlBytes, edits);
457
+ readBack(root, xml, scan, valueOf, multilineSet);
458
+
459
+ const used = new Set(order);
460
+ /** @type {FillReport} */
461
+ const report = {
462
+ tags,
463
+ empty: [...empty].sort(),
464
+ unused: Object.keys(record).filter((key) => !used.has(key)).sort(),
465
+ warnings,
466
+ counts: { tags: tags.length, occurrences: scan.occurrences.length, textElementsWritten: edits.length },
467
+ engine: { version: ENGINE_VERSION },
468
+ };
469
+ return { xml, report: devFreeze(report) };
470
+ }
471
+
472
+ // --- package layer ------------------------------------------------------------
473
+
474
+ /**
475
+ * @typedef {object} TemplatePackage
476
+ * @property {import("./zip-container.js").ZipEntry[]} entries
477
+ * @property {import("./zip-container.js").ZipEntry} main
478
+ * @property {Uint8Array} xml word/document.xml, inflated
479
+ * @property {import("./xml-scan.js").XmlElement} root
480
+ * @property {Problem[]} otherPartProblems
481
+ * @property {Map<string, Uint8Array>} parts the inflated XML parts the caller asked to keep
482
+ */
483
+
484
+ /**
485
+ * @param {string} where
486
+ * @param {unknown} err
487
+ * @returns {TemplateError}
488
+ */
489
+ function unreadable(where, err) {
490
+ const reason = err instanceof Error ? err.message : String(err);
491
+ return new TemplateError(`${where}: the package cannot be read — ${reason}`, [{ code: "PACKAGE_UNREADABLE", message: reason }], { cause: err });
492
+ }
493
+
494
+ /**
495
+ * Whether `el` holds a brace in text a reader shows: in a docProps part every
496
+ * text node (docxtemplater fills those), elsewhere the text of every element
497
+ * named `t` (w:t, a:t, m:t). Attributes never count — docProps/custom.xml
498
+ * carries `fmtid="{…}"` GUIDs in all 46 corpus documents.
499
+ * @param {import("./xml-scan.js").XmlElement} el
500
+ * @param {boolean} allText
501
+ * @returns {boolean}
502
+ */
503
+ function holdsBrace(el, allText) {
504
+ for (const c of el.children) {
505
+ if (c.kind === "element") {
506
+ if (!allText && c.localName === "t") {
507
+ for (const x of c.children) {
508
+ if ((x.kind === "text" || x.kind === "cdata") && (x.value.includes("{") || x.value.includes("}"))) return true;
509
+ }
510
+ } else if (holdsBrace(c, allText)) {
511
+ return true;
512
+ }
513
+ } else if (allText && (c.kind === "text" || c.kind === "cdata") && (c.value.includes("{") || c.value.includes("}"))) {
514
+ return true;
515
+ }
516
+ }
517
+ return false;
518
+ }
519
+
520
+ /**
521
+ * Reads the package: word/document.xml parsed, and every other XML part
522
+ * checked for braces (ADR-0037 Karar 6 — the engine fills only the main
523
+ * document, so a tag anywhere else would ship unfilled). An unreadable zip
524
+ * or XML part is PACKAGE_UNREADABLE.
525
+ * @param {string} where
526
+ * @param {unknown} bytes
527
+ * @param {readonly string[]} [keep] part names whose inflated bytes the caller needs (checkFit: styles, settings, theme)
528
+ * @returns {Promise<TemplatePackage>}
529
+ */
530
+ export async function readTemplatePackage(where, bytes, keep = []) {
531
+ if (!(bytes instanceof Uint8Array)) throw new TypeError(`${where}: bytes must be a Uint8Array (a .docx file's bytes)`);
532
+ try {
533
+ const entries = parseZipContainer(bytes);
534
+ const main = resolveMainDocumentPart(entries);
535
+ const xml = await inflateEntry(bytes, main);
536
+ const { root } = scanXml(xml);
537
+ /** @type {Problem[]} */
538
+ const otherPartProblems = [];
539
+ /** @type {Map<string, Uint8Array>} */
540
+ const parts = new Map();
541
+ for (const entry of entries) {
542
+ if (entry === main || entry.name === "[Content_Types].xml" || !entry.name.toLowerCase().endsWith(".xml")) continue;
543
+ const partBytes = await inflateEntry(bytes, entry);
544
+ if (keep.includes(entry.name)) parts.set(entry.name, partBytes);
545
+ const part = scanXml(partBytes).root;
546
+ if (holdsBrace(part, entry.name.startsWith("docProps/"))) {
547
+ otherPartProblems.push({
548
+ code: "TAG_IN_OTHER_PART",
549
+ message: "a brace in a part the engine does not fill (only word/document.xml is filled)",
550
+ part: entry.name,
551
+ });
552
+ }
553
+ }
554
+ return { entries, main, xml, root, otherPartProblems, parts };
555
+ } catch (err) {
556
+ throw unreadable(where, err);
557
+ }
558
+ }
559
+
560
+ /**
561
+ * What a template holds: its tags with their boxes and pages, every problem
562
+ * that would stop a fill, and the warnings. Template problems are reported,
563
+ * not thrown; an unreadable package is a TemplateError (PACKAGE_UNREADABLE).
564
+ * @param {Uint8Array} bytes a .docx file
565
+ * @returns {Promise<{tags: TagInfo[], problems: Problem[], warnings: Problem[]}>}
566
+ */
567
+ export async function inspectTemplate(bytes) {
568
+ const pkg = await readTemplatePackage("inspectTemplate", bytes);
569
+ const scan = scanTemplate(pkg.root, pkg.xml);
570
+ const tags = tagInfo(scan);
571
+ /** @type {Problem[]} */
572
+ const warnings = [...scan.warnings];
573
+ if (tags.length === 0 && scan.problems.length === 0) {
574
+ warnings.push({ code: "NO_TAGS", message: "the template holds no tag to fill" });
575
+ }
576
+ return devFreeze({ tags, problems: [...scan.problems, ...pkg.otherPartProblems], warnings });
577
+ }
578
+
579
+ /**
580
+ * `inspectTemplate` plus the binding manifest's exact tag set (SK10). `ok` is
581
+ * true when nothing would stop a fill with complete data — so a template
582
+ * without tags, only a warning to `inspectTemplate`, is a problem here, as it
583
+ * is to `fillTemplate` (the FR2 review found the two disagreeing).
584
+ * @param {Uint8Array} bytes
585
+ * @param {FillOptions} [options]
586
+ * @returns {Promise<{ok: boolean, tags: TagInfo[], problems: Problem[], warnings: Problem[]}>}
587
+ */
588
+ export async function validateTemplate(bytes, options) {
589
+ const { expectedTags, multiline, problems: optionProblems } = readOptions("validateTemplate", options);
590
+ const info = await inspectTemplate(bytes);
591
+ /** @type {Problem[]} */
592
+ const problems = [...info.problems, ...optionProblems];
593
+ const noTags = info.warnings.find((w) => w.code === "NO_TAGS");
594
+ if (noTags !== undefined) problems.push({ ...noTags });
595
+ if (expectedTags !== undefined) problems.push(...expectedTagProblems(info.tags, expectedTags));
596
+ if (multiline !== undefined) problems.push(...multilineProblems(info.tags, multiline));
597
+ const warnings = info.warnings.filter((w) => w.code !== "NO_TAGS");
598
+ return devFreeze({ ok: problems.length === 0, tags: [...info.tags], problems, warnings });
599
+ }
600
+
601
+ /**
602
+ * Fills a .docx template. Strict: every problem — the package's other parts,
603
+ * the template, the options, the data — arrives in ONE TemplateError and
604
+ * nothing is written. Otherwise only word/document.xml is rewritten; every
605
+ * other part's bytes are copied verbatim (invariant #10).
606
+ * @param {Uint8Array} bytes a .docx file
607
+ * @param {FillData} data a plain object (or one made with Object.create(null))
608
+ * @param {FillOptions} [options]
609
+ * @returns {Promise<{bytes: Uint8Array, documentXml: Uint8Array, report: FillReport}>}
610
+ * `documentXml` is the filled part uncompressed — hash it to compare
611
+ * runtimes (zip bytes depend on the runtime's CompressionStream; FR1-DENO)
612
+ */
613
+ export async function fillTemplate(bytes, data, options) {
614
+ const pkg = await readTemplatePackage("fillTemplate", bytes);
615
+ const { xml, report } = fillParsed(pkg.root, pkg.xml, data, options, pkg.otherPartProblems);
616
+ const out = await writePackage(/** @type {Uint8Array} */ (bytes), pkg.entries, { [pkg.main.name]: xml });
617
+ return { bytes: out, documentXml: xml, report };
618
+ }