@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,581 +1,582 @@
1
- // Template scan for the fill engine (FR2, ADR-0037): where the `{tag}`s are,
2
- // which of them may be filled, and every reason one may not. Pure and
3
- // synchronous — word/document.xml's scanned tree and bytes in, a plain result
4
- // out. Never calls importDocx: the importer's gate (character styles,
5
- // wp:inline, group transforms) has nothing to say about filling.
6
- //
7
- // THE RULES (ADR-0037 Karar 1–3, 7):
8
- // - A tag is the WHOLE text of one `w:t`: `{` + [a-z][a-z0-9_]* + `}`, its
9
- // raw bytes equal to that text, covered by a single text node.
10
- // - The only context a tag may sit in:
11
- // mc:AlternateContent > mc:Choice|mc:Fallback > … > w:txbxContent > w:p >
12
- // w:r > w:t, the AlternateContent holding exactly one Choice (with one
13
- // wp:anchor), one Fallback and one w:txbxContent per branch, not nested,
14
- // in a paragraph with no field.
15
- // - The two copies are compared STRUCTURALLY; only bookmark ids may differ.
16
- // When they are equal, the k-th tag of the Choice copy is the twin of the
17
- // k-th tag of the Fallback copy — no VML id, bookmark name or offset is
18
- // ever used to pair them.
19
- // - Grammar and context problems are reported from the Choice copy (and the
20
- // body) only. A brace in the Fallback copy marks its AlternateContent for
21
- // the copy comparison, which is what surfaces a Fallback-only difference.
22
-
23
- import { getAttr, findChild, isElement } from "./xml-scan.js";
24
- import { readSections, pageForAnchor } from "./sections.js";
25
- import { W_NS, WP_NS, MC_NS, XML_NS } from "./ns.js";
26
-
27
- const TAG_TEXT = /^\{([a-z][a-z0-9_]*)\}$/;
28
- const TAG_ANYWHERE = /\{[a-z][a-z0-9_]*\}/;
29
- const BRACED = /\{[^{}]*\}/;
30
-
31
- // ST_OnOff's false spellings (ECMA-376 §22.9.2.7) — `<w:vanish w:val="0"/>` is not hidden.
32
- const ON_OFF_FALSE = new Set(["0", "false", "off"]);
33
-
34
- const textDecoder = new TextDecoder("utf-8", { fatal: true });
35
-
36
- /**
37
- * @typedef {object} TemplateProblem
38
- * @property {string} code
39
- * @property {string} message never contains a data value
40
- * @property {string} [tag]
41
- * @property {number | null} [docPrId]
42
- * @property {number} [page]
43
- * @property {string} [part]
44
- * @property {string} [path]
45
- */
46
-
47
- /**
48
- * @typedef {object} TagSite one tag `w:t`, in one copy
49
- * @property {string} name
50
- * @property {import("./xml-scan.js").XmlElement} t
51
- * @property {import("./xml-scan.js").XmlElement} run
52
- */
53
-
54
- /**
55
- * @typedef {object} TagOccurrence one tag of a box's Choice copy, with its Fallback twin
56
- * @property {string} name
57
- * @property {number | null} docPrId
58
- * @property {number} page
59
- * @property {number} acIndex
60
- * @property {TagSite} choice
61
- * @property {TagSite} fallback
62
- */
63
-
64
- /**
65
- * @typedef {object} AlternateContentRecord
66
- * @property {import("./xml-scan.js").XmlElement} el
67
- * @property {number} index document (pre-)order among all AlternateContents
68
- * @property {boolean} nested
69
- * @property {import("./xml-scan.js").XmlElement[]} choices
70
- * @property {import("./xml-scan.js").XmlElement[]} fallbacks
71
- * @property {import("./xml-scan.js").XmlElement[]} choiceTx
72
- * @property {import("./xml-scan.js").XmlElement[]} fallbackTx
73
- * @property {import("./xml-scan.js").XmlElement[]} choiceAnchors wp:anchor / wp:inline in the Choice, outside any text box
74
- */
75
-
76
- /**
77
- * @typedef {object} WalkContext
78
- * @property {AlternateContentRecord | null} ac
79
- * @property {"choice" | "fallback" | null} branch
80
- * @property {{el: import("./xml-scan.js").XmlElement, nested: boolean} | null} txbx
81
- */
82
-
83
- /**
84
- * @param {import("./xml-scan.js").XmlElement} el
85
- * @returns {string}
86
- */
87
- function qname(el) {
88
- return el.prefix ? `${el.prefix}:${el.localName}` : el.localName;
89
- }
90
-
91
- /**
92
- * The decoded text of a `w:t`-like element: every text and CDATA child.
93
- * @param {import("./xml-scan.js").XmlElement} el
94
- * @returns {string}
95
- */
96
- function elementText(el) {
97
- let text = "";
98
- for (const c of el.children) {
99
- if (c.kind === "text" || c.kind === "cdata") text += c.value;
100
- }
101
- return text;
102
- }
103
-
104
- /**
105
- * One document-order walk over the whole tree — Choice AND Fallback, inside
106
- * text boxes too — building the AlternateContent records and handing every
107
- * `t` element to `onText` together with its ancestor stack (stack top = its
108
- * parent), and every `w:br` to `onBreak`. No parent pointers exist in
109
- * xml-scan's tree; the stack is the parent chain.
110
- * @param {import("./xml-scan.js").XmlElement} root
111
- * @param {(t: import("./xml-scan.js").XmlElement, ctx: WalkContext, stack: import("./xml-scan.js").XmlElement[]) => void} onText
112
- * @param {(br: import("./xml-scan.js").XmlElement) => void} [onBreak]
113
- * @returns {AlternateContentRecord[]}
114
- */
115
- function walk(root, onText, onBreak = () => {}) {
116
- /** @type {AlternateContentRecord[]} */
117
- const acs = [];
118
- /** @type {import("./xml-scan.js").XmlElement[]} */
119
- const stack = [];
120
- /**
121
- * @param {import("./xml-scan.js").XmlElement} el
122
- * @param {WalkContext} ctx
123
- */
124
- function visit(el, ctx) {
125
- stack.push(el);
126
- for (const child of el.children) {
127
- if (child.kind !== "element") continue;
128
- if (isElement(child, MC_NS, "AlternateContent")) {
129
- /** @type {AlternateContentRecord} */
130
- const rec = {
131
- el: child,
132
- index: acs.length,
133
- nested: ctx.ac !== null,
134
- choices: [],
135
- fallbacks: [],
136
- choiceTx: [],
137
- fallbackTx: [],
138
- choiceAnchors: [],
139
- };
140
- acs.push(rec);
141
- visit(child, { ac: rec, branch: null, txbx: null });
142
- } else if (ctx.ac !== null && el === ctx.ac.el && isElement(child, MC_NS, "Choice")) {
143
- ctx.ac.choices.push(child);
144
- visit(child, { ...ctx, branch: "choice" });
145
- } else if (ctx.ac !== null && el === ctx.ac.el && isElement(child, MC_NS, "Fallback")) {
146
- ctx.ac.fallbacks.push(child);
147
- visit(child, { ...ctx, branch: "fallback" });
148
- } else if (
149
- ctx.ac !== null &&
150
- ctx.branch === "choice" &&
151
- ctx.txbx === null &&
152
- (isElement(child, WP_NS, "anchor") || isElement(child, WP_NS, "inline"))
153
- ) {
154
- ctx.ac.choiceAnchors.push(child);
155
- visit(child, ctx);
156
- } else if (isElement(child, W_NS, "txbxContent")) {
157
- const txbx = { el: child, nested: ctx.txbx !== null };
158
- if (ctx.ac !== null && ctx.branch !== null && !txbx.nested) {
159
- (ctx.branch === "choice" ? ctx.ac.choiceTx : ctx.ac.fallbackTx).push(child);
160
- }
161
- visit(child, { ...ctx, txbx });
162
- } else if (child.localName === "t") {
163
- onText(child, ctx, stack);
164
- } else if (isElement(child, W_NS, "br")) {
165
- onBreak(child);
166
- } else {
167
- visit(child, ctx);
168
- }
169
- }
170
- stack.pop();
171
- }
172
- visit(root, { ac: null, branch: null, txbx: null });
173
- return acs;
174
- }
175
-
176
- /**
177
- * Every AlternateContent's structure, in the same order `scanTemplate`
178
- * numbers them — for checking the copies of a filled output.
179
- * @param {import("./xml-scan.js").XmlElement} root
180
- * @returns {AlternateContentRecord[]}
181
- */
182
- export function collectAlternateContents(root) {
183
- return walk(root, () => {});
184
- }
185
-
186
- /**
187
- * Every `w:t` of the document, in document order, with its decoded text —
188
- * body, Choice and Fallback alike.
189
- * @param {import("./xml-scan.js").XmlElement} root
190
- * @returns {{el: import("./xml-scan.js").XmlElement, text: string}[]}
191
- */
192
- export function textElements(root) {
193
- /** @type {{el: import("./xml-scan.js").XmlElement, text: string}[]} */
194
- const out = [];
195
- walk(root, (t) => {
196
- if (t.uri === W_NS) out.push({ el: t, text: elementText(t) });
197
- });
198
- return out;
199
- }
200
-
201
- /**
202
- * @typedef {{kind: "t", el: import("./xml-scan.js").XmlElement, text: string} | {kind: "br", el: import("./xml-scan.js").XmlElement}} TextToken
203
- */
204
-
205
- /**
206
- * Every `w:t` (with its decoded text) and every `w:br` of the document, in
207
- * document order — body, Choice and Fallback alike. The fill's read-back
208
- * compares these streams (ADR-0039 Karar 4): a multi-line value is its lines'
209
- * `w:t`s with a `w:br` between each two, so counting `w:t`s alone would
210
- * accept an output whose lines were never separated.
211
- * @param {import("./xml-scan.js").XmlElement} root
212
- * @returns {TextToken[]}
213
- */
214
- export function textTokens(root) {
215
- /** @type {TextToken[]} */
216
- const out = [];
217
- walk(
218
- root,
219
- (t) => {
220
- if (t.uri === W_NS) out.push({ kind: "t", el: t, text: elementText(t) });
221
- },
222
- (br) => {
223
- out.push({ kind: "br", el: br });
224
- },
225
- );
226
- return out;
227
- }
228
-
229
- /**
230
- * Two `w:txbxContent` subtrees compared structurally: namespace + local name,
231
- * attributes as a set (with values), decoded text and CDATA, child order.
232
- * Comments count only by position — xml-scan keeps no comment text, and a
233
- * processing instruction is not a node at all — so two copies whose comments
234
- * say different things compare equal; neither is rendered. The one exemption is `w:id` on `w:bookmarkStart`/`w:bookmarkEnd`
235
- * (29 corpus boxes differ only there). Returns the first difference's path,
236
- * e.g. `w:p[1]/w:r[2]/w:rPr/w:rFonts@w:hAnsi`.
237
- * @param {import("./xml-scan.js").XmlElement} a
238
- * @param {import("./xml-scan.js").XmlElement} b
239
- * @returns {{equal: true} | {equal: false, path: string}}
240
- */
241
- export function compareCopies(a, b) {
242
- const path = compareElements(a, b, qname(a));
243
- return path === null ? { equal: true } : { equal: false, path };
244
- }
245
-
246
- /**
247
- * @param {import("./xml-scan.js").XmlElement} el
248
- * @param {import("./xml-scan.js").XmlAttribute} attr
249
- * @returns {boolean}
250
- */
251
- function isExemptAttribute(el, attr) {
252
- return (
253
- el.uri === W_NS &&
254
- (el.localName === "bookmarkStart" || el.localName === "bookmarkEnd") &&
255
- attr.uri === W_NS &&
256
- attr.localName === "id"
257
- );
258
- }
259
-
260
- /**
261
- * @param {import("./xml-scan.js").XmlElement} a
262
- * @param {import("./xml-scan.js").XmlElement} b
263
- * @param {string} path
264
- * @returns {string | null} null when equal
265
- */
266
- function compareElements(a, b, path) {
267
- if (a.uri !== b.uri || a.localName !== b.localName) return `${path} (${qname(a)} vs ${qname(b)})`;
268
- const attrsA = a.attributes.filter((x) => !isExemptAttribute(a, x));
269
- const attrsB = b.attributes.filter((x) => !isExemptAttribute(b, x));
270
- for (const x of attrsA) {
271
- const y = attrsB.find((o) => o.uri === x.uri && o.localName === x.localName);
272
- const name = x.prefix ? `${x.prefix}:${x.localName}` : x.localName;
273
- if (y === undefined) return `${path}@${name} (missing in the Fallback copy)`;
274
- if (y.value !== x.value) return `${path}@${name} (values differ)`;
275
- }
276
- for (const y of attrsB) {
277
- if (!attrsA.some((x) => x.uri === y.uri && x.localName === y.localName)) {
278
- const name = y.prefix ? `${y.prefix}:${y.localName}` : y.localName;
279
- return `${path}@${name} (missing in the Choice copy)`;
280
- }
281
- }
282
- if (a.children.length !== b.children.length) return `${path} (child count ${a.children.length} vs ${b.children.length})`;
283
- /** @type {Map<string, number>} */
284
- const seen = new Map();
285
- for (let i = 0; i < a.children.length; i += 1) {
286
- const ca = a.children[i];
287
- const cb = b.children[i];
288
- if (ca.kind !== cb.kind) return `${path}/node()[${i + 1}] (${ca.kind} vs ${cb.kind})`;
289
- if (ca.kind === "element" && cb.kind === "element") {
290
- const key = `${ca.uri} ${ca.localName}`;
291
- const n = (seen.get(key) ?? 0) + 1;
292
- seen.set(key, n);
293
- const diff = compareElements(ca, cb, `${path}/${qname(ca)}[${n}]`);
294
- if (diff !== null) return diff;
295
- } else if ((ca.kind === "text" || ca.kind === "cdata") && (cb.kind === "text" || cb.kind === "cdata")) {
296
- if (ca.value !== cb.value) return `${path}/text()[${i + 1}] (text differs)`;
297
- }
298
- }
299
- return null;
300
- }
301
-
302
- /**
303
- * @param {import("./xml-scan.js").XmlElement} t
304
- * @param {Uint8Array} xmlBytes
305
- * @returns {{kind: "none"} | {kind: "tag", name: string} | {kind: "problem", code: string, detail: string}}
306
- */
307
- function classifyText(t, xmlBytes) {
308
- const text = elementText(t);
309
- if (!text.includes("{") && !text.includes("}")) return { kind: "none" };
310
- const only = t.children.length === 1 ? t.children[0] : null;
311
- if (only === null || only.kind !== "text" || only.start !== t.contentStart || only.end !== t.contentEnd) {
312
- return { kind: "problem", code: "TAG_TEXT_NOT_PLAIN", detail: "its content is not one plain text node (comment, CDATA or processing instruction)" };
313
- }
314
- const match = TAG_TEXT.exec(text);
315
- if (match !== null) {
316
- if (textDecoder.decode(xmlBytes.subarray(t.contentStart, t.contentEnd)) !== text) {
317
- return { kind: "problem", code: "TAG_ENCODED", detail: "the tag is written with a character reference; write the braces and name as plain text" };
318
- }
319
- return { kind: "tag", name: match[1] };
320
- }
321
- if (TAG_ANYWHERE.test(text)) {
322
- return { kind: "problem", code: "TAG_NOT_ALONE", detail: "a tag must be the whole text of its w:t, with nothing before or after it" };
323
- }
324
- if (BRACED.test(text)) {
325
- return { kind: "problem", code: "INVALID_TAG_NAME", detail: "a tag name is a lowercase ASCII letter followed by lowercase ASCII letters, digits or _" };
326
- }
327
- return { kind: "problem", code: "STRAY_BRACE", detail: "a brace that is not part of a whole-w:t tag (a tag split across runs looks like this)" };
328
- }
329
-
330
- /**
331
- * Why a tag `w:t` sits in a context the engine does not fill, or null.
332
- * @param {import("./xml-scan.js").XmlElement} t
333
- * @param {WalkContext} ctx
334
- * @param {import("./xml-scan.js").XmlElement[]} stack stack top = the w:t's parent
335
- * @returns {{code: string, detail: string} | null}
336
- */
337
- function contextProblem(t, ctx, stack) {
338
- if (t.uri !== W_NS) {
339
- return { code: "TAG_CONTEXT_UNSUPPORTED", detail: `a brace in <${qname(t)}> (only w:t is filled)` };
340
- }
341
- if (ctx.txbx === null) {
342
- if (ctx.ac === null) return { code: "TAG_OUTSIDE_TEXT_BOX", detail: "tags are filled only inside text boxes" };
343
- return { code: "TAG_CONTEXT_UNSUPPORTED", detail: "a w:t inside mc:AlternateContent but outside any w:txbxContent" };
344
- }
345
- if (ctx.txbx.nested) return { code: "TAG_CONTEXT_UNSUPPORTED", detail: "a text box nested inside another text box" };
346
- if (ctx.ac === null || ctx.branch === null) {
347
- return { code: "TAG_CONTEXT_UNSUPPORTED", detail: "a text box outside mc:AlternateContent (VML-only or a bare w:drawing)" };
348
- }
349
- const run = stack[stack.length - 1];
350
- const paragraph = stack[stack.length - 2];
351
- const box = stack[stack.length - 3];
352
- if (!isElement(run, W_NS, "r")) return { code: "TAG_CONTEXT_UNSUPPORTED", detail: `the w:t sits in <${qname(run)}>, not directly in a w:r` };
353
- if (paragraph === undefined || !isElement(paragraph, W_NS, "p")) {
354
- return { code: "TAG_CONTEXT_UNSUPPORTED", detail: `the run sits in <${paragraph === undefined ? "?" : qname(paragraph)}>, not directly in a w:p` };
355
- }
356
- if (box !== ctx.txbx.el) {
357
- return { code: "TAG_CONTEXT_UNSUPPORTED", detail: `the paragraph sits in <${box === undefined ? "?" : qname(box)}>, not directly in the w:txbxContent` };
358
- }
359
- if (hasField(paragraph)) return { code: "TAG_CONTEXT_UNSUPPORTED", detail: "the paragraph holds a field (w:fldChar / w:instrText)" };
360
- return null;
361
- }
362
-
363
- /**
364
- * @param {import("./xml-scan.js").XmlElement} el
365
- * @returns {boolean}
366
- */
367
- function hasField(el) {
368
- for (const c of el.children) {
369
- if (c.kind !== "element") continue;
370
- if (isElement(c, W_NS, "fldChar") || isElement(c, W_NS, "instrText") || isElement(c, W_NS, "fldSimple")) return true;
371
- if (hasField(c)) return true;
372
- }
373
- return false;
374
- }
375
-
376
- /**
377
- * Null when the tag `w:t`'s start tag can be kept or given `xml:space`, else why not.
378
- * @param {import("./xml-scan.js").XmlElement} t
379
- * @returns {string | null}
380
- */
381
- function attributeProblem(t) {
382
- if (t.attributes.length === 0) return null;
383
- const only = t.attributes.length === 1 ? t.attributes[0] : null;
384
- if (only !== null && only.uri === XML_NS && only.localName === "space" && only.value === "preserve") return null;
385
- return `the tag's w:t carries attributes other than xml:space="preserve" (${t.attributes.map((x) => (x.prefix ? `${x.prefix}:` : "") + x.localName).join(", ")})`;
386
- }
387
-
388
- /**
389
- * The run's direct `w:rFonts` sets one of the ascii/hAnsi slots but not the
390
- * other, or sets them to different fonts (FILL-E1: Turkish letters then draw
391
- * in another font than the rest of the value).
392
- * @param {import("./xml-scan.js").XmlElement} run
393
- * @returns {boolean}
394
- */
395
- function fontSlotsSplit(run) {
396
- const rPr = findChild(run, W_NS, "rPr");
397
- const rFonts = rPr && findChild(rPr, W_NS, "rFonts");
398
- if (!rFonts) return false;
399
- const ascii = getAttr(rFonts, W_NS, "asciiTheme") ?? getAttr(rFonts, W_NS, "ascii");
400
- const hAnsi = getAttr(rFonts, W_NS, "hAnsiTheme") ?? getAttr(rFonts, W_NS, "hAnsi");
401
- if (ascii === undefined && hAnsi === undefined) return false;
402
- return ascii !== hAnsi;
403
- }
404
-
405
- /**
406
- * @param {import("./xml-scan.js").XmlElement} run
407
- * @returns {boolean}
408
- */
409
- function isHidden(run) {
410
- const rPr = findChild(run, W_NS, "rPr");
411
- const vanish = rPr && findChild(rPr, W_NS, "vanish");
412
- if (!vanish) return false;
413
- const val = getAttr(vanish, W_NS, "val");
414
- return val === undefined || !ON_OFF_FALSE.has(val);
415
- }
416
-
417
- /**
418
- * @typedef {object} ScanResult
419
- * @property {TagOccurrence[]} occurrences document order (Choice copy)
420
- * @property {TemplateProblem[]} problems
421
- * @property {TemplateProblem[]} warnings
422
- * @property {AlternateContentRecord[]} acs
423
- */
424
-
425
- /**
426
- * @param {import("./xml-scan.js").XmlElement} root scanXml(xmlBytes).root
427
- * @param {Uint8Array} xmlBytes
428
- * @returns {ScanResult}
429
- */
430
- export function scanTemplate(root, xmlBytes) {
431
- const { page, sectPrStarts } = readSections(root);
432
- /** @type {{t: import("./xml-scan.js").XmlElement, code: string, detail: string, acIndex: number | null, tag?: string}[]} */
433
- const pending = [];
434
- /** @type {Map<number, {choice: TagSite[], fallback: TagSite[], braces: boolean}>} */
435
- const byAc = new Map();
436
- /** @param {number} index */
437
- const acEntry = (index) => {
438
- let entry = byAc.get(index);
439
- if (entry === undefined) {
440
- entry = { choice: [], fallback: [], braces: false };
441
- byAc.set(index, entry);
442
- }
443
- return entry;
444
- };
445
-
446
- const acs = walk(root, (t, ctx, stack) => {
447
- const verdict = classifyText(t, xmlBytes);
448
- if (verdict.kind === "none") return;
449
- const acIndex = ctx.ac === null ? null : ctx.ac.index;
450
- if (acIndex !== null) acEntry(acIndex).braces = true;
451
- const reportHere = ctx.branch !== "fallback";
452
- if (verdict.kind === "problem") {
453
- if (reportHere) pending.push({ t, code: verdict.code, detail: verdict.detail, acIndex });
454
- return;
455
- }
456
- const where = contextProblem(t, ctx, stack);
457
- if (where !== null) {
458
- if (reportHere) pending.push({ t, code: where.code, detail: where.detail, acIndex, tag: verdict.name });
459
- return;
460
- }
461
- const attrs = attributeProblem(t);
462
- if (attrs !== null) {
463
- if (reportHere) pending.push({ t, code: "TAG_TEXT_ATTRIBUTES", detail: attrs, acIndex, tag: verdict.name });
464
- return;
465
- }
466
- const site = { name: verdict.name, t, run: stack[stack.length - 1] };
467
- const entry = acEntry(/** @type {number} */ (acIndex));
468
- (ctx.branch === "choice" ? entry.choice : entry.fallback).push(site);
469
- });
470
-
471
- /** @type {Map<number, {docPrId: number | null, page: number}>} */
472
- const attribution = new Map();
473
- /** @type {TemplateProblem[]} */
474
- const acProblems = [];
475
- /** @type {TemplateProblem[]} */
476
- const warnings = [];
477
- /** @type {TagOccurrence[]} */
478
- const occurrences = [];
479
-
480
- for (const [index, entry] of [...byAc.entries()].sort((x, y) => x[0] - y[0])) {
481
- const ac = acs[index];
482
- const anchor = ac.choiceAnchors.length === 1 ? ac.choiceAnchors[0] : null;
483
- const shapeIssues = [];
484
- if (ac.nested) shapeIssues.push("it is nested inside another mc:AlternateContent");
485
- if (ac.choices.length !== 1) shapeIssues.push(`${ac.choices.length} mc:Choice`);
486
- if (ac.fallbacks.length !== 1) shapeIssues.push(`${ac.fallbacks.length} mc:Fallback`);
487
- if (ac.choiceTx.length !== 1) shapeIssues.push(`${ac.choiceTx.length} w:txbxContent in the Choice`);
488
- if (ac.fallbackTx.length !== 1) shapeIssues.push(`${ac.fallbackTx.length} w:txbxContent in the Fallback`);
489
- if (anchor === null) shapeIssues.push(`${ac.choiceAnchors.length} wp:anchor/wp:inline in the Choice`);
490
- else if (!isElement(anchor, WP_NS, "anchor")) shapeIssues.push("the Choice holds wp:inline, not wp:anchor");
491
-
492
- /** @type {number | null} */
493
- let docPrId = null;
494
- let pageNumber = pageForAnchor(sectPrStarts, ac.el.start, page.count);
495
- if (anchor !== null) {
496
- pageNumber = pageForAnchor(sectPrStarts, anchor.start, page.count);
497
- const docPr = findChild(anchor, WP_NS, "docPr");
498
- const id = docPr ? getAttr(docPr, "", "id") : undefined;
499
- if (id !== undefined && /^[0-9]+$/.test(id)) docPrId = Number(id);
500
- }
501
- attribution.set(index, { docPrId, page: pageNumber });
502
-
503
- if (shapeIssues.length > 0) {
504
- acProblems.push({
505
- code: "ALTERNATE_CONTENT_SHAPE",
506
- message: `the text box holding a tag does not have the shape the engine fills: ${shapeIssues.join("; ")}`,
507
- docPrId,
508
- page: pageNumber,
509
- });
510
- continue;
511
- }
512
- const copies = compareCopies(ac.choiceTx[0], ac.fallbackTx[0]);
513
- if (!copies.equal) {
514
- acProblems.push({
515
- code: "COPIES_DIFFER",
516
- message: `the box's mc:Choice and mc:Fallback copies differ at ${copies.path}`,
517
- docPrId,
518
- page: pageNumber,
519
- path: copies.path,
520
- });
521
- continue;
522
- }
523
- // Structurally equal trees can still spell a tag differently in raw bytes
524
- // (a character reference in one copy) — compare the twin lists themselves.
525
- const twinsMatch =
526
- entry.choice.length === entry.fallback.length && entry.choice.every((site, i) => site.name === entry.fallback[i].name);
527
- if (!twinsMatch) {
528
- acProblems.push({
529
- code: "COPIES_DIFFER",
530
- message: "the box's mc:Choice and mc:Fallback copies do not hold the same tags",
531
- docPrId,
532
- page: pageNumber,
533
- });
534
- continue;
535
- }
536
- if (anchor !== null && docPrId === null) {
537
- warnings.push({ code: "DOCPR_MISSING", message: "a text box holding a tag has no numeric wp:docPr id", page: pageNumber });
538
- }
539
- entry.choice.forEach((site, i) => {
540
- occurrences.push({ name: site.name, docPrId, page: pageNumber, acIndex: index, choice: site, fallback: entry.fallback[i] });
541
- });
542
- }
543
-
544
- /** @type {TemplateProblem[]} */
545
- const problems = pending.map((p) => {
546
- const where = p.acIndex !== null && attribution.has(p.acIndex)
547
- ? /** @type {{docPrId: number | null, page: number}} */ (attribution.get(p.acIndex))
548
- : { docPrId: null, page: pageForAnchor(sectPrStarts, p.t.start, page.count) };
549
- /** @type {TemplateProblem} */
550
- const problem = { code: p.code, message: p.detail, docPrId: where.docPrId, page: where.page };
551
- if (p.tag !== undefined) problem.tag = p.tag;
552
- return problem;
553
- });
554
- problems.push(...acProblems);
555
-
556
- /** @type {Map<number, number>} */
557
- const docPrUse = new Map();
558
- for (const index of new Set(occurrences.map((o) => o.acIndex))) {
559
- const id = /** @type {{docPrId: number | null}} */ (attribution.get(index)).docPrId;
560
- if (id !== null) docPrUse.set(id, (docPrUse.get(id) ?? 0) + 1);
561
- }
562
- for (const [id, n] of docPrUse) {
563
- if (n > 1) warnings.push({ code: "DUPLICATE_DOCPR_ID", message: `${n} text boxes holding tags share wp:docPr id ${id}`, docPrId: id });
564
- }
565
- for (const o of occurrences) {
566
- if (fontSlotsSplit(o.choice.run)) {
567
- warnings.push({
568
- code: "FONT_SLOTS_SPLIT",
569
- message: "the tag's run sets the ascii and hAnsi font slots differently, so Turkish letters may draw in another font",
570
- tag: o.name,
571
- docPrId: o.docPrId,
572
- page: o.page,
573
- });
574
- }
575
- if (isHidden(o.choice.run)) {
576
- warnings.push({ code: "HIDDEN_TAG_RUN", message: "the tag's run is hidden (w:vanish)", tag: o.name, docPrId: o.docPrId, page: o.page });
577
- }
578
- }
579
-
580
- return { occurrences, problems, warnings, acs };
581
- }
1
+ // Template scan for the fill engine (FR2, ADR-0037): where the `{tag}`s are,
2
+ // which of them may be filled, and every reason one may not. Pure and
3
+ // synchronous — word/document.xml's scanned tree and bytes in, a plain result
4
+ // out. Never calls importDocx: the importer's gate (character styles,
5
+ // wp:inline, group transforms) has nothing to say about filling.
6
+ //
7
+ // THE RULES (ADR-0037 Karar 1–3, 7):
8
+ // - A tag is the WHOLE text of one `w:t`: `{` + [a-z][a-z0-9_]* + `}`, its
9
+ // raw bytes equal to that text, covered by a single text node.
10
+ // - The only context a tag may sit in:
11
+ // mc:AlternateContent > mc:Choice|mc:Fallback > … > w:txbxContent > w:p >
12
+ // w:r > w:t, the AlternateContent holding exactly one Choice (with one
13
+ // wp:anchor), one Fallback and one w:txbxContent per branch, not nested,
14
+ // in a paragraph with no field.
15
+ // - The two copies are compared STRUCTURALLY; only bookmark ids may differ.
16
+ // When they are equal, the k-th tag of the Choice copy is the twin of the
17
+ // k-th tag of the Fallback copy — no VML id, bookmark name or offset is
18
+ // ever used to pair them.
19
+ // - Grammar and context problems are reported from the Choice copy (and the
20
+ // body) only. A brace in the Fallback copy marks its AlternateContent for
21
+ // the copy comparison, which is what surfaces a Fallback-only difference.
22
+
23
+ import { getAttr, findChild, isElement } from "./xml-scan.js";
24
+ import { readSections, pageForAnchor } from "./sections.js";
25
+ import { W_NS, WP_NS, MC_NS, XML_NS } from "./ns.js";
26
+
27
+ const TAG_TEXT = /^\{([a-z][a-z0-9_]*)\}$/;
28
+ const TAG_ANYWHERE = /\{[a-z][a-z0-9_]*\}/;
29
+ const BRACED = /\{[^{}]*\}/;
30
+
31
+ // ST_OnOff's false spellings (ECMA-376 §22.9.2.7) — `<w:vanish w:val="0"/>` is not hidden.
32
+ const ON_OFF_FALSE = new Set(["0", "false", "off"]);
33
+
34
+ const textDecoder = new TextDecoder("utf-8", { fatal: true });
35
+
36
+ /**
37
+ * @typedef {object} TemplateProblem
38
+ * @property {string} code
39
+ * @property {string} message never contains a data value
40
+ * @property {string} [tag]
41
+ * @property {number | null} [docPrId]
42
+ * @property {number} [page]
43
+ * @property {string} [part]
44
+ * @property {string} [path]
45
+ */
46
+
47
+ /**
48
+ * @typedef {object} TagSite one tag `w:t`, in one copy
49
+ * @property {string} name
50
+ * @property {import("./xml-scan.js").XmlElement} t
51
+ * @property {import("./xml-scan.js").XmlElement} run
52
+ * @property {import("./xml-scan.js").XmlElement} paragraph the w:p holding the run (contextProblem checked it)
53
+ */
54
+
55
+ /**
56
+ * @typedef {object} TagOccurrence one tag of a box's Choice copy, with its Fallback twin
57
+ * @property {string} name
58
+ * @property {number | null} docPrId
59
+ * @property {number} page
60
+ * @property {number} acIndex
61
+ * @property {TagSite} choice
62
+ * @property {TagSite} fallback
63
+ */
64
+
65
+ /**
66
+ * @typedef {object} AlternateContentRecord
67
+ * @property {import("./xml-scan.js").XmlElement} el
68
+ * @property {number} index document (pre-)order among all AlternateContents
69
+ * @property {boolean} nested
70
+ * @property {import("./xml-scan.js").XmlElement[]} choices
71
+ * @property {import("./xml-scan.js").XmlElement[]} fallbacks
72
+ * @property {import("./xml-scan.js").XmlElement[]} choiceTx
73
+ * @property {import("./xml-scan.js").XmlElement[]} fallbackTx
74
+ * @property {import("./xml-scan.js").XmlElement[]} choiceAnchors wp:anchor / wp:inline in the Choice, outside any text box
75
+ */
76
+
77
+ /**
78
+ * @typedef {object} WalkContext
79
+ * @property {AlternateContentRecord | null} ac
80
+ * @property {"choice" | "fallback" | null} branch
81
+ * @property {{el: import("./xml-scan.js").XmlElement, nested: boolean} | null} txbx
82
+ */
83
+
84
+ /**
85
+ * @param {import("./xml-scan.js").XmlElement} el
86
+ * @returns {string}
87
+ */
88
+ function qname(el) {
89
+ return el.prefix ? `${el.prefix}:${el.localName}` : el.localName;
90
+ }
91
+
92
+ /**
93
+ * The decoded text of a `w:t`-like element: every text and CDATA child.
94
+ * @param {import("./xml-scan.js").XmlElement} el
95
+ * @returns {string}
96
+ */
97
+ function elementText(el) {
98
+ let text = "";
99
+ for (const c of el.children) {
100
+ if (c.kind === "text" || c.kind === "cdata") text += c.value;
101
+ }
102
+ return text;
103
+ }
104
+
105
+ /**
106
+ * One document-order walk over the whole tree — Choice AND Fallback, inside
107
+ * text boxes too — building the AlternateContent records and handing every
108
+ * `t` element to `onText` together with its ancestor stack (stack top = its
109
+ * parent), and every `w:br` to `onBreak`. No parent pointers exist in
110
+ * xml-scan's tree; the stack is the parent chain.
111
+ * @param {import("./xml-scan.js").XmlElement} root
112
+ * @param {(t: import("./xml-scan.js").XmlElement, ctx: WalkContext, stack: import("./xml-scan.js").XmlElement[]) => void} onText
113
+ * @param {(br: import("./xml-scan.js").XmlElement) => void} [onBreak]
114
+ * @returns {AlternateContentRecord[]}
115
+ */
116
+ function walk(root, onText, onBreak = () => {}) {
117
+ /** @type {AlternateContentRecord[]} */
118
+ const acs = [];
119
+ /** @type {import("./xml-scan.js").XmlElement[]} */
120
+ const stack = [];
121
+ /**
122
+ * @param {import("./xml-scan.js").XmlElement} el
123
+ * @param {WalkContext} ctx
124
+ */
125
+ function visit(el, ctx) {
126
+ stack.push(el);
127
+ for (const child of el.children) {
128
+ if (child.kind !== "element") continue;
129
+ if (isElement(child, MC_NS, "AlternateContent")) {
130
+ /** @type {AlternateContentRecord} */
131
+ const rec = {
132
+ el: child,
133
+ index: acs.length,
134
+ nested: ctx.ac !== null,
135
+ choices: [],
136
+ fallbacks: [],
137
+ choiceTx: [],
138
+ fallbackTx: [],
139
+ choiceAnchors: [],
140
+ };
141
+ acs.push(rec);
142
+ visit(child, { ac: rec, branch: null, txbx: null });
143
+ } else if (ctx.ac !== null && el === ctx.ac.el && isElement(child, MC_NS, "Choice")) {
144
+ ctx.ac.choices.push(child);
145
+ visit(child, { ...ctx, branch: "choice" });
146
+ } else if (ctx.ac !== null && el === ctx.ac.el && isElement(child, MC_NS, "Fallback")) {
147
+ ctx.ac.fallbacks.push(child);
148
+ visit(child, { ...ctx, branch: "fallback" });
149
+ } else if (
150
+ ctx.ac !== null &&
151
+ ctx.branch === "choice" &&
152
+ ctx.txbx === null &&
153
+ (isElement(child, WP_NS, "anchor") || isElement(child, WP_NS, "inline"))
154
+ ) {
155
+ ctx.ac.choiceAnchors.push(child);
156
+ visit(child, ctx);
157
+ } else if (isElement(child, W_NS, "txbxContent")) {
158
+ const txbx = { el: child, nested: ctx.txbx !== null };
159
+ if (ctx.ac !== null && ctx.branch !== null && !txbx.nested) {
160
+ (ctx.branch === "choice" ? ctx.ac.choiceTx : ctx.ac.fallbackTx).push(child);
161
+ }
162
+ visit(child, { ...ctx, txbx });
163
+ } else if (child.localName === "t") {
164
+ onText(child, ctx, stack);
165
+ } else if (isElement(child, W_NS, "br")) {
166
+ onBreak(child);
167
+ } else {
168
+ visit(child, ctx);
169
+ }
170
+ }
171
+ stack.pop();
172
+ }
173
+ visit(root, { ac: null, branch: null, txbx: null });
174
+ return acs;
175
+ }
176
+
177
+ /**
178
+ * Every AlternateContent's structure, in the same order `scanTemplate`
179
+ * numbers them — for checking the copies of a filled output.
180
+ * @param {import("./xml-scan.js").XmlElement} root
181
+ * @returns {AlternateContentRecord[]}
182
+ */
183
+ export function collectAlternateContents(root) {
184
+ return walk(root, () => {});
185
+ }
186
+
187
+ /**
188
+ * Every `w:t` of the document, in document order, with its decoded text —
189
+ * body, Choice and Fallback alike.
190
+ * @param {import("./xml-scan.js").XmlElement} root
191
+ * @returns {{el: import("./xml-scan.js").XmlElement, text: string}[]}
192
+ */
193
+ export function textElements(root) {
194
+ /** @type {{el: import("./xml-scan.js").XmlElement, text: string}[]} */
195
+ const out = [];
196
+ walk(root, (t) => {
197
+ if (t.uri === W_NS) out.push({ el: t, text: elementText(t) });
198
+ });
199
+ return out;
200
+ }
201
+
202
+ /**
203
+ * @typedef {{kind: "t", el: import("./xml-scan.js").XmlElement, text: string} | {kind: "br", el: import("./xml-scan.js").XmlElement}} TextToken
204
+ */
205
+
206
+ /**
207
+ * Every `w:t` (with its decoded text) and every `w:br` of the document, in
208
+ * document order — body, Choice and Fallback alike. The fill's read-back
209
+ * compares these streams (ADR-0039 Karar 4): a multi-line value is its lines'
210
+ * `w:t`s with a `w:br` between each two, so counting `w:t`s alone would
211
+ * accept an output whose lines were never separated.
212
+ * @param {import("./xml-scan.js").XmlElement} root
213
+ * @returns {TextToken[]}
214
+ */
215
+ export function textTokens(root) {
216
+ /** @type {TextToken[]} */
217
+ const out = [];
218
+ walk(
219
+ root,
220
+ (t) => {
221
+ if (t.uri === W_NS) out.push({ kind: "t", el: t, text: elementText(t) });
222
+ },
223
+ (br) => {
224
+ out.push({ kind: "br", el: br });
225
+ },
226
+ );
227
+ return out;
228
+ }
229
+
230
+ /**
231
+ * Two `w:txbxContent` subtrees compared structurally: namespace + local name,
232
+ * attributes as a set (with values), decoded text and CDATA, child order.
233
+ * Comments count only by position — xml-scan keeps no comment text, and a
234
+ * processing instruction is not a node at all — so two copies whose comments
235
+ * say different things compare equal; neither is rendered. The one exemption is `w:id` on `w:bookmarkStart`/`w:bookmarkEnd`
236
+ * (29 corpus boxes differ only there). Returns the first difference's path,
237
+ * e.g. `w:p[1]/w:r[2]/w:rPr/w:rFonts@w:hAnsi`.
238
+ * @param {import("./xml-scan.js").XmlElement} a
239
+ * @param {import("./xml-scan.js").XmlElement} b
240
+ * @returns {{equal: true} | {equal: false, path: string}}
241
+ */
242
+ export function compareCopies(a, b) {
243
+ const path = compareElements(a, b, qname(a));
244
+ return path === null ? { equal: true } : { equal: false, path };
245
+ }
246
+
247
+ /**
248
+ * @param {import("./xml-scan.js").XmlElement} el
249
+ * @param {import("./xml-scan.js").XmlAttribute} attr
250
+ * @returns {boolean}
251
+ */
252
+ function isExemptAttribute(el, attr) {
253
+ return (
254
+ el.uri === W_NS &&
255
+ (el.localName === "bookmarkStart" || el.localName === "bookmarkEnd") &&
256
+ attr.uri === W_NS &&
257
+ attr.localName === "id"
258
+ );
259
+ }
260
+
261
+ /**
262
+ * @param {import("./xml-scan.js").XmlElement} a
263
+ * @param {import("./xml-scan.js").XmlElement} b
264
+ * @param {string} path
265
+ * @returns {string | null} null when equal
266
+ */
267
+ function compareElements(a, b, path) {
268
+ if (a.uri !== b.uri || a.localName !== b.localName) return `${path} (${qname(a)} vs ${qname(b)})`;
269
+ const attrsA = a.attributes.filter((x) => !isExemptAttribute(a, x));
270
+ const attrsB = b.attributes.filter((x) => !isExemptAttribute(b, x));
271
+ for (const x of attrsA) {
272
+ const y = attrsB.find((o) => o.uri === x.uri && o.localName === x.localName);
273
+ const name = x.prefix ? `${x.prefix}:${x.localName}` : x.localName;
274
+ if (y === undefined) return `${path}@${name} (missing in the Fallback copy)`;
275
+ if (y.value !== x.value) return `${path}@${name} (values differ)`;
276
+ }
277
+ for (const y of attrsB) {
278
+ if (!attrsA.some((x) => x.uri === y.uri && x.localName === y.localName)) {
279
+ const name = y.prefix ? `${y.prefix}:${y.localName}` : y.localName;
280
+ return `${path}@${name} (missing in the Choice copy)`;
281
+ }
282
+ }
283
+ if (a.children.length !== b.children.length) return `${path} (child count ${a.children.length} vs ${b.children.length})`;
284
+ /** @type {Map<string, number>} */
285
+ const seen = new Map();
286
+ for (let i = 0; i < a.children.length; i += 1) {
287
+ const ca = a.children[i];
288
+ const cb = b.children[i];
289
+ if (ca.kind !== cb.kind) return `${path}/node()[${i + 1}] (${ca.kind} vs ${cb.kind})`;
290
+ if (ca.kind === "element" && cb.kind === "element") {
291
+ const key = `${ca.uri} ${ca.localName}`;
292
+ const n = (seen.get(key) ?? 0) + 1;
293
+ seen.set(key, n);
294
+ const diff = compareElements(ca, cb, `${path}/${qname(ca)}[${n}]`);
295
+ if (diff !== null) return diff;
296
+ } else if ((ca.kind === "text" || ca.kind === "cdata") && (cb.kind === "text" || cb.kind === "cdata")) {
297
+ if (ca.value !== cb.value) return `${path}/text()[${i + 1}] (text differs)`;
298
+ }
299
+ }
300
+ return null;
301
+ }
302
+
303
+ /**
304
+ * @param {import("./xml-scan.js").XmlElement} t
305
+ * @param {Uint8Array} xmlBytes
306
+ * @returns {{kind: "none"} | {kind: "tag", name: string} | {kind: "problem", code: string, detail: string}}
307
+ */
308
+ function classifyText(t, xmlBytes) {
309
+ const text = elementText(t);
310
+ if (!text.includes("{") && !text.includes("}")) return { kind: "none" };
311
+ const only = t.children.length === 1 ? t.children[0] : null;
312
+ if (only === null || only.kind !== "text" || only.start !== t.contentStart || only.end !== t.contentEnd) {
313
+ return { kind: "problem", code: "TAG_TEXT_NOT_PLAIN", detail: "its content is not one plain text node (comment, CDATA or processing instruction)" };
314
+ }
315
+ const match = TAG_TEXT.exec(text);
316
+ if (match !== null) {
317
+ if (textDecoder.decode(xmlBytes.subarray(t.contentStart, t.contentEnd)) !== text) {
318
+ return { kind: "problem", code: "TAG_ENCODED", detail: "the tag is written with a character reference; write the braces and name as plain text" };
319
+ }
320
+ return { kind: "tag", name: match[1] };
321
+ }
322
+ if (TAG_ANYWHERE.test(text)) {
323
+ return { kind: "problem", code: "TAG_NOT_ALONE", detail: "a tag must be the whole text of its w:t, with nothing before or after it" };
324
+ }
325
+ if (BRACED.test(text)) {
326
+ return { kind: "problem", code: "INVALID_TAG_NAME", detail: "a tag name is a lowercase ASCII letter followed by lowercase ASCII letters, digits or _" };
327
+ }
328
+ return { kind: "problem", code: "STRAY_BRACE", detail: "a brace that is not part of a whole-w:t tag (a tag split across runs looks like this)" };
329
+ }
330
+
331
+ /**
332
+ * Why a tag `w:t` sits in a context the engine does not fill, or null.
333
+ * @param {import("./xml-scan.js").XmlElement} t
334
+ * @param {WalkContext} ctx
335
+ * @param {import("./xml-scan.js").XmlElement[]} stack stack top = the w:t's parent
336
+ * @returns {{code: string, detail: string} | null}
337
+ */
338
+ function contextProblem(t, ctx, stack) {
339
+ if (t.uri !== W_NS) {
340
+ return { code: "TAG_CONTEXT_UNSUPPORTED", detail: `a brace in <${qname(t)}> (only w:t is filled)` };
341
+ }
342
+ if (ctx.txbx === null) {
343
+ if (ctx.ac === null) return { code: "TAG_OUTSIDE_TEXT_BOX", detail: "tags are filled only inside text boxes" };
344
+ return { code: "TAG_CONTEXT_UNSUPPORTED", detail: "a w:t inside mc:AlternateContent but outside any w:txbxContent" };
345
+ }
346
+ if (ctx.txbx.nested) return { code: "TAG_CONTEXT_UNSUPPORTED", detail: "a text box nested inside another text box" };
347
+ if (ctx.ac === null || ctx.branch === null) {
348
+ return { code: "TAG_CONTEXT_UNSUPPORTED", detail: "a text box outside mc:AlternateContent (VML-only or a bare w:drawing)" };
349
+ }
350
+ const run = stack[stack.length - 1];
351
+ const paragraph = stack[stack.length - 2];
352
+ const box = stack[stack.length - 3];
353
+ if (!isElement(run, W_NS, "r")) return { code: "TAG_CONTEXT_UNSUPPORTED", detail: `the w:t sits in <${qname(run)}>, not directly in a w:r` };
354
+ if (paragraph === undefined || !isElement(paragraph, W_NS, "p")) {
355
+ return { code: "TAG_CONTEXT_UNSUPPORTED", detail: `the run sits in <${paragraph === undefined ? "?" : qname(paragraph)}>, not directly in a w:p` };
356
+ }
357
+ if (box !== ctx.txbx.el) {
358
+ return { code: "TAG_CONTEXT_UNSUPPORTED", detail: `the paragraph sits in <${box === undefined ? "?" : qname(box)}>, not directly in the w:txbxContent` };
359
+ }
360
+ if (hasField(paragraph)) return { code: "TAG_CONTEXT_UNSUPPORTED", detail: "the paragraph holds a field (w:fldChar / w:instrText)" };
361
+ return null;
362
+ }
363
+
364
+ /**
365
+ * @param {import("./xml-scan.js").XmlElement} el
366
+ * @returns {boolean}
367
+ */
368
+ function hasField(el) {
369
+ for (const c of el.children) {
370
+ if (c.kind !== "element") continue;
371
+ if (isElement(c, W_NS, "fldChar") || isElement(c, W_NS, "instrText") || isElement(c, W_NS, "fldSimple")) return true;
372
+ if (hasField(c)) return true;
373
+ }
374
+ return false;
375
+ }
376
+
377
+ /**
378
+ * Null when the tag `w:t`'s start tag can be kept or given `xml:space`, else why not.
379
+ * @param {import("./xml-scan.js").XmlElement} t
380
+ * @returns {string | null}
381
+ */
382
+ function attributeProblem(t) {
383
+ if (t.attributes.length === 0) return null;
384
+ const only = t.attributes.length === 1 ? t.attributes[0] : null;
385
+ if (only !== null && only.uri === XML_NS && only.localName === "space" && only.value === "preserve") return null;
386
+ return `the tag's w:t carries attributes other than xml:space="preserve" (${t.attributes.map((x) => (x.prefix ? `${x.prefix}:` : "") + x.localName).join(", ")})`;
387
+ }
388
+
389
+ /**
390
+ * The run's direct `w:rFonts` sets one of the ascii/hAnsi slots but not the
391
+ * other, or sets them to different fonts (FILL-E1: Turkish letters then draw
392
+ * in another font than the rest of the value).
393
+ * @param {import("./xml-scan.js").XmlElement} run
394
+ * @returns {boolean}
395
+ */
396
+ function fontSlotsSplit(run) {
397
+ const rPr = findChild(run, W_NS, "rPr");
398
+ const rFonts = rPr && findChild(rPr, W_NS, "rFonts");
399
+ if (!rFonts) return false;
400
+ const ascii = getAttr(rFonts, W_NS, "asciiTheme") ?? getAttr(rFonts, W_NS, "ascii");
401
+ const hAnsi = getAttr(rFonts, W_NS, "hAnsiTheme") ?? getAttr(rFonts, W_NS, "hAnsi");
402
+ if (ascii === undefined && hAnsi === undefined) return false;
403
+ return ascii !== hAnsi;
404
+ }
405
+
406
+ /**
407
+ * @param {import("./xml-scan.js").XmlElement} run
408
+ * @returns {boolean}
409
+ */
410
+ function isHidden(run) {
411
+ const rPr = findChild(run, W_NS, "rPr");
412
+ const vanish = rPr && findChild(rPr, W_NS, "vanish");
413
+ if (!vanish) return false;
414
+ const val = getAttr(vanish, W_NS, "val");
415
+ return val === undefined || !ON_OFF_FALSE.has(val);
416
+ }
417
+
418
+ /**
419
+ * @typedef {object} ScanResult
420
+ * @property {TagOccurrence[]} occurrences document order (Choice copy)
421
+ * @property {TemplateProblem[]} problems
422
+ * @property {TemplateProblem[]} warnings
423
+ * @property {AlternateContentRecord[]} acs
424
+ */
425
+
426
+ /**
427
+ * @param {import("./xml-scan.js").XmlElement} root scanXml(xmlBytes).root
428
+ * @param {Uint8Array} xmlBytes
429
+ * @returns {ScanResult}
430
+ */
431
+ export function scanTemplate(root, xmlBytes) {
432
+ const { page, sectPrStarts } = readSections(root);
433
+ /** @type {{t: import("./xml-scan.js").XmlElement, code: string, detail: string, acIndex: number | null, tag?: string}[]} */
434
+ const pending = [];
435
+ /** @type {Map<number, {choice: TagSite[], fallback: TagSite[], braces: boolean}>} */
436
+ const byAc = new Map();
437
+ /** @param {number} index */
438
+ const acEntry = (index) => {
439
+ let entry = byAc.get(index);
440
+ if (entry === undefined) {
441
+ entry = { choice: [], fallback: [], braces: false };
442
+ byAc.set(index, entry);
443
+ }
444
+ return entry;
445
+ };
446
+
447
+ const acs = walk(root, (t, ctx, stack) => {
448
+ const verdict = classifyText(t, xmlBytes);
449
+ if (verdict.kind === "none") return;
450
+ const acIndex = ctx.ac === null ? null : ctx.ac.index;
451
+ if (acIndex !== null) acEntry(acIndex).braces = true;
452
+ const reportHere = ctx.branch !== "fallback";
453
+ if (verdict.kind === "problem") {
454
+ if (reportHere) pending.push({ t, code: verdict.code, detail: verdict.detail, acIndex });
455
+ return;
456
+ }
457
+ const where = contextProblem(t, ctx, stack);
458
+ if (where !== null) {
459
+ if (reportHere) pending.push({ t, code: where.code, detail: where.detail, acIndex, tag: verdict.name });
460
+ return;
461
+ }
462
+ const attrs = attributeProblem(t);
463
+ if (attrs !== null) {
464
+ if (reportHere) pending.push({ t, code: "TAG_TEXT_ATTRIBUTES", detail: attrs, acIndex, tag: verdict.name });
465
+ return;
466
+ }
467
+ const site = { name: verdict.name, t, run: stack[stack.length - 1], paragraph: stack[stack.length - 2] };
468
+ const entry = acEntry(/** @type {number} */ (acIndex));
469
+ (ctx.branch === "choice" ? entry.choice : entry.fallback).push(site);
470
+ });
471
+
472
+ /** @type {Map<number, {docPrId: number | null, page: number}>} */
473
+ const attribution = new Map();
474
+ /** @type {TemplateProblem[]} */
475
+ const acProblems = [];
476
+ /** @type {TemplateProblem[]} */
477
+ const warnings = [];
478
+ /** @type {TagOccurrence[]} */
479
+ const occurrences = [];
480
+
481
+ for (const [index, entry] of [...byAc.entries()].sort((x, y) => x[0] - y[0])) {
482
+ const ac = acs[index];
483
+ const anchor = ac.choiceAnchors.length === 1 ? ac.choiceAnchors[0] : null;
484
+ const shapeIssues = [];
485
+ if (ac.nested) shapeIssues.push("it is nested inside another mc:AlternateContent");
486
+ if (ac.choices.length !== 1) shapeIssues.push(`${ac.choices.length} mc:Choice`);
487
+ if (ac.fallbacks.length !== 1) shapeIssues.push(`${ac.fallbacks.length} mc:Fallback`);
488
+ if (ac.choiceTx.length !== 1) shapeIssues.push(`${ac.choiceTx.length} w:txbxContent in the Choice`);
489
+ if (ac.fallbackTx.length !== 1) shapeIssues.push(`${ac.fallbackTx.length} w:txbxContent in the Fallback`);
490
+ if (anchor === null) shapeIssues.push(`${ac.choiceAnchors.length} wp:anchor/wp:inline in the Choice`);
491
+ else if (!isElement(anchor, WP_NS, "anchor")) shapeIssues.push("the Choice holds wp:inline, not wp:anchor");
492
+
493
+ /** @type {number | null} */
494
+ let docPrId = null;
495
+ let pageNumber = pageForAnchor(sectPrStarts, ac.el.start, page.count);
496
+ if (anchor !== null) {
497
+ pageNumber = pageForAnchor(sectPrStarts, anchor.start, page.count);
498
+ const docPr = findChild(anchor, WP_NS, "docPr");
499
+ const id = docPr ? getAttr(docPr, "", "id") : undefined;
500
+ if (id !== undefined && /^[0-9]+$/.test(id)) docPrId = Number(id);
501
+ }
502
+ attribution.set(index, { docPrId, page: pageNumber });
503
+
504
+ if (shapeIssues.length > 0) {
505
+ acProblems.push({
506
+ code: "ALTERNATE_CONTENT_SHAPE",
507
+ message: `the text box holding a tag does not have the shape the engine fills: ${shapeIssues.join("; ")}`,
508
+ docPrId,
509
+ page: pageNumber,
510
+ });
511
+ continue;
512
+ }
513
+ const copies = compareCopies(ac.choiceTx[0], ac.fallbackTx[0]);
514
+ if (!copies.equal) {
515
+ acProblems.push({
516
+ code: "COPIES_DIFFER",
517
+ message: `the box's mc:Choice and mc:Fallback copies differ at ${copies.path}`,
518
+ docPrId,
519
+ page: pageNumber,
520
+ path: copies.path,
521
+ });
522
+ continue;
523
+ }
524
+ // Structurally equal trees can still spell a tag differently in raw bytes
525
+ // (a character reference in one copy) — compare the twin lists themselves.
526
+ const twinsMatch =
527
+ entry.choice.length === entry.fallback.length && entry.choice.every((site, i) => site.name === entry.fallback[i].name);
528
+ if (!twinsMatch) {
529
+ acProblems.push({
530
+ code: "COPIES_DIFFER",
531
+ message: "the box's mc:Choice and mc:Fallback copies do not hold the same tags",
532
+ docPrId,
533
+ page: pageNumber,
534
+ });
535
+ continue;
536
+ }
537
+ if (anchor !== null && docPrId === null) {
538
+ warnings.push({ code: "DOCPR_MISSING", message: "a text box holding a tag has no numeric wp:docPr id", page: pageNumber });
539
+ }
540
+ entry.choice.forEach((site, i) => {
541
+ occurrences.push({ name: site.name, docPrId, page: pageNumber, acIndex: index, choice: site, fallback: entry.fallback[i] });
542
+ });
543
+ }
544
+
545
+ /** @type {TemplateProblem[]} */
546
+ const problems = pending.map((p) => {
547
+ const where = p.acIndex !== null && attribution.has(p.acIndex)
548
+ ? /** @type {{docPrId: number | null, page: number}} */ (attribution.get(p.acIndex))
549
+ : { docPrId: null, page: pageForAnchor(sectPrStarts, p.t.start, page.count) };
550
+ /** @type {TemplateProblem} */
551
+ const problem = { code: p.code, message: p.detail, docPrId: where.docPrId, page: where.page };
552
+ if (p.tag !== undefined) problem.tag = p.tag;
553
+ return problem;
554
+ });
555
+ problems.push(...acProblems);
556
+
557
+ /** @type {Map<number, number>} */
558
+ const docPrUse = new Map();
559
+ for (const index of new Set(occurrences.map((o) => o.acIndex))) {
560
+ const id = /** @type {{docPrId: number | null}} */ (attribution.get(index)).docPrId;
561
+ if (id !== null) docPrUse.set(id, (docPrUse.get(id) ?? 0) + 1);
562
+ }
563
+ for (const [id, n] of docPrUse) {
564
+ if (n > 1) warnings.push({ code: "DUPLICATE_DOCPR_ID", message: `${n} text boxes holding tags share wp:docPr id ${id}`, docPrId: id });
565
+ }
566
+ for (const o of occurrences) {
567
+ if (fontSlotsSplit(o.choice.run)) {
568
+ warnings.push({
569
+ code: "FONT_SLOTS_SPLIT",
570
+ message: "the tag's run sets the ascii and hAnsi font slots differently, so Turkish letters may draw in another font",
571
+ tag: o.name,
572
+ docPrId: o.docPrId,
573
+ page: o.page,
574
+ });
575
+ }
576
+ if (isHidden(o.choice.run)) {
577
+ warnings.push({ code: "HIDDEN_TAG_RUN", message: "the tag's run is hidden (w:vanish)", tag: o.name, docPrId: o.docPrId, page: o.page });
578
+ }
579
+ }
580
+
581
+ return { occurrences, problems, warnings, acs };
582
+ }