@sametalmdr/word-editor 0.2.0-alpha → 0.3.0-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +108 -70
- package/LICENSE +21 -21
- package/README.md +286 -247
- package/dist/canvas/box-clipboard.js +238 -238
- package/dist/canvas/box-selection.js +80 -80
- package/dist/canvas/canvas-editor.js +502 -502
- package/dist/canvas/controller.js +717 -717
- package/dist/canvas/geometry.js +131 -131
- package/dist/canvas/gesture.d.ts +0 -1
- package/dist/canvas/gesture.js +289 -289
- package/dist/canvas/handles.js +212 -212
- package/dist/canvas/menu.js +82 -82
- package/dist/canvas/mode-controller.js +81 -81
- package/dist/canvas/nudge.js +108 -108
- package/dist/canvas/overlay.js +149 -149
- package/dist/canvas/snap.js +132 -132
- package/dist/canvas/viewport.js +112 -112
- package/dist/canvas/z-order.js +171 -171
- package/dist/commands/alignment.js +150 -150
- package/dist/commands/box.js +272 -272
- package/dist/commands/clipboard.js +93 -93
- package/dist/commands/graphemes.js +112 -112
- package/dist/commands/marks.js +153 -153
- package/dist/commands/paragraph-props.js +176 -176
- package/dist/commands/size.js +368 -368
- package/dist/commands/text.js +291 -291
- package/dist/docx.d.ts +1 -0
- package/dist/docx.js +15 -13
- package/dist/editor/editing-host.js +434 -434
- package/dist/element.js +41 -41
- package/dist/fonts/OFL-Carlito.txt +94 -0
- package/dist/fonts/calibri-metrics.d.ts +27 -0
- package/dist/fonts/calibri-metrics.js +88 -0
- package/dist/history/history.js +433 -433
- package/dist/index.js +63 -63
- package/dist/input/beforeinput.js +224 -224
- package/dist/input/clipboard.js +107 -107
- package/dist/input/composition.js +341 -341
- package/dist/input/keymap.js +54 -54
- package/dist/io/ooxml/custgeom.js +72 -72
- package/dist/io/ooxml/drawingml.js +232 -232
- package/dist/io/ooxml/edits.js +67 -67
- package/dist/io/ooxml/export-splice.js +880 -880
- package/dist/io/ooxml/export.js +365 -365
- package/dist/io/ooxml/fill.d.ts +93 -8
- package/dist/io/ooxml/fill.js +618 -562
- package/dist/io/ooxml/fit.d.ts +252 -0
- package/dist/io/ooxml/fit.js +598 -0
- package/dist/io/ooxml/import-gate.js +200 -200
- package/dist/io/ooxml/import.js +691 -691
- package/dist/io/ooxml/inflate.js +126 -126
- package/dist/io/ooxml/media.js +87 -87
- package/dist/io/ooxml/ns.d.ts +2 -0
- package/dist/io/ooxml/ns.js +26 -21
- package/dist/io/ooxml/numbering.js +290 -290
- package/dist/io/ooxml/opc-write.js +171 -171
- package/dist/io/ooxml/provenance.js +93 -93
- package/dist/io/ooxml/recipe.js +708 -708
- package/dist/io/ooxml/relationships.js +59 -59
- package/dist/io/ooxml/run-fonts.d.ts +89 -0
- package/dist/io/ooxml/run-fonts.js +378 -0
- package/dist/io/ooxml/slots.d.ts +4 -0
- package/dist/io/ooxml/slots.js +582 -581
- package/dist/io/ooxml/styles-resolve.js +257 -257
- package/dist/io/ooxml/units.js +43 -43
- package/dist/io/ooxml/value.js +120 -120
- package/dist/io/ooxml/xml-scan.js +616 -616
- package/dist/io/ooxml/zip-container.js +228 -228
- package/dist/io/ooxml/zip-reject.js +106 -106
- package/dist/measure/placement.js +83 -83
- package/dist/measure/report.js +75 -75
- package/dist/model/ids.js +70 -70
- package/dist/model/normalize.js +146 -146
- package/dist/model/position.js +283 -283
- package/dist/model/transaction.js +209 -209
- package/dist/platform/capabilities.js +25 -25
- package/dist/react/index.js +21 -21
- package/dist/selection/controller.js +315 -315
- package/dist/selection/dom-map.js +334 -334
- package/dist/state/editor-state.js +109 -109
- package/dist/styles.css +66 -66
- package/package.json +94 -93
package/dist/io/ooxml/fill.js
CHANGED
|
@@ -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.
|
|
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
|
-
*
|
|
181
|
-
*
|
|
182
|
-
* @
|
|
183
|
-
* @
|
|
184
|
-
* @
|
|
185
|
-
*/
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
*/
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
/** @type {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
}
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
* @param {
|
|
348
|
-
* @param {
|
|
349
|
-
* @
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
)
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
}
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
/**
|
|
434
|
-
* @param {
|
|
435
|
-
* @param {
|
|
436
|
-
* @
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
*/
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
* @
|
|
476
|
-
* @
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
*
|
|
527
|
-
*
|
|
528
|
-
* @
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
+
}
|