@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
@@ -0,0 +1,598 @@
1
+ // checkFit (FR4a, ADR-0042): whether each filled value stays on the lines its
2
+ // box gives it — the width a text box gives its text, the width a filled line
3
+ // takes, and the decision between them. Integer arithmetic throughout (EMU,
4
+ // 1 pt = 12700, 1 twip = 635, and u = EMU × 2048 for sums of advances);
5
+ // points appear only in the report. No break point is ever chosen
6
+ // (invariant #9): a line is measured whole.
7
+ //
8
+ // A box is measured only in the shape the evidence covers (ADR-0042 Karar 3):
9
+ // one rectangular wps:wsp of its own, unrotated, unframed, its extent the
10
+ // same in wp:extent and a:ext, the body wrapping in one horizontal column.
11
+ // Anything else is a reason — "unknown" — never a guess.
12
+
13
+ import { scanXml, findChild, getAttr } from "./xml-scan.js";
14
+ import { W_NS, WP_NS, A_NS, WPS_NS, WPG_NS, WP14_NS, WORD_COMPAT_URI, XML_NS } from "./ns.js";
15
+ import { paragraphStyleOf, layoutResolver } from "./run-fonts.js";
16
+ import { readTemplatePackage, prepareFill, tagInfo, ENGINE_VERSION } from "./fill.js";
17
+ import { CALIBRI_METRICS } from "../../fonts/calibri-metrics.js";
18
+ import { devFreeze } from "../../dev.js";
19
+
20
+ /** DrawingML's default left and right inset (ECMA-376 §21.1.2.1.1 bodyPr: 91440 EMU, 0.1 in). */
21
+ export const DEFAULT_INSET_EMU = 91440;
22
+ const NON_NEGATIVE = /^[0-9]+$/;
23
+ const ON = new Set(["1", "true", "on"]);
24
+
25
+ // Widths are summed in u = EMU × 2048, so a Calibri advance at a size in half-points is an
26
+ // integer: adv/2048 em × hps/2 pt × 12700 EMU/pt × 2048 = adv × hps × 6350.
27
+ /** u per EMU */
28
+ export const U_PER_EMU = 2048;
29
+ const U_PER_TWIP = 635 * U_PER_EMU;
30
+ const U_PER_ADVANCE_HALF_POINT = (6350 * 2048) / CALIBRI_METRICS.unitsPerEm;
31
+ // Spaces at a line's end: whether Word lets them hang past the edge is not known — FR4-WORD-FIT2 laid out no
32
+ // line that ended in one, U+0020 included (ADR-0042 H3) — so a line is decided both with them and without.
33
+ const SPACE_AT_END = (/** @type {number} */ cp) => cp === 0x20 || cp === 0xa0 || (cp >= 0x2000 && cp <= 0x200a) || cp === 0x202f || cp === 0x205f || cp === 0x3000;
34
+ const EAST_ASIAN_LANGUAGE = /^(zh|ja|ko)(-|$)/i;
35
+ // Paragraph and run children checkFit reads (text, tabs, breaks) or that draw nothing; any other one is a reason.
36
+ const PARAGRAPH_MARKERS = new Set(["pPr", "r", "bookmarkStart", "bookmarkEnd", "proofErr", "commentRangeStart", "commentRangeEnd", "permStart", "permEnd"]);
37
+ const RUN_MARKERS = new Set(["rPr", "t", "tab", "br", "lastRenderedPageBreak"]);
38
+
39
+ /**
40
+ * @typedef {import("./xml-scan.js").XmlElement} XmlElement
41
+ * @typedef {import("./run-fonts.js").Unknown} Unknown
42
+ * @typedef {{widthEmu: number, lInsEmu: number, rInsEmu: number, textWidthEmu: number}} BoxGeometry
43
+ * `textWidthEmu`: the width between the insets, where a paragraph's indents start
44
+ * @typedef {object} LineMeasure one line of a filled paragraph
45
+ * @property {number} startEmu where it starts, from the box's inner left edge: the indents
46
+ * @property {number} limitEmu where it must end: the text width less the right indent
47
+ * @property {number} widthU its natural width, every character counted — trailing spaces too (u = EMU × 2048)
48
+ * @property {number} widthWithoutTrailingU the same with the trailing spaces left out, as if Word hung them (H3)
49
+ * @property {number} glyphs characters in `widthU` since its last exact place — the line start or a tab stop — whose
50
+ * deviations τ covers
51
+ */
52
+
53
+ /** @type {Map<string, Map<number, number>> | null} */
54
+ let advances = null;
55
+ /**
56
+ * The metrics table as a code point -> advance map per style, built on first use.
57
+ * @param {"regular" | "bold" | "italic" | "boldItalic"} style
58
+ * @returns {Map<number, number>}
59
+ */
60
+ function advancesOf(style) {
61
+ if (advances === null) {
62
+ advances = new Map();
63
+ for (const [name, runs] of Object.entries(CALIBRI_METRICS.styles)) {
64
+ /** @type {Map<number, number>} */
65
+ const map = new Map();
66
+ for (const [first, list] of runs) list.forEach((adv, i) => map.set(first + i, adv));
67
+ advances.set(name, map);
68
+ }
69
+ }
70
+ return /** @type {Map<number, number>} */ (advances.get(style));
71
+ }
72
+
73
+ /**
74
+ * @param {XmlElement} el
75
+ * @returns {XmlElement[]}
76
+ */
77
+ const elementChildren = (el) => /** @type {XmlElement[]} */ (el.children.filter((c) => c.kind === "element"));
78
+
79
+ /**
80
+ * The width a scanned text box gives its text, or why it cannot be known.
81
+ * @param {import("./slots.js").AlternateContentRecord} ac a box the slot scan accepted
82
+ * @returns {{box: BoxGeometry} | {unknown: Unknown}}
83
+ */
84
+ export function boxGeometry(ac) {
85
+ /** @param {string} code @param {string} [property] @returns {{unknown: Unknown}} */
86
+ const no = (code, property) => ({ unknown: property === undefined ? { code } : { code, property } });
87
+ const anchor = ac.choiceAnchors[0];
88
+ // Word writes wp14:sizeRelH with pctWidth 0 on boxes of absolute size; any other width is a share of the page.
89
+ const sizeRelH = findChild(anchor, WP14_NS, "sizeRelH");
90
+ const pctWidth = sizeRelH && findChild(sizeRelH, WP14_NS, "pctWidth");
91
+ const pct = pctWidth ? pctWidth.children.map((c) => (c.kind === "text" ? c.value : "")).join("").trim() : undefined;
92
+ if (sizeRelH && pct !== "0") return no("BOX_RELATIVE_SIZE");
93
+ const extent = findChild(anchor, WP_NS, "extent");
94
+ const cx = extent && getAttr(extent, "", "cx");
95
+ if (cx === undefined || !NON_NEGATIVE.test(cx)) return no("BOX_EXTENT_UNREADABLE");
96
+
97
+ const graphic = findChild(anchor, A_NS, "graphic");
98
+ const data = graphic && findChild(graphic, A_NS, "graphicData");
99
+ if (data && findChild(data, WPG_NS, "wgp")) return no("BOX_GROUP");
100
+ const wsp = data && findChild(data, WPS_NS, "wsp");
101
+ const txbx = wsp && findChild(wsp, WPS_NS, "txbx");
102
+ if (!wsp || !txbx || findChild(txbx, W_NS, "txbxContent") !== ac.choiceTx[0]) return no("BOX_SHAPE");
103
+ // A text box Word links into a chain carries an id; its overflow continues in the next box.
104
+ if (getAttr(txbx, "", "id") !== undefined) return no("BOX_LINKED");
105
+
106
+ const spPr = findChild(wsp, WPS_NS, "spPr");
107
+ const xfrm = spPr && findChild(spPr, A_NS, "xfrm");
108
+ if (xfrm) {
109
+ const rot = getAttr(xfrm, "", "rot");
110
+ if (rot !== undefined && rot !== "0") return no("BOX_ROTATED", "rot");
111
+ for (const flip of ["flipH", "flipV"]) {
112
+ const v = getAttr(xfrm, "", flip);
113
+ if (v !== undefined && ON.has(v)) return no("BOX_ROTATED", flip);
114
+ }
115
+ }
116
+ const ext = xfrm && findChild(xfrm, A_NS, "ext");
117
+ if (!ext || getAttr(ext, "", "cx") !== cx) return no("BOX_EXTENT_MISMATCH");
118
+ const geometry = spPr && findChild(spPr, A_NS, "prstGeom");
119
+ if (!geometry || getAttr(geometry, "", "prst") !== "rect") return no("BOX_GEOMETRY");
120
+ const ln = spPr && findChild(spPr, A_NS, "ln");
121
+ if (ln) {
122
+ if (!findChild(ln, A_NS, "noFill")) return no("BOX_OUTLINE");
123
+ } else {
124
+ // No line of its own: the shape style's line reference draws one unless it is 0.
125
+ const style = findChild(wsp, WPS_NS, "style");
126
+ const lnRef = style && findChild(style, A_NS, "lnRef");
127
+ if (lnRef && getAttr(lnRef, "", "idx") !== "0") return no("BOX_OUTLINE");
128
+ }
129
+
130
+ const bodyPr = findChild(wsp, WPS_NS, "bodyPr");
131
+ let lIns = DEFAULT_INSET_EMU;
132
+ let rIns = DEFAULT_INSET_EMU;
133
+ if (bodyPr) {
134
+ /** @type {[string, (v: string) => boolean][]} */
135
+ const rules = [
136
+ ["wrap", (v) => v === "square"],
137
+ ["vert", (v) => v === "horz"],
138
+ ["numCol", (v) => v === "1"],
139
+ ["rot", (v) => v === "0"],
140
+ ["fromWordArt", (v) => !ON.has(v)],
141
+ ];
142
+ for (const [name, ok] of rules) {
143
+ const v = getAttr(bodyPr, "", name);
144
+ if (v !== undefined && !ok(v)) return no("BOX_BODY", name);
145
+ }
146
+ for (const c of elementChildren(bodyPr)) {
147
+ if (c.uri === A_NS && c.localName === "normAutofit") return no("BOX_AUTOFIT");
148
+ if (!(c.uri === A_NS && (c.localName === "noAutofit" || c.localName === "spAutoFit"))) return no("BOX_BODY", c.localName);
149
+ }
150
+ const l = getAttr(bodyPr, "", "lIns");
151
+ const r = getAttr(bodyPr, "", "rIns");
152
+ if ((l !== undefined && !NON_NEGATIVE.test(l)) || (r !== undefined && !NON_NEGATIVE.test(r))) return no("BOX_INSET_UNREADABLE");
153
+ if (l !== undefined) lIns = Number(l);
154
+ if (r !== undefined) rIns = Number(r);
155
+ }
156
+ const widthEmu = Number(cx);
157
+ const textWidthEmu = widthEmu - lIns - rIns;
158
+ if (textWidthEmu <= 0) return no("BOX_NO_ROOM");
159
+ return { box: { widthEmu, lInsEmu: lIns, rInsEmu: rIns, textWidthEmu } };
160
+ }
161
+
162
+ /**
163
+ * @typedef {{cp: number, u: number, sp: number, tab?: true}} Item a character, or a tab's jump to its stop
164
+ * @typedef {{lines: LineMeasure[], stops: number[]}} Laid the lines, and the stop each tab reached (u)
165
+ * @typedef {{unknown: Unknown & {codePoint?: number}} | {overflow: {code: "TAB_STOP_PASSED"}}} NotLaid
166
+ */
167
+
168
+ /**
169
+ * The lines a paragraph holds once its tags are filled, each measured: where it
170
+ * starts, where it must end, how wide it is (ADR-0042 Karar 5). A line ends at a
171
+ * w:br — the template's, or one a multi-line value's LF becomes. No break point is
172
+ * ever chosen (invariant #9): a line is measured whole, as the template and the
173
+ * values give it.
174
+ *
175
+ * A tab jumps to the first left stop past the pen, stops measured from the box's
176
+ * inner left edge (H6). The paragraph is laid out once with its tags empty: that
177
+ * fixes the stop each tab reaches. Filled, a tab whose text has passed that stop
178
+ * has broken the column the template aligned — an overflow, TAB_STOP_PASSED, even
179
+ * if the line itself still fits.
180
+ * @param {object} input
181
+ * @param {XmlElement} input.paragraph
182
+ * @param {BoxGeometry} input.box
183
+ * @param {ReturnType<typeof import("./run-fonts.js").layoutResolver>} input.layout
184
+ * @param {Map<XmlElement, string>} input.values tag run -> the value written into it
185
+ * @param {boolean} [input.preserve] every w:t inherits xml:space="preserve" (inheritsPreserve);
186
+ * otherwise a template text with edge whitespace and no preserve of its own is unknown —
187
+ * Word may drop that whitespace
188
+ * @returns {{lines: LineMeasure[]} | NotLaid}
189
+ */
190
+ export function measureParagraph({ paragraph, box, layout, values, preserve = false }) {
191
+ const styleId = paragraphStyleOf(paragraph);
192
+ const p = layout.paragraph(styleId, findChild(paragraph, W_NS, "pPr"));
193
+ if (p.unknown.length > 0) return { unknown: p.unknown[0] };
194
+ if (p.leftTwips < 0 || p.rightTwips < 0 || p.leftTwips + p.firstLineTwips < 0) return { unknown: { code: "INDENT_OUTSIDE" } };
195
+ const limitEmu = box.textWidthEmu - p.rightTwips * 635;
196
+ const limitU = limitEmu * U_PER_EMU;
197
+ /** @param {number} line @returns {number} EMU */
198
+ const startOf = (line) => (p.leftTwips + (line === 0 ? p.firstLineTwips : 0)) * 635;
199
+
200
+ /**
201
+ * @param {Map<XmlElement, string>} vals
202
+ * @param {number[] | null} expected the stops the blank layout's tabs reached; null for that layout itself
203
+ * @returns {Laid | NotLaid}
204
+ */
205
+ const layOut = (vals, expected) => {
206
+ /** @type {Item[][]} */
207
+ const lines = [[]];
208
+ /** @type {number[]} */
209
+ const stops = [];
210
+ let x = startOf(0) * U_PER_EMU;
211
+ let sinceExact = 0;
212
+ for (const child of paragraph.children) {
213
+ if (child.kind !== "element") continue;
214
+ if (child.uri !== W_NS || !PARAGRAPH_MARKERS.has(child.localName)) return { unknown: { code: "PARAGRAPH_CONTENT", property: child.localName } };
215
+ if (child.localName !== "r") continue;
216
+ const run = layout.run(styleId, findChild(child, W_NS, "rPr"));
217
+ if (run.unknown.length > 0) return { unknown: run.unknown[0] };
218
+ const hps = run.sizeHalfPoints;
219
+ if (hps === null) return { unknown: { code: "SIZE_UNKNOWN" } };
220
+ const style = run.bold ? (run.italic ? "boldItalic" : "bold") : run.italic ? "italic" : "regular";
221
+ const table = advancesOf(style);
222
+ const value = vals.get(child);
223
+ /** @param {string} text @returns {Unknown & {codePoint?: number} | null} */
224
+ const add = (text) => {
225
+ for (const ch of text) {
226
+ const cp = /** @type {number} */ (ch.codePointAt(0));
227
+ const font = cp <= 0x7f ? run.font.ascii : run.font.hAnsi;
228
+ if (font === null || font.toLowerCase() !== CALIBRI_METRICS.family.toLowerCase()) return { code: "FONT_NOT_SUPPORTED" };
229
+ if (cp > 0x7f && run.hint !== null && run.hint !== "default") return { code: "FONT_HINT" };
230
+ if (cp > 0x7f && run.eastAsiaLanguage !== null && EAST_ASIAN_LANGUAGE.test(run.eastAsiaLanguage)) return { code: "EAST_ASIAN_LANGUAGE" };
231
+ const adv = table.get(cp);
232
+ if (adv === undefined) return { code: "UNKNOWN_CHARACTER", codePoint: cp };
233
+ // Letter spacing follows every character, the last included (ADR-0042 H5).
234
+ const sp = run.spacingTwips * U_PER_TWIP;
235
+ const u = adv * hps * U_PER_ADVANCE_HALF_POINT + sp;
236
+ lines[lines.length - 1].push({ cp, u, sp });
237
+ x += u;
238
+ sinceExact += 1;
239
+ }
240
+ return null;
241
+ };
242
+ const newLine = () => {
243
+ lines.push([]);
244
+ x = startOf(lines.length - 1) * U_PER_EMU;
245
+ sinceExact = 0;
246
+ };
247
+ /** @returns {Unknown | {overflow: {code: "TAB_STOP_PASSED"}} | null} */
248
+ const tab = () => {
249
+ // A hanging first line gets an implicit stop at the left indent: not modelled.
250
+ if (p.firstLineTwips < 0) return { code: "TAB_HANGING" };
251
+ // Measured in Word only on left-aligned lines in boxes without a left inset (FR4-WORD-FIT2):
252
+ // a centred or right line moves its stops, and an inset leaves "the inner edge" untested (H6).
253
+ if (p.jc !== "left" && p.jc !== "start") return { code: "TAB_ALIGNMENT" };
254
+ if (box.lInsEmu !== 0) return { code: "TAB_INSET" };
255
+ // The pen's place is exact at a line start or a stop; τ grows with the glyphs since.
256
+ const tau = TAU_BASE_U + TAU_PER_GLYPH_U * sinceExact;
257
+ let stop;
258
+ if (expected !== null) {
259
+ stop = expected[stops.length];
260
+ if (x > stop + tau) return { overflow: { code: "TAB_STOP_PASSED" } };
261
+ if (x >= stop - tau) return { code: "TAB_BORDERLINE" };
262
+ } else {
263
+ const at = p.tabs.map((t) => ({ ...t, u: t.posTwips * 635 * U_PER_EMU }));
264
+ const next = at.find((t) => t.u > x);
265
+ const previous = at.filter((t) => t.u <= x).pop();
266
+ if (next === undefined) return { code: "TAB_DEFAULT" };
267
+ if (!next.left) return { code: "TAB_STOP_KIND" };
268
+ if (next.u - x <= tau || (previous !== undefined && x - previous.u <= tau)) return { code: "TAB_BORDERLINE" };
269
+ if (next.u > limitU) return { code: "TAB_BEYOND" };
270
+ stop = next.u;
271
+ }
272
+ lines[lines.length - 1].push({ cp: 0x09, u: stop - x, sp: 0, tab: true });
273
+ stops.push(stop);
274
+ x = stop;
275
+ sinceExact = 0;
276
+ return null;
277
+ };
278
+ if (value !== undefined) {
279
+ // A tag run holds its one w:t and nothing else that draws (the slot scan's grammar); the value
280
+ // replaces it, a multi-line value's LFs becoming w:br inside the run (ADR-0039).
281
+ for (const [i, line] of value.split("\n").entries()) {
282
+ if (i > 0) newLine();
283
+ const bad = add(line);
284
+ if (bad !== null) return { unknown: bad };
285
+ }
286
+ continue;
287
+ }
288
+ for (const c of child.children) {
289
+ if (c.kind !== "element") continue;
290
+ if (c.uri !== W_NS || !RUN_MARKERS.has(c.localName)) return { unknown: { code: "RUN_CONTENT", property: c.localName } };
291
+ if (c.localName === "t") {
292
+ const text = c.children.map((y) => (y.kind === "text" || y.kind === "cdata" ? y.value : "")).join("");
293
+ if (!preserve && getAttr(c, XML_NS, "space") !== "preserve" && /^\s|\s$/.test(text)) return { unknown: { code: "WHITESPACE_UNPRESERVED" } };
294
+ const bad = add(text);
295
+ if (bad !== null) return { unknown: bad };
296
+ } else if (c.localName === "tab") {
297
+ const bad = tab();
298
+ if (bad !== null) return "overflow" in bad ? bad : { unknown: bad };
299
+ } else if (c.localName === "br") {
300
+ const type = getAttr(c, W_NS, "type");
301
+ if (type !== undefined && type !== "textWrapping") return { unknown: { code: "RUN_CONTENT", property: `br ${type}` } };
302
+ newLine();
303
+ }
304
+ }
305
+ }
306
+ return {
307
+ stops,
308
+ lines: lines.map((items, i) => {
309
+ let end = items.length;
310
+ while (end > 0 && SPACE_AT_END(items[end - 1].cp)) end -= 1;
311
+ const sum = (/** @type {number} */ n) => items.slice(0, n).reduce((u, c) => u + c.u, 0);
312
+ const lastTab = items.map((c) => c.tab === true).lastIndexOf(true);
313
+ return {
314
+ startEmu: startOf(i),
315
+ limitEmu,
316
+ widthU: sum(items.length),
317
+ widthWithoutTrailingU: sum(end),
318
+ glyphs: items.length - lastTab - 1,
319
+ };
320
+ }),
321
+ };
322
+ };
323
+
324
+ const tabbed = paragraph.children.some((r) => r.kind === "element" && r.uri === W_NS && r.localName === "r" && !values.has(r) && findChild(r, W_NS, "tab") !== undefined);
325
+ if (!tabbed) {
326
+ const laid = layOut(values, null);
327
+ return "lines" in laid ? { lines: laid.lines } : laid;
328
+ }
329
+ const blank = layOut(new Map([...values.keys()].map((run) => [run, ""])), null);
330
+ if (!("lines" in blank)) return blank;
331
+ const laid = layOut(values, blank.stops);
332
+ return "lines" in laid ? { lines: laid.lines } : laid;
333
+ }
334
+
335
+ // --- the decision and the public function --------------------------------------------------------
336
+
337
+ const STYLES_PART = "word/styles.xml";
338
+ const SETTINGS_PART = "word/settings.xml";
339
+ const THEME_PART = "word/theme/theme1.xml";
340
+ // w:compat children the FR4-WORD-FIT gate measures with (KR09 carries them); one turned off is no change.
341
+ const COMPAT_OPTIONS = new Set(["ulTrailSpace", "shapeLayoutLikeWW8", "useFELayout"]);
342
+ // Word 2010's compatibility settings next to mode 14; the others touch tables, mirrored indents or
343
+ // OpenType features a run asks for (w14, itself unknown).
344
+ const COMPAT_SETTINGS = new Set(["compatibilityMode", "overrideTableStyleFontSizeAndJustification", "enableOpenTypeFeatures", "doNotFlipMirrorIndents", "differentiateMultirowTableHeaders"]);
345
+ const OFF = new Set(["0", "false", "off"]);
346
+ /** τ = 0.05 pt + 0.025 pt a glyph (ADR-0042 Karar 6), in u. */
347
+ export const TAU_BASE_U = 635 * U_PER_EMU;
348
+ export const TAU_PER_GLYPH_U = 317.5 * U_PER_EMU;
349
+ /** @type {Record<FitStatus, number>} */
350
+ const SEVERITY = { empty: 0, fits: 1, unknown: 2, overflows: 3 };
351
+
352
+ /**
353
+ * @typedef {"fits" | "overflows" | "unknown" | "empty"} FitStatus
354
+ * @typedef {{status: "fits" | "overflows"} | {status: "unknown", reason: Unknown}} LineVerdict
355
+ * @typedef {object} FitOccurrence
356
+ * @property {number | null} docPrId
357
+ * @property {number} page
358
+ * @property {FitStatus} status
359
+ * @property {string} [reason] from a closed list (ADR-0042)
360
+ * @property {string} [property] the property or element the reason names
361
+ * @property {number} [codePoint] the character the table lacks
362
+ * @property {number} [widthPt] the tightest line's width
363
+ * @property {number} [availablePt] the room it has
364
+ * @property {number} [marginPt] room less width; negative when it overflows
365
+ * @property {number} [lines] a multi-line value's line count
366
+ * @property {number} [capacity]
367
+ * @property {string[]} [paragraphTags] the other tags measured on the same line
368
+ * @typedef {{name: string, status: FitStatus, occurrences: FitOccurrence[]}} FitTag
369
+ * @typedef {object} FitReport
370
+ * @property {boolean} ok no occurrence overflows or is unknown — the caller's approval rule
371
+ * @property {FitTag[]} tags sorted by name
372
+ * @property {{tags: number, occurrences: number, fits: number, overflows: number, unknown: number, empty: number}} counts
373
+ * @property {{version: string, metrics: {family: string, carlito: string | null, calibri: string | null}}} engine
374
+ */
375
+
376
+ /**
377
+ * One line against its room: fits when even with its trailing spaces counted and τ more it would
378
+ * fit, overflows when even with them hanging and τ less it would not; between, unknown (ADR-0042
379
+ * Karar 6) — TRAILING_SPACE when the trailing spaces alone decide.
380
+ * @param {LineMeasure} line
381
+ * @returns {LineVerdict}
382
+ */
383
+ export function decideLine(line) {
384
+ const available = (line.limitEmu - line.startEmu) * U_PER_EMU;
385
+ const tau = TAU_BASE_U + TAU_PER_GLYPH_U * line.glyphs;
386
+ if (line.widthU <= available - tau) return { status: "fits" };
387
+ if (line.widthWithoutTrailingU > available + tau) return { status: "overflows" };
388
+ const trailing = line.widthWithoutTrailingU !== line.widthU && (line.widthWithoutTrailingU <= available - tau || line.widthU > available + tau);
389
+ return { status: "unknown", reason: { code: trailing ? "TRAILING_SPACE" : "BORDERLINE" } };
390
+ }
391
+
392
+ /**
393
+ * Whether every w:t of the document inherits xml:space="preserve": the root sets it and no element
394
+ * below sets anything else (Word writes the root's; all 46 corpus documents carry it).
395
+ * @param {XmlElement} root
396
+ * @returns {boolean}
397
+ */
398
+ export function inheritsPreserve(root) {
399
+ if (getAttr(root, XML_NS, "space") !== "preserve") return false;
400
+ /** @param {XmlElement} el @returns {boolean} */
401
+ const overrides = (el) => elementChildren(el).some((c) => {
402
+ const v = getAttr(c, XML_NS, "space");
403
+ return (v !== undefined && v !== "preserve") || overrides(c);
404
+ });
405
+ return !overrides(root);
406
+ }
407
+
408
+ /**
409
+ * Settings that change every line, or that the evidence does not cover: the compatibility mode
410
+ * (14 only), the w:compat options, East Asian punctuation compression, a character grid.
411
+ * @param {Uint8Array | undefined} settingsXml
412
+ * @param {XmlElement} documentRoot
413
+ * @returns {Unknown | null}
414
+ */
415
+ export function documentUnknown(settingsXml, documentRoot) {
416
+ if (settingsXml === undefined) return { code: "COMPAT_MODE" };
417
+ const settings = scanXml(settingsXml).root;
418
+ const control = findChild(settings, W_NS, "characterSpacingControl");
419
+ if (control && getAttr(control, W_NS, "val") !== "doNotCompress") return { code: "SETTING", property: "characterSpacingControl" };
420
+ const compat = findChild(settings, W_NS, "compat");
421
+ /** @type {string | undefined} */
422
+ let mode;
423
+ for (const c of compat ? elementChildren(compat) : []) {
424
+ if (c.uri === W_NS && c.localName === "compatSetting") {
425
+ const name = getAttr(c, W_NS, "name");
426
+ if (getAttr(c, W_NS, "uri") !== WORD_COMPAT_URI || name === undefined || !COMPAT_SETTINGS.has(name)) return { code: "COMPAT_OPTION", property: name ?? "compatSetting" };
427
+ if (name === "compatibilityMode") mode = getAttr(c, W_NS, "val");
428
+ } else if (!(c.uri === W_NS && COMPAT_OPTIONS.has(c.localName)) && !OFF.has(getAttr(c, W_NS, "val") ?? "")) {
429
+ return { code: "COMPAT_OPTION", property: c.localName };
430
+ }
431
+ }
432
+ if (mode !== "14") return { code: "COMPAT_MODE" };
433
+ /** @param {XmlElement} el @returns {boolean} */
434
+ const charGrid = (el) =>
435
+ elementChildren(el).some((c) => {
436
+ if (c.uri === W_NS && c.localName === "docGrid") {
437
+ const type = getAttr(c, W_NS, "type");
438
+ return type !== undefined && type !== "default" && type !== "lines";
439
+ }
440
+ return charGrid(c);
441
+ });
442
+ return charGrid(documentRoot) ? { code: "DOCUMENT_GRID" } : null;
443
+ }
444
+
445
+ /**
446
+ * checkFit's own option: `capacity`, tag -> the lines a multi-line tag's box shows.
447
+ * @param {unknown} value
448
+ * @param {readonly string[]} multiline
449
+ * @returns {{capacity: Map<string, number>, problems: import("./fill.js").Problem[]}}
450
+ */
451
+ function readCapacity(value, multiline) {
452
+ /** @type {Map<string, number>} */
453
+ const capacity = new Map();
454
+ if (value === undefined) return { capacity, problems: [] };
455
+ const proto = value !== null && typeof value === "object" ? Object.getPrototypeOf(value) : undefined;
456
+ const entries = proto === Object.prototype || proto === null ? Object.entries(/** @type {object} */ (value)) : null;
457
+ if (entries === null || !entries.every(([, n]) => Number.isSafeInteger(n) && /** @type {number} */ (n) > 0)) {
458
+ throw new TypeError("checkFit: options.capacity must be a plain object of positive integers (tag -> lines)");
459
+ }
460
+ /** @type {import("./fill.js").Problem[]} */
461
+ const problems = [];
462
+ for (const [name, n] of entries.sort((x, y) => (x[0] < y[0] ? -1 : 1))) {
463
+ if (multiline.includes(name)) capacity.set(name, /** @type {number} */ (n));
464
+ else problems.push({ code: "CAPACITY_NOT_MULTILINE", message: "capacity names a tag the multiline option does not", tag: name });
465
+ }
466
+ return { capacity, problems };
467
+ }
468
+
469
+ /** @param {number} u @returns {number} */
470
+ const ptOf = (u) => Math.round((u / U_PER_EMU / 12700) * 100) / 100;
471
+
472
+ /**
473
+ * Whether each value of `data` stays on the lines its box gives it, as Word lays the filled
474
+ * template out (ADR-0042). Validates exactly as fillTemplate does — the same TemplateError for
475
+ * the same input — and writes nothing. The report carries no value: widths, reasons, code points.
476
+ * @param {Uint8Array} bytes a .docx template
477
+ * @param {import("./fill.js").FillData} data
478
+ * @param {{expectedTags?: readonly string[], multiline?: readonly string[], capacity?: Record<string, number>}} [options]
479
+ * `capacity`: tag -> lines, for multi-line tags only; a binding manifest carries it as `fit`
480
+ * @returns {Promise<FitReport>}
481
+ */
482
+ export async function checkFit(bytes, data, options) {
483
+ const pkg = await readTemplatePackage("checkFit", bytes, [STYLES_PART, SETTINGS_PART, THEME_PART]);
484
+ /** @type {Map<string, number>} */
485
+ let capacity = new Map();
486
+ const prepared = prepareFill("checkFit", pkg.root, pkg.xml, data, options, pkg.otherPartProblems, ["expectedTags", "multiline", "capacity"], (o) => {
487
+ const read = readCapacity(o.given.capacity, o.multiline ?? []);
488
+ capacity = read.capacity;
489
+ return read.problems;
490
+ });
491
+ const { scan, valueOf, multilineSet } = prepared;
492
+ const styles = pkg.parts.get(STYLES_PART);
493
+ const everywhere = styles === undefined ? { code: "STYLES_MISSING" } : documentUnknown(pkg.parts.get(SETTINGS_PART), pkg.root);
494
+ const layout = styles === undefined ? null : layoutResolver(styles, pkg.parts.get(THEME_PART) ?? null);
495
+ const preserve = inheritsPreserve(pkg.root);
496
+
497
+ /** @type {Map<XmlElement, string>} */
498
+ const values = new Map(scan.occurrences.map((o) => [o.choice.run, /** @type {string} */ (valueOf.get(o.name))]));
499
+ /** @type {Map<XmlElement, string[]>} */
500
+ const tagsOf = new Map();
501
+ for (const o of scan.occurrences) tagsOf.set(o.choice.paragraph, [...(tagsOf.get(o.choice.paragraph) ?? []), o.name]);
502
+ /** @type {Map<XmlElement, ReturnType<typeof measureParagraph>>} */
503
+ const measured = new Map();
504
+
505
+ /** @type {Map<string, FitOccurrence[]>} */
506
+ const byName = new Map();
507
+ for (const o of scan.occurrences) {
508
+ const paragraph = o.choice.paragraph;
509
+ const runs = paragraph.children.filter((c) => c.kind === "element" && c.uri === W_NS && c.localName === "r").length;
510
+ const others = [...new Set((tagsOf.get(paragraph) ?? []).filter((n) => n !== o.name))].sort();
511
+ /** @type {FitOccurrence} */
512
+ const out = { docPrId: o.docPrId, page: o.page, status: "unknown" };
513
+ /** @param {Unknown & {codePoint?: number}} u */
514
+ const unknown = (u) => {
515
+ out.status = "unknown";
516
+ out.reason = u.code;
517
+ if (u.property !== undefined) out.property = u.property;
518
+ if (u.codePoint !== undefined) out.codePoint = u.codePoint;
519
+ };
520
+ /**
521
+ * @param {string} name
522
+ * @param {LineMeasure[]} lines
523
+ * @param {boolean} lone the tag's run is the paragraph's only one
524
+ */
525
+ const decide = (name, lines, lone) => {
526
+ const verdicts = lines.map(decideLine);
527
+ const roomOf = (/** @type {LineMeasure} */ l) => (l.limitEmu - l.startEmu) * U_PER_EMU;
528
+ const tightest = lines.reduce((best, l) => (roomOf(l) - l.widthU < roomOf(best) - best.widthU ? l : best));
529
+ out.widthPt = ptOf(tightest.widthU);
530
+ out.availablePt = ptOf(roomOf(tightest));
531
+ out.marginPt = ptOf(roomOf(tightest) - tightest.widthU);
532
+ const firstUnknown = verdicts.find((v) => v.status === "unknown");
533
+ if (!multilineSet.has(name)) {
534
+ if (verdicts.some((v) => v.status === "overflows")) out.status = "overflows";
535
+ else if (firstUnknown !== undefined && firstUnknown.status === "unknown") unknown(firstUnknown.reason);
536
+ else out.status = "fits";
537
+ return;
538
+ }
539
+ // A multi-line value: n lines the caller broke, m of them too wide — each of those takes at
540
+ // least one more line; how many more is the browser's to say (FR4b).
541
+ out.lines = lines.length;
542
+ if (!lone) return unknown({ code: "SHARED_MULTILINE" });
543
+ const c = capacity.get(name);
544
+ if (c === undefined) return unknown({ code: "NO_CAPACITY" });
545
+ out.capacity = c;
546
+ const m = verdicts.filter((v) => v.status === "overflows").length;
547
+ if (lines.length + m > c) out.status = "overflows";
548
+ else if (m === 0 && firstUnknown === undefined) out.status = "fits";
549
+ else if (m > 0) unknown({ code: "WRAPS" });
550
+ else if (firstUnknown !== undefined && firstUnknown.status === "unknown") unknown(firstUnknown.reason);
551
+ };
552
+
553
+ const value = /** @type {string} */ (valueOf.get(o.name));
554
+ // Empty is nothing written: whitespace still takes width, and a multi-line value's LFs take lines.
555
+ if (value === "" && runs === 1) {
556
+ out.status = "empty";
557
+ } else if (everywhere !== null) {
558
+ unknown(everywhere);
559
+ } else {
560
+ const geometry = boxGeometry(scan.acs[o.acIndex]);
561
+ if ("unknown" in geometry) unknown(geometry.unknown);
562
+ else {
563
+ let m = measured.get(paragraph);
564
+ if (m === undefined) {
565
+ m = measureParagraph({ paragraph, box: geometry.box, layout: /** @type {ReturnType<typeof layoutResolver>} */ (layout), values, preserve });
566
+ measured.set(paragraph, m);
567
+ }
568
+ if ("unknown" in m) unknown(m.unknown);
569
+ else if ("overflow" in m) {
570
+ out.status = "overflows";
571
+ out.reason = m.overflow.code;
572
+ } else decide(o.name, m.lines, runs === 1);
573
+ }
574
+ }
575
+ if (others.length > 0) out.paragraphTags = others;
576
+ byName.set(o.name, [...(byName.get(o.name) ?? []), out]);
577
+ }
578
+
579
+ /** @type {FitTag[]} */
580
+ const tags = tagInfo(scan).map(({ name }) => {
581
+ const occurrences = /** @type {FitOccurrence[]} */ (byName.get(name));
582
+ const status = occurrences.reduce((worst, x) => (SEVERITY[x.status] > SEVERITY[worst] ? x.status : worst), /** @type {FitStatus} */ ("empty"));
583
+ return { name, status, occurrences };
584
+ });
585
+ const all = tags.flatMap((t) => t.occurrences);
586
+ /** @param {FitStatus} s */
587
+ const count = (s) => all.filter((x) => x.status === s).length;
588
+ const counts = { tags: tags.length, occurrences: all.length, fits: count("fits"), overflows: count("overflows"), unknown: count("unknown"), empty: count("empty") };
589
+ const source = CALIBRI_METRICS.source.regular;
590
+ /** @type {FitReport} */
591
+ const report = {
592
+ ok: counts.overflows === 0 && counts.unknown === 0,
593
+ tags,
594
+ counts,
595
+ engine: { version: ENGINE_VERSION, metrics: { family: CALIBRI_METRICS.family, carlito: source.carlito.version, calibri: source.calibri.version } },
596
+ };
597
+ return devFreeze(report);
598
+ }