@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,880 +1,880 @@
1
- // P6 piece 4b + piece 7a: re-write ONLY the `w:txbxContent` of each touched
2
- // container in the untouched document.xml, leaving every other byte identical.
3
- // NOT a full export — this produces document.xml BYTES, not a .docx package
4
- // (repack is piece 2). NO new anchor (the user edited an existing box's text,
5
- // they did not add one — a new anchor is a later slice), and the `mc:Fallback`
6
- // VML is never touched (§3.5 dirty-only already leaves it, and 4a measured Word
7
- // accepts a Choice/plain drawing whose Fallback is stale).
8
- //
9
- // FORMATTING (piece 7a, ADR-0017 opaque blobs): both `w:pPr` and `w:rPr` are
10
- // re-emitted, never dropped.
11
- // - `w:pPr` is copied VERBATIM from the paragraph's `pPrRef` blob unless the
12
- // paragraph carries an editor override — `Paragraph.jc` (ADR-0029) or
13
- // `pPrOverride.ind` (ADR-0030). Then `emitParagraphProps` re-emits it in
14
- // canonical CT_PPr order, every OTHER child byte-for-byte, so numPr/spacing
15
- // survive an alignment or indent edit exactly as they survive a text edit.
16
- // - `w:rPr` for an UNCHANGED run is its `rPrRef` blob VERBATIM (font/color/sz
17
- // preserved byte-for-byte). When the user toggled a mark or changed the size
18
- // (ADR-0028/0031), `emitRunProps` re-emits the blob with `w:b`/`w:i`/`w:u`/
19
- // `w:sz` set to the run's current values in canonical CT_RPr order, every
20
- // OTHER child carried through byte-for-byte. A run with no original rPr
21
- // (freshly typed) gets a marks-only rPr, or none.
22
- // SCOPE: b/i/u/sz on runs, jc/ind on paragraphs. color/vertAlign/kerning/font
23
- // are NOT written. `w:szCs` is not updated with `w:sz` (undocumented until
24
- // 2026-10-17; decided by master plan MP3's Word experiment). Geometry: x/y only
25
- // (`buildGeometryEdits` below). New anchors, deletions and grouped multi-box are
26
- // later slices.
27
- // (Header brought up to date 2026-10-17 — until then it still said the editor
28
- // never edits paragraph properties and that only b/i/u were written.) 🔴 DOĞRULANMALI: an "off" mark is written as ABSENCE (no `<w:b/>`),
29
- // not `<w:b w:val="0"/>` — correct when the run's effective style is not bold
30
- // (the overwhelming text-box case), under-specified for a bold-by-default style
31
- // (4a did not measure Word's save-normalisation, so we stay on the plain side).
32
- //
33
- // The guarantee (§3.5, ARCHITECTURE §3.5): touch N containers -> exactly N
34
- // txbxContent ranges differ, every other byte is byte-identical.
35
- //
36
- // Locating the range: piece (1)'s provenance gives `documentXmlBytes` and, per
37
- // container, its anchor's byte range (`anchorRanges`) plus the pPr/rPr blob
38
- // ranges (`propBlobs`). document.xml declares its namespace prefixes only on the
39
- // root, so a blob cannot be scanned in isolation; the whole (original)
40
- // document.xml is scanned once here, and every blob element is found by its
41
- // start offset in that one parse.
42
-
43
- import { scanXml, findChild } from "./xml-scan.js";
44
- import { collectAnchors } from "./drawingml.js";
45
- import { W_NS, WP_NS, A_NS, WPG_NS } from "./ns.js";
46
- import { containerById } from "../../model/node.js";
47
- import { emuToPt, ptToEmu } from "./units.js";
48
- import { applyEdits } from "./edits.js";
49
-
50
- const encoder = new TextEncoder();
51
-
52
- // Mirrors drawingml.js's importer: these `w:val` values mean the mark is OFF, so
53
- // `<w:b w:val="0"/>` reads as not-bold — the exact inverse of what we write.
54
- const MARK_OFF_VALUES = new Set(["0", "false", "none"]);
55
-
56
- // Canonical CT_RPr child order (ECMA-376 §17.3.2.28). We only ever INSERT
57
- // w:b/w:i/w:u; every other child is carried through verbatim, so this table
58
- // exists solely to place those three at their schema-correct position relative
59
- // to the rest (4a's deney (d): Word tolerates non-canonical order on open, but
60
- // its save-normalisation was not measured, so we write canonical — güvenli taraf).
61
- const CT_RPR_ORDER = [
62
- "rStyle", "rFonts", "b", "bCs", "i", "iCs", "caps", "smallCaps", "strike",
63
- "dstrike", "outline", "shadow", "emboss", "imprint", "noProof", "snapToGrid",
64
- "vanish", "webHidden", "color", "spacing", "w", "kern", "position", "sz",
65
- "szCs", "highlight", "u", "effect", "bdr", "shd", "fitText", "vertAlign",
66
- "rtl", "cs", "em", "lang", "eastAsianLayout", "specVanish", "oMath",
67
- ];
68
- /** @type {Map<string, number>} localName -> canonical rank */
69
- const RPR_RANK = new Map(CT_RPR_ORDER.map((name, i) => [name, i]));
70
-
71
- // Canonical CT_PPr child order (ECMA-376 §17.3.1.26) — the pPr twin of
72
- // CT_RPR_ORDER above, and written to the same rule: the table is COMPLETE
73
- // (every child the schema's sequence defines, in schema order) even though
74
- // this exporter only ever INSERTS `w:jc`. Every other child is carried
75
- // through verbatim, so the extra names exist purely so that a future
76
- // editable pPr field (ind, spacing, ...) already has its slot — exactly the
77
- // shape CT_RPR_ORDER has, where 30+ names are ranked and only b/i/u/sz are
78
- // ever emitted.
79
- //
80
- // Measured 2026-08-31 before writing this (ADR-0029): 14,321 distinct pPr
81
- // blobs across the 46-document corpus, 0 of them in non-canonical order —
82
- // so a rank-based insert is safe on real Word output. The table itself is
83
- // derived from the SPEC, not from that corpus; the measurement only confirms
84
- // real documents agree with it.
85
- const CT_PPR_ORDER = [
86
- "pStyle", "keepNext", "keepLines", "pageBreakBefore", "framePr", "widowControl",
87
- "numPr", "suppressLineNumbers", "pBdr", "shd", "tabs", "suppressAutoHyphens",
88
- "kinsoku", "wordWrap", "overflowPunct", "topLinePunct", "autoSpaceDE",
89
- "autoSpaceDN", "bidi", "adjustRightInd", "snapToGrid", "spacing", "ind",
90
- "contextualSpacing", "mirrorIndents", "suppressOverlap", "jc", "textDirection",
91
- "textAlignment", "textboxTightWrap", "outlineLvl", "divId", "cnfStyle",
92
- "rPr", "sectPr", "pPrChange",
93
- ];
94
- /** @type {Map<string, number>} localName -> canonical rank */
95
- const PPR_RANK = new Map(CT_PPR_ORDER.map((name, i) => [name, i]));
96
-
97
- const OPEN_PPR = encoder.encode("<w:pPr>");
98
- const CLOSE_PPR = encoder.encode("</w:pPr>");
99
-
100
- const OPEN_P = encoder.encode("<w:p>");
101
- const CLOSE_P = encoder.encode("</w:p>");
102
- const OPEN_R = encoder.encode("<w:r>");
103
- const CLOSE_R = encoder.encode("</w:r>");
104
- const OPEN_RPR = encoder.encode("<w:rPr>");
105
- const CLOSE_RPR = encoder.encode("</w:rPr>");
106
-
107
- /**
108
- * @param {string} text
109
- * @returns {string}
110
- */
111
- function escapeXml(text) {
112
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
113
- }
114
-
115
- /** The `<w:b/>`/`<w:i/>`/`<w:u .../>` element source for a set mark. */
116
- const MARK_XML = { bold: "<w:b/>", italic: "<w:i/>", underline: '<w:u w:val="single"/>' };
117
- /** Which mark each CT_RPr child localName represents (for dropping existing ones). */
118
- const BIU_LOCAL = /** @type {Record<string, "bold" | "italic" | "underline" | undefined>} */ ({ b: "bold", i: "italic", u: "underline" });
119
-
120
- /**
121
- * Index every element in the tree by its start offset. pPr/rPr live inside
122
- * `w:txbxContent`, so this must descend everywhere (unlike `collectAnchors`).
123
- * @param {import("./xml-scan.js").XmlElement} root
124
- * @returns {Map<number, import("./xml-scan.js").XmlElement>}
125
- */
126
- function indexElementsByStart(root) {
127
- /** @type {Map<number, import("./xml-scan.js").XmlElement>} */
128
- const map = new Map();
129
- const stack = [root];
130
- while (stack.length > 0) {
131
- const el = /** @type {import("./xml-scan.js").XmlElement} */ (stack.pop());
132
- map.set(el.start, el);
133
- for (const child of el.children) {
134
- if (child.kind === "element") stack.push(child);
135
- }
136
- }
137
- return map;
138
- }
139
-
140
- /**
141
- * The `w:val` of a direct `w:*` attribute, or undefined.
142
- * @param {import("./xml-scan.js").XmlElement} el
143
- * @param {string} localName
144
- * @returns {string | undefined}
145
- */
146
- function wAttr(el, localName) {
147
- for (const a of el.attributes) {
148
- if (a.uri === W_NS && a.localName === localName) return a.value;
149
- }
150
- return undefined;
151
- }
152
-
153
- /**
154
- * The b/i/u state an rPr element encodes, matching import's `runMarks`
155
- * (an off-value counts as absent).
156
- * @param {import("./xml-scan.js").XmlElement} rPrEl
157
- * @returns {{bold: boolean, italic: boolean, underline: boolean}}
158
- */
159
- function rPrMarks(rPrEl) {
160
- const out = { bold: false, italic: false, underline: false };
161
- for (const child of rPrEl.children) {
162
- if (child.kind !== "element" || child.uri !== W_NS) continue;
163
- const name = BIU_LOCAL[child.localName];
164
- if (name === undefined) continue;
165
- const val = wAttr(child, "val");
166
- if (val === undefined || !MARK_OFF_VALUES.has(val)) out[name] = true;
167
- }
168
- return out;
169
- }
170
-
171
- /**
172
- * The existing direct `<w:sz w:val="N"/>` an rPr element carries, as the
173
- * raw XML attribute STRING — `readRPr`'s own convention (`styles-resolve.
174
- * js`), never converted here. `undefined` when the element carries none
175
- * (ADR-0028: unlike b/i/u, `sz`'s absence is not itself a target — the
176
- * caller decides whether that means "leave untouched" or "no override").
177
- * @param {import("./xml-scan.js").XmlElement} rPrEl
178
- * @returns {string | undefined}
179
- */
180
- function rPrSz(rPrEl) {
181
- for (const child of rPrEl.children) {
182
- if (child.kind !== "element" || child.uri !== W_NS || child.localName !== "sz") continue;
183
- return wAttr(child, "val");
184
- }
185
- return undefined;
186
- }
187
-
188
- /**
189
- * Resolve a container-scoped prop ref to its original blob: the verbatim bytes
190
- * AND the parsed element (needed to walk/merge rPr children). Null when the run
191
- * or paragraph had no such property in the source.
192
- * @param {import("./provenance.js").Provenance} provenance
193
- * @param {number} containerId
194
- * @param {string | undefined} ref
195
- * @param {Map<number, import("./xml-scan.js").XmlElement>} elementByStart
196
- * @returns {{bytes: Uint8Array, el: import("./xml-scan.js").XmlElement | null} | null}
197
- */
198
- function resolveBlob(provenance, containerId, ref, elementByStart) {
199
- if (ref === undefined) return null;
200
- const byRef = provenance.propBlobs.get(containerId);
201
- if (byRef === undefined) return null;
202
- const range = byRef.get(ref);
203
- if (range === undefined) return null;
204
- return {
205
- bytes: provenance.documentXmlBytes.subarray(range.start, range.end),
206
- el: elementByStart.get(range.start) ?? null,
207
- };
208
- }
209
-
210
- /**
211
- * Canonical `<w:b/><w:i/><w:u .../><w:sz .../>` inner for a mark set (only
212
- * set marks) — ordered by `RPR_RANK`, not source order: `sz` (rank 23) sorts
213
- * BETWEEN `i` (rank 4) and `u` (rank 26), so a naive "b then i then u then
214
- * sz" concatenation would have placed a fresh run's size AFTER underline,
215
- * violating CT_RPr's own schema order (ADR-0028, caught before this landed —
216
- * the three-way b/i/u concatenation happened to already be rank-ascending,
217
- * masking that it was never actually sorting).
218
- * @param {{bold: boolean, italic: boolean, underline: boolean, sz: number | undefined}} want
219
- * @returns {string}
220
- */
221
- function rPrInnerFromWant(want) {
222
- /** @type {{rank: number, xml: string}[]} */
223
- const parts = [];
224
- if (want.bold) parts.push({ rank: /** @type {number} */ (RPR_RANK.get("b")), xml: MARK_XML.bold });
225
- if (want.italic) parts.push({ rank: /** @type {number} */ (RPR_RANK.get("i")), xml: MARK_XML.italic });
226
- if (want.underline) parts.push({ rank: /** @type {number} */ (RPR_RANK.get("u")), xml: MARK_XML.underline });
227
- if (want.sz !== undefined) {
228
- parts.push({ rank: /** @type {number} */ (RPR_RANK.get("sz")), xml: `<w:sz w:val="${want.sz}"/>` });
229
- }
230
- parts.sort((a, b) => a.rank - b.rank);
231
- return parts.map((p) => p.xml).join("");
232
- }
233
-
234
- /**
235
- * Emit a run's `w:rPr` into `parts`. Unchanged -> the original blob verbatim;
236
- * changed -> the blob merged with `want` (b/i/u in canonical order, other
237
- * children byte-for-byte); no blob -> a marks-only rPr, or nothing.
238
- * @param {Uint8Array[]} parts
239
- * @param {import("../../model/node.js").Run} run
240
- * @param {number} containerId
241
- * @param {import("./provenance.js").Provenance} provenance
242
- * @param {Map<number, import("./xml-scan.js").XmlElement>} elementByStart
243
- * @returns {void}
244
- */
245
- function emitRunProps(parts, run, containerId, provenance, elementByStart) {
246
- const marks = run.marks;
247
- const want = {
248
- bold: marks.bold === true,
249
- italic: marks.italic === true,
250
- underline: marks.underline === true,
251
- // ADR-0028: undefined ≠ "off" here — b/i/u always have a definite
252
- // target (on or off), but sz has no such target when the user never
253
- // touched it; `undefined` means "leave whatever was there untouched",
254
- // not "force size absent". See the szChanged gate below.
255
- sz: typeof marks.sz === "number" ? marks.sz : undefined,
256
- };
257
- const blob = resolveBlob(provenance, containerId, marks.rPrRef, elementByStart);
258
-
259
- if (blob === null) {
260
- const inner = rPrInnerFromWant(want);
261
- if (inner !== "") parts.push(encoder.encode(`<w:rPr>${inner}</w:rPr>`));
262
- return;
263
- }
264
- if (blob.el === null) {
265
- parts.push(blob.bytes); // index miss (unexpected): safest is verbatim
266
- return;
267
- }
268
- const have = rPrMarks(blob.el);
269
- const haveSzRaw = rPrSz(blob.el);
270
- const haveSz = haveSzRaw !== undefined ? Number.parseInt(haveSzRaw, 10) : undefined;
271
- // A change only if the model actually holds a direct override AND it
272
- // differs from what the blob already says — `want.sz === undefined`
273
- // never forces a change, unlike b/i/u's always-definite want.
274
- const szChanged = want.sz !== undefined && want.sz !== haveSz;
275
- if (have.bold === want.bold && have.italic === want.italic && have.underline === want.underline && !szChanged) {
276
- parts.push(blob.bytes); // UNCHANGED -> verbatim (ADR-0017)
277
- return;
278
- }
279
-
280
- // CHANGED -> re-emit with b/i/u = want (+ sz, when szChanged), canonical
281
- // order, other children verbatim.
282
- const bytes = provenance.documentXmlBytes;
283
- /** @type {{rank: number, xml: string}[]} */
284
- const toPlace = [];
285
- if (want.bold) toPlace.push({ rank: /** @type {number} */ (RPR_RANK.get("b")), xml: MARK_XML.bold });
286
- if (want.italic) toPlace.push({ rank: /** @type {number} */ (RPR_RANK.get("i")), xml: MARK_XML.italic });
287
- if (want.underline) toPlace.push({ rank: /** @type {number} */ (RPR_RANK.get("u")), xml: MARK_XML.underline });
288
- if (szChanged) {
289
- toPlace.push({ rank: /** @type {number} */ (RPR_RANK.get("sz")), xml: `<w:sz w:val="${want.sz}"/>` });
290
- }
291
- toPlace.sort((a, b) => a.rank - b.rank);
292
-
293
- // Would the result be an empty <w:rPr></w:rPr>? (blob held only b/i/u [+
294
- // an sz now being replaced], all now off/replaced, no other children.)
295
- // Then omit the rPr entirely — cleaner and identical in meaning. An
296
- // UNCHANGED sz (szChanged false) still counts as "other" — it survives
297
- // verbatim in the loop below, same as any child this function does not
298
- // understand.
299
- const hasOther = blob.el.children.some(
300
- (c) =>
301
- c.kind === "element" &&
302
- !(c.uri === W_NS && BIU_LOCAL[c.localName] !== undefined) &&
303
- !(szChanged && c.uri === W_NS && c.localName === "sz"),
304
- );
305
- if (!hasOther && toPlace.length === 0) return;
306
-
307
- parts.push(OPEN_RPR);
308
- let placed = 0;
309
- let prevRank = -1;
310
- for (const child of blob.el.children) {
311
- if (child.kind !== "element") continue;
312
- if (child.uri === W_NS && BIU_LOCAL[child.localName] !== undefined) {
313
- continue; // drop existing b/i/u — `want` replaces them
314
- }
315
- if (szChanged && child.uri === W_NS && child.localName === "sz") {
316
- continue; // drop the existing sz — `want.sz` replaces it
317
- }
318
- const rank = child.uri === W_NS && RPR_RANK.has(child.localName)
319
- ? /** @type {number} */ (RPR_RANK.get(child.localName))
320
- : prevRank; // unknown child keeps its predecessor's rank (stays adjacent)
321
- prevRank = rank;
322
- while (placed < toPlace.length && toPlace[placed].rank < rank) {
323
- parts.push(encoder.encode(toPlace[placed].xml));
324
- placed += 1;
325
- }
326
- parts.push(bytes.subarray(child.start, child.end)); // other child, verbatim
327
- }
328
- while (placed < toPlace.length) {
329
- parts.push(encoder.encode(toPlace[placed].xml));
330
- placed += 1;
331
- }
332
- parts.push(CLOSE_RPR);
333
- }
334
-
335
- /**
336
- * The existing direct `<w:jc w:val="..."/>` a pPr element carries, as the raw
337
- * XML attribute STRING (`readPPr`'s own convention). `undefined` when absent.
338
- * @param {import("./xml-scan.js").XmlElement} pPrEl
339
- * @returns {string | undefined}
340
- */
341
- function pPrJc(pPrEl) {
342
- for (const child of pPrEl.children) {
343
- if (child.kind !== "element" || child.uri !== W_NS || child.localName !== "jc") continue;
344
- return wAttr(child, "val");
345
- }
346
- return undefined;
347
- }
348
-
349
- /**
350
- * A named `w:` child of a pPr element, or null.
351
- * @param {import("./xml-scan.js").XmlElement} pPrEl
352
- * @param {string} localName
353
- * @returns {import("./xml-scan.js").XmlElement | null}
354
- */
355
- function findPPrChild(pPrEl, localName) {
356
- for (const child of pPrEl.children) {
357
- if (child.kind === "element" && child.uri === W_NS && child.localName === localName) {
358
- return child;
359
- }
360
- }
361
- return null;
362
- }
363
-
364
- /**
365
- * Whether an existing `<w:ind>` already says exactly what `want` asks for.
366
- * Only the WANTED attributes are compared — an unmodelled attribute on the
367
- * element is irrelevant to whether a write is needed, and comparing it would
368
- * force a needless re-emit (and lose ADR-0017's verbatim guarantee) on every
369
- * export of a document this editor never touched.
370
- * @param {import("./xml-scan.js").XmlElement | null} existing
371
- * @param {Record<string, string>} want
372
- * @returns {boolean}
373
- */
374
- function indMatches(existing, want) {
375
- for (const [attr, value] of Object.entries(want)) {
376
- const have = existing === null ? undefined : wAttr(existing, attr);
377
- if (have !== value) {
378
- return false;
379
- }
380
- }
381
- return true;
382
- }
383
-
384
- /**
385
- * The `w:ind` attribute name each writable `pPrOverride` key maps to
386
- * (ADR-0030). One element, four attributes — which is what makes this
387
- * materially harder than `<w:jc w:val>`: changing one of them means re-emitting
388
- * the whole element, and a naive re-emit would drop every attribute it does
389
- * not know about.
390
- * @type {Readonly<Record<string, string>>}
391
- */
392
- const IND_ATTR_BY_KEY = Object.freeze({
393
- "ind.left": "left",
394
- "ind.right": "right",
395
- "ind.firstLine": "firstLine",
396
- "ind.hanging": "hanging",
397
- });
398
-
399
- /**
400
- * The `ind.*` values a paragraph WANTS, as OOXML attribute strings — or null
401
- * when it carries no `ind` override at all, meaning "leave the blob alone".
402
- * @param {import("../../model/node.js").Paragraph} para
403
- * @returns {Record<string, string> | null}
404
- */
405
- function wantedInd(para) {
406
- const override = para.pPrOverride;
407
- if (override === undefined) {
408
- return null;
409
- }
410
- /** @type {Record<string, string>} */
411
- const want = {};
412
- let any = false;
413
- for (const [key, attr] of Object.entries(IND_ATTR_BY_KEY)) {
414
- const value = override[key];
415
- if (value !== undefined) {
416
- want[attr] = String(value);
417
- any = true;
418
- }
419
- }
420
- return any ? want : null;
421
- }
422
-
423
- /**
424
- * Re-emits a `<w:ind>` element with `want`'s attributes applied over its
425
- * existing ones.
426
- *
427
- * 🔴 Every attribute the source element carried is written back, including
428
- * ones this codebase does not model (`w:leftChars`, `w:start`, ...). Measured
429
- * 2026-08-31: the corpus carries ONLY the four modelled attributes across
430
- * 46/46 documents, so this costs nothing here — but they exist in the schema,
431
- * the measurement cannot speak for a corpus-external document, and
432
- * `XmlAttribute` already holds what is needed. `emitRunProps`' own "every
433
- * other child, verbatim" rule, one level down: child -> attribute.
434
- *
435
- * ⚠ `XmlAttribute.value` is entity-DECODED by `xml-scan`, so it is re-escaped
436
- * on the way out. For `w:ind`'s integers that is a formality; the mechanism
437
- * has to be right regardless, because it is the path any future
438
- * multi-attribute element will take.
439
- * @param {import("./xml-scan.js").XmlElement | null} existing
440
- * @param {Record<string, string>} want
441
- * @returns {string}
442
- */
443
- function emitIndElement(existing, want) {
444
- /** @type {string[]} */
445
- const attrs = [];
446
- const written = new Set();
447
- if (existing !== null) {
448
- for (const a of existing.attributes) {
449
- const name = a.prefix === "" ? a.localName : `${a.prefix}:${a.localName}`;
450
- const isModelled = a.uri === W_NS && want[a.localName] !== undefined;
451
- const value = isModelled ? want[a.localName] : a.value;
452
- if (isModelled) {
453
- written.add(a.localName);
454
- }
455
- attrs.push(`${name}="${escapeXml(value).replace(/"/g, "&quot;")}"`);
456
- }
457
- }
458
- for (const [attr, value] of Object.entries(want)) {
459
- if (!written.has(attr)) {
460
- attrs.push(`w:${attr}="${escapeXml(value)}"`);
461
- }
462
- }
463
- return `<w:ind ${attrs.join(" ")}/>`;
464
- }
465
-
466
- /**
467
- * Emit a paragraph's `w:pPr` into `parts` — the pPr twin of `emitRunProps`,
468
- * same three branches: no blob -> a jc-only pPr (or nothing); unchanged ->
469
- * the original blob VERBATIM (ADR-0017); changed -> the blob re-emitted with
470
- * `w:jc` replaced at its canonical rank and EVERY OTHER CHILD carried through
471
- * byte-for-byte.
472
- *
473
- * SCOPE (ADR-0029): only `w:jc`. `ind`/`spacing`/`numPr`/`pStyle`/`tabs` are
474
- * never written — their preservation comes from the mechanism, not from
475
- * intent: the merge loop below skips ONLY the `jc` child and copies every
476
- * other one with `bytes.subarray(child.start, child.end)`.
477
- * @param {Uint8Array[]} parts
478
- * @param {import("../../model/node.js").Paragraph} para
479
- * @param {number} containerId
480
- * @param {import("./provenance.js").Provenance} provenance
481
- * @param {Map<number, import("./xml-scan.js").XmlElement>} elementByStart
482
- * @returns {void}
483
- */
484
- function emitParagraphProps(parts, para, containerId, provenance, elementByStart) {
485
- // undefined means "no direct override" — NOT "force absent". Same
486
- // convention `want.sz` uses in emitRunProps.
487
- const wantJc = typeof para.jc === "string" ? para.jc : undefined;
488
- const wantInd = wantedInd(para);
489
- const blob = resolveBlob(provenance, containerId, para.pPrRef, elementByStart);
490
-
491
- if (blob === null) {
492
- // No blob at all: emit only what the model actually declares, in rank
493
- // order (ind 22 before jc 26).
494
- if (wantJc === undefined && wantInd === null) {
495
- return;
496
- }
497
- const inner =
498
- (wantInd === null ? "" : emitIndElement(null, wantInd)) +
499
- (wantJc === undefined ? "" : `<w:jc w:val="${wantJc}"/>`);
500
- parts.push(encoder.encode(`<w:pPr>${inner}</w:pPr>`));
501
- return;
502
- }
503
- if (blob.el === null) {
504
- parts.push(blob.bytes); // index miss (unexpected): safest is verbatim
505
- return;
506
- }
507
-
508
- const haveIndEl = findPPrChild(blob.el, "ind");
509
- const jcChanged = wantJc !== undefined && wantJc !== pPrJc(blob.el);
510
- const indChanged = wantInd !== null && !indMatches(haveIndEl, wantInd);
511
- if (!jcChanged && !indChanged) {
512
- parts.push(blob.bytes); // UNCHANGED -> verbatim (ADR-0017)
513
- return;
514
- }
515
-
516
- // ONE rank walk places both, so a paragraph whose jc AND ind both changed
517
- // still comes out in canonical order — two separate walks could not, and a
518
- // second pass over a rebuilt buffer is the class of bug ADR-0023's own
519
- // "single edit list, single applier" rule exists to prevent.
520
- const bytes = provenance.documentXmlBytes;
521
- /** @type {{ rank: number, xml: string, drop: string }[]} */
522
- const pending = [];
523
- if (indChanged) {
524
- pending.push({
525
- rank: /** @type {number} */ (PPR_RANK.get("ind")),
526
- xml: emitIndElement(haveIndEl, /** @type {Record<string, string>} */ (wantInd)),
527
- drop: "ind",
528
- });
529
- }
530
- if (jcChanged) {
531
- pending.push({
532
- rank: /** @type {number} */ (PPR_RANK.get("jc")),
533
- xml: `<w:jc w:val="${wantJc}"/>`,
534
- drop: "jc",
535
- });
536
- }
537
- pending.sort((a, b) => a.rank - b.rank);
538
- const dropped = new Set(pending.map((entry) => entry.drop));
539
-
540
- parts.push(OPEN_PPR);
541
- let next = 0;
542
- let prevRank = -1;
543
- for (const child of blob.el.children) {
544
- if (child.kind !== "element") continue;
545
- if (child.uri === W_NS && dropped.has(child.localName)) {
546
- continue; // dropped — the pending replacement carries its attributes
547
- }
548
- const rank = child.uri === W_NS && PPR_RANK.has(child.localName)
549
- ? /** @type {number} */ (PPR_RANK.get(child.localName))
550
- : prevRank; // unknown child keeps its predecessor's rank (stays adjacent)
551
- prevRank = rank;
552
- while (next < pending.length && pending[next].rank < rank) {
553
- parts.push(encoder.encode(pending[next].xml));
554
- next += 1;
555
- }
556
- parts.push(bytes.subarray(child.start, child.end)); // other child, verbatim
557
- }
558
- while (next < pending.length) {
559
- parts.push(encoder.encode(pending[next].xml));
560
- next += 1;
561
- }
562
- parts.push(CLOSE_PPR);
563
- }
564
-
565
- /**
566
- * One paragraph -> `<w:p>[pPr][runs]</w:p>`. `w:pPr` is the paragraph's blob
567
- * verbatim (never edited); empty runs (the single empty run normalize keeps for
568
- * an empty paragraph, ADR-0021) emit nothing, so an empty paragraph is
569
- * `<w:p>[pPr]</w:p>` — a valid, caret-able empty paragraph.
570
- * @param {Uint8Array[]} parts
571
- * @param {import("../../model/node.js").Paragraph} para
572
- * @param {number} containerId
573
- * @param {import("./provenance.js").Provenance} provenance
574
- * @param {Map<number, import("./xml-scan.js").XmlElement>} elementByStart
575
- * @returns {void}
576
- */
577
- function emitParagraph(parts, para, containerId, provenance, elementByStart) {
578
- parts.push(OPEN_P);
579
- // Must precede runs (CT_P). Verbatim unless the model carries a direct jc
580
- // override that differs from the blob's — see emitParagraphProps.
581
- emitParagraphProps(parts, para, containerId, provenance, elementByStart);
582
- for (const run of para.content) {
583
- if (run.text === "") continue;
584
- parts.push(OPEN_R);
585
- emitRunProps(parts, run, containerId, provenance, elementByStart);
586
- parts.push(encoder.encode(`<w:t xml:space="preserve">${escapeXml(run.text)}</w:t>`));
587
- parts.push(CLOSE_R);
588
- }
589
- parts.push(CLOSE_P);
590
- }
591
-
592
- /**
593
- * Every `w:txbxContent` descendant of `el`, NOT descending into one once found
594
- * (a text box nested inside a text box is 0/46 in the corpus, ADR-0020 §2 — and
595
- * `walkDocumentOrder` excludes it for exactly that reason; this mirrors it).
596
- * @param {import("./xml-scan.js").XmlElement} el
597
- * @param {import("./xml-scan.js").XmlElement[]} out
598
- * @returns {import("./xml-scan.js").XmlElement[]}
599
- */
600
- function collectTxbxContents(el, out) {
601
- for (const child of el.children) {
602
- if (child.kind !== "element") {
603
- continue;
604
- }
605
- if (child.uri === W_NS && child.localName === "txbxContent") {
606
- out.push(child);
607
- } else {
608
- collectTxbxContents(child, out);
609
- }
610
- }
611
- return out;
612
- }
613
-
614
- /**
615
- * @param {Uint8Array[]} parts
616
- * @returns {Uint8Array}
617
- */
618
- function concatBytes(parts) {
619
- let total = 0;
620
- for (const p of parts) {
621
- total += p.length;
622
- }
623
- const out = new Uint8Array(total);
624
- let at = 0;
625
- for (const p of parts) {
626
- out.set(p, at);
627
- at += p.length;
628
- }
629
- return out;
630
- }
631
-
632
- /**
633
- * New document.xml bytes with ONLY each dirty container's txbxContent re-written
634
- * from `doc` (the EDITED document); every other byte byte-identical to the
635
- * original. `provenance` must be the one `importDocx` returned for `doc`'s source
636
- * (its `documentXmlBytes`/`anchorRanges`/`propBlobs` address the original bytes).
637
- * An empty `dirtyIds` returns the original bytes unchanged (a genuine no-op).
638
- *
639
- * @param {import("./provenance.js").Provenance} provenance
640
- * @param {import("../../model/node.js").Doc} doc the edited document
641
- * @param {Iterable<import("../../model/node.js").NodeId>} dirtyIds
642
- * @returns {Uint8Array}
643
- */
644
- export function spliceDirtyText(provenance, doc, dirtyIds) {
645
- return spliceDirty(provenance, doc, { textIds: dirtyIds }).bytes;
646
- }
647
-
648
- /**
649
- * The `wp:anchor` of container `id`, resolved through provenance. Throws — never
650
- * returns null — because every failure here means the provenance and the document
651
- * disagree, which must be loud.
652
- * @param {import("./provenance.js").Provenance} provenance
653
- * @param {Map<number, import("./xml-scan.js").XmlElement>} anchorByStart
654
- * @param {import("../../model/node.js").NodeId} id
655
- * @returns {import("./xml-scan.js").XmlElement}
656
- */
657
- function anchorFor(provenance, anchorByStart, id) {
658
- const range = provenance.anchorRanges.get(id);
659
- if (range === undefined) {
660
- throw new Error(`splice: container ${id} has no anchor range (not an imported box)`);
661
- }
662
- const anchor = anchorByStart.get(range.start);
663
- if (anchor === undefined) {
664
- throw new Error(`splice: no anchor at byte ${range.start} for container ${id}`);
665
- }
666
- return anchor;
667
- }
668
-
669
- /**
670
- * The `wp:positionH`/`wp:positionV` child holding this axis's `wp:posOffset`.
671
- * @param {import("./xml-scan.js").XmlElement} anchor
672
- * @param {"x" | "y"} axis
673
- * @returns {import("./xml-scan.js").XmlElement | null}
674
- */
675
- function posOffsetElement(anchor, axis) {
676
- const position = findChild(anchor, WP_NS, axis === "x" ? "positionH" : "positionV");
677
- if (position === null || position === undefined) {
678
- return null;
679
- }
680
- return findChild(position, WP_NS, "posOffset") ?? null;
681
- }
682
-
683
- /**
684
- * Whether this anchor's graphic is a `wpg:wgp` GROUP.
685
- *
686
- * The geometry writer must refuse groups: a group child's model coordinate is a
687
- * COMPOSED value (`import.js`: `x = anchorX + (offX - chOffX) * scaleX`), so
688
- * writing it into the anchor's own `wp:posOffset` would move the wrong thing.
689
- *
690
- * 🔴 This cannot reuse the text path's guard: that one counts `w:txbxContent`
691
- * under the anchor ("need 1"), which a group holding ONE text box passes
692
- * cleanly — measured, that proxy has never fired on this corpus. Grouping is
693
- * tested here directly.
694
- * @param {import("./xml-scan.js").XmlElement} anchor
695
- * @returns {boolean}
696
- */
697
- function isGroupedAnchor(anchor) {
698
- const graphic = findChild(anchor, A_NS, "graphic");
699
- if (graphic === null || graphic === undefined) {
700
- return false;
701
- }
702
- const graphicData = findChild(graphic, A_NS, "graphicData");
703
- if (graphicData === null || graphicData === undefined) {
704
- return false;
705
- }
706
- const group = findChild(graphicData, WPG_NS, "wgp");
707
- return group !== null && group !== undefined;
708
- }
709
-
710
- /**
711
- * @typedef {object} SkippedGeometry
712
- * @property {import("../../model/node.js").NodeId} containerId
713
- * @property {string[]} keys the layout keys whose new value was NOT written
714
- * @property {"grouped" | "locked" | "unsupported-key"} reason
715
- */
716
-
717
- /**
718
- * Byte edits for the geometry the user actually changed, plus the list of
719
- * changes this slice REFUSES to write (never silently dropped — the caller
720
- * surfaces them, see `export.js`'s report).
721
- *
722
- * Only `x`/`y` are written, by replacing the digits inside `wp:posOffset`. The
723
- * anchor itself is never regenerated: ADR-0023's 4a experiment (Word refused a
724
- * document whose anchor had lost three attributes) constrains REGENERATION, and
725
- * a digit replacement touches no attribute at all.
726
- *
727
- * `cx`/`cy` are refused on purpose. Measured 2026-08-29: size has TWO live
728
- * copies inside `mc:Choice` — `wp:extent@cx/@cy` AND `wps:spPr/a:xfrm/a:ext`
729
- * (7,990/7,990 containers carry both) — so writing one leaves the other stale.
730
- * Writing both is its own slice.
731
- *
732
- * @param {import("./provenance.js").Provenance} provenance
733
- * @param {import("../../model/node.js").Doc} doc the EDITED document
734
- * @param {import("../../model/node.js").Doc} originalDoc the IMPORTED document (read-back baseline)
735
- * @param {Map<import("../../model/node.js").NodeId, Set<string>>} geometry id -> changed layout keys
736
- * @param {Map<number, import("./xml-scan.js").XmlElement>} anchorByStart
737
- * @param {SkippedGeometry[]} skipped appended to
738
- * @returns {{start: number, end: number, bytes: Uint8Array}[]}
739
- */
740
- function buildGeometryEdits(provenance, doc, originalDoc, geometry, anchorByStart, skipped) {
741
- /** @type {{start: number, end: number, bytes: Uint8Array}[]} */
742
- const edits = [];
743
- const encoder = new TextEncoder();
744
- const decoder = new TextDecoder();
745
-
746
- for (const [id, changedKeys] of geometry) {
747
- if (containerById(doc, id) === null) {
748
- throw new Error(`splice: dirty geometry id ${id} is not a container in doc`);
749
- }
750
- const layout = doc.layout[id];
751
- const before = originalDoc.layout[id];
752
- if (layout === undefined || before === undefined) {
753
- // A box the imported document did not position (or a brand-new one) has no
754
- // original bytes to splice — new-anchor writing is a later slice.
755
- skipped.push({ containerId: id, keys: [...changedKeys], reason: "unsupported-key" });
756
- continue;
757
- }
758
-
759
- const anchor = anchorFor(provenance, anchorByStart, id);
760
- if (isGroupedAnchor(anchor)) {
761
- skipped.push({ containerId: id, keys: [...changedKeys], reason: "grouped" });
762
- continue;
763
- }
764
- if (layout.locked === true) {
765
- // `locked` means the box carries its own rotation/flip, which this codebase
766
- // never renders (FIDELITY: DEGRADED). Writing posOffset would move the
767
- // UNROTATED bounding box — not what the editor drew. The UI withholds the
768
- // drag affordance but the keyboard nudge path does not check `locked`, so
769
- // the refusal has to live here, not in the UI.
770
- skipped.push({ containerId: id, keys: [...changedKeys], reason: "locked" });
771
- continue;
772
- }
773
-
774
- /** @type {string[]} */
775
- const refusedKeys = [];
776
- for (const key of changedKeys) {
777
- if (key !== "x" && key !== "y") {
778
- refusedKeys.push(key);
779
- continue;
780
- }
781
- const element = posOffsetElement(anchor, key);
782
- if (element === null) {
783
- refusedKeys.push(key);
784
- continue;
785
- }
786
-
787
- // READ-BACK: the digits currently in these bytes must decode to the value
788
- // the IMPORTED model holds for this key. Exact by construction (import
789
- // computed layout[key] from precisely this text), so a mismatch means the
790
- // provenance and the document disagree — a bug, not a user situation.
791
- const currentText = decoder.decode(provenance.documentXmlBytes.subarray(element.contentStart, element.contentEnd));
792
- const currentEmu = Number.parseInt(currentText, 10);
793
- if (!Number.isFinite(currentEmu) || emuToPt(currentEmu) !== before[key]) {
794
- throw new Error(
795
- `splice: read-back mismatch for container ${id} ${key} — document says ${currentText} ` +
796
- `(${emuToPt(currentEmu)} pt) but the imported model says ${before[key]} pt`,
797
- );
798
- }
799
-
800
- edits.push({
801
- start: element.contentStart,
802
- end: element.contentEnd,
803
- bytes: encoder.encode(String(ptToEmu(/** @type {number} */ (layout[key])))),
804
- });
805
- }
806
- if (refusedKeys.length > 0) {
807
- skipped.push({ containerId: id, keys: refusedKeys, reason: "unsupported-key" });
808
- }
809
- }
810
- return edits;
811
- }
812
-
813
- /**
814
- * New document.xml bytes with the touched containers' text AND geometry
815
- * re-written from `doc`; every other byte byte-identical to the original.
816
- *
817
- * @param {import("./provenance.js").Provenance} provenance
818
- * @param {import("../../model/node.js").Doc} doc the edited document
819
- * @param {object} dirty
820
- * @param {Iterable<import("../../model/node.js").NodeId>} [dirty.textIds]
821
- * @param {Map<import("../../model/node.js").NodeId, Set<string>>} [dirty.geometry]
822
- * @param {import("../../model/node.js").Doc} [dirty.originalDoc] required when `geometry` is non-empty
823
- * @returns {{bytes: Uint8Array, skippedGeometry: SkippedGeometry[]}} `bytes` is
824
- * the ORIGINAL reference when nothing was written (export.js depends on that
825
- * identity to skip repacking entirely)
826
- */
827
- export function spliceDirty(provenance, doc, { textIds = [], geometry = new Map(), originalDoc } = {}) {
828
- const original = provenance.documentXmlBytes;
829
- const ids = [...new Set(textIds)];
830
- /** @type {SkippedGeometry[]} */
831
- const skippedGeometry = [];
832
- if (ids.length === 0 && geometry.size === 0) {
833
- return { bytes: original, skippedGeometry };
834
- }
835
-
836
- const { root } = scanXml(original);
837
- const elementByStart = indexElementsByStart(root);
838
- /** @type {Map<number, import("./xml-scan.js").XmlElement>} */
839
- const anchorByStart = new Map();
840
- for (const anchor of collectAnchors(root)) {
841
- anchorByStart.set(anchor.start, anchor);
842
- }
843
-
844
- /** @type {{start: number, end: number, bytes: Uint8Array}[]} */
845
- const edits = [];
846
- for (const id of ids) {
847
- const container = containerById(doc, id);
848
- if (container === null) {
849
- throw new Error(`spliceDirtyText: dirty id ${id} is not a container in doc`);
850
- }
851
- const anchor = anchorFor(provenance, anchorByStart, id);
852
- const txbxes = collectTxbxContents(anchor, []);
853
- if (txbxes.length !== 1) {
854
- // Narrowest slice: exactly one text box per touched anchor. A grouped
855
- // multi-box anchor (several txbxContent sharing one anchor range) cannot
856
- // be disambiguated from the anchor range alone — that is a later slice.
857
- throw new Error(
858
- `spliceDirtyText: container ${id}'s anchor holds ${txbxes.length} txbxContent (need 1) — grouped multi-box is a later slice`,
859
- );
860
- }
861
- const txbx = txbxes[0];
862
- /** @type {Uint8Array[]} */
863
- const parts = [];
864
- for (const para of container.content) {
865
- emitParagraph(parts, para, id, provenance, elementByStart);
866
- }
867
- edits.push({ start: txbx.contentStart, end: txbx.contentEnd, bytes: concatBytes(parts) });
868
- }
869
-
870
- if (geometry.size > 0) {
871
- if (originalDoc === undefined) {
872
- throw new Error("spliceDirty: geometry edits need `originalDoc` (the imported document) for the read-back check");
873
- }
874
- edits.push(...buildGeometryEdits(provenance, doc, originalDoc, geometry, anchorByStart, skippedGeometry));
875
- }
876
-
877
- // ONE application pass for text AND geometry together — see applyEdits for why
878
- // a second pass over the rebuilt buffer would be a silent corruption.
879
- return { bytes: applyEdits(original, edits), skippedGeometry };
880
- }
1
+ // P6 piece 4b + piece 7a: re-write ONLY the `w:txbxContent` of each touched
2
+ // container in the untouched document.xml, leaving every other byte identical.
3
+ // NOT a full export — this produces document.xml BYTES, not a .docx package
4
+ // (repack is piece 2). NO new anchor (the user edited an existing box's text,
5
+ // they did not add one — a new anchor is a later slice), and the `mc:Fallback`
6
+ // VML is never touched (§3.5 dirty-only already leaves it, and 4a measured Word
7
+ // accepts a Choice/plain drawing whose Fallback is stale).
8
+ //
9
+ // FORMATTING (piece 7a, ADR-0017 opaque blobs): both `w:pPr` and `w:rPr` are
10
+ // re-emitted, never dropped.
11
+ // - `w:pPr` is copied VERBATIM from the paragraph's `pPrRef` blob unless the
12
+ // paragraph carries an editor override — `Paragraph.jc` (ADR-0029) or
13
+ // `pPrOverride.ind` (ADR-0030). Then `emitParagraphProps` re-emits it in
14
+ // canonical CT_PPr order, every OTHER child byte-for-byte, so numPr/spacing
15
+ // survive an alignment or indent edit exactly as they survive a text edit.
16
+ // - `w:rPr` for an UNCHANGED run is its `rPrRef` blob VERBATIM (font/color/sz
17
+ // preserved byte-for-byte). When the user toggled a mark or changed the size
18
+ // (ADR-0028/0031), `emitRunProps` re-emits the blob with `w:b`/`w:i`/`w:u`/
19
+ // `w:sz` set to the run's current values in canonical CT_RPr order, every
20
+ // OTHER child carried through byte-for-byte. A run with no original rPr
21
+ // (freshly typed) gets a marks-only rPr, or none.
22
+ // SCOPE: b/i/u/sz on runs, jc/ind on paragraphs. color/vertAlign/kerning/font
23
+ // are NOT written. `w:szCs` is not updated with `w:sz` (undocumented until
24
+ // 2026-10-17; decided by master plan MP3's Word experiment). Geometry: x/y only
25
+ // (`buildGeometryEdits` below). New anchors, deletions and grouped multi-box are
26
+ // later slices.
27
+ // (Header brought up to date 2026-10-17 — until then it still said the editor
28
+ // never edits paragraph properties and that only b/i/u were written.) 🔴 DOĞRULANMALI: an "off" mark is written as ABSENCE (no `<w:b/>`),
29
+ // not `<w:b w:val="0"/>` — correct when the run's effective style is not bold
30
+ // (the overwhelming text-box case), under-specified for a bold-by-default style
31
+ // (4a did not measure Word's save-normalisation, so we stay on the plain side).
32
+ //
33
+ // The guarantee (§3.5, ARCHITECTURE §3.5): touch N containers -> exactly N
34
+ // txbxContent ranges differ, every other byte is byte-identical.
35
+ //
36
+ // Locating the range: piece (1)'s provenance gives `documentXmlBytes` and, per
37
+ // container, its anchor's byte range (`anchorRanges`) plus the pPr/rPr blob
38
+ // ranges (`propBlobs`). document.xml declares its namespace prefixes only on the
39
+ // root, so a blob cannot be scanned in isolation; the whole (original)
40
+ // document.xml is scanned once here, and every blob element is found by its
41
+ // start offset in that one parse.
42
+
43
+ import { scanXml, findChild } from "./xml-scan.js";
44
+ import { collectAnchors } from "./drawingml.js";
45
+ import { W_NS, WP_NS, A_NS, WPG_NS } from "./ns.js";
46
+ import { containerById } from "../../model/node.js";
47
+ import { emuToPt, ptToEmu } from "./units.js";
48
+ import { applyEdits } from "./edits.js";
49
+
50
+ const encoder = new TextEncoder();
51
+
52
+ // Mirrors drawingml.js's importer: these `w:val` values mean the mark is OFF, so
53
+ // `<w:b w:val="0"/>` reads as not-bold — the exact inverse of what we write.
54
+ const MARK_OFF_VALUES = new Set(["0", "false", "none"]);
55
+
56
+ // Canonical CT_RPr child order (ECMA-376 §17.3.2.28). We only ever INSERT
57
+ // w:b/w:i/w:u; every other child is carried through verbatim, so this table
58
+ // exists solely to place those three at their schema-correct position relative
59
+ // to the rest (4a's deney (d): Word tolerates non-canonical order on open, but
60
+ // its save-normalisation was not measured, so we write canonical — güvenli taraf).
61
+ const CT_RPR_ORDER = [
62
+ "rStyle", "rFonts", "b", "bCs", "i", "iCs", "caps", "smallCaps", "strike",
63
+ "dstrike", "outline", "shadow", "emboss", "imprint", "noProof", "snapToGrid",
64
+ "vanish", "webHidden", "color", "spacing", "w", "kern", "position", "sz",
65
+ "szCs", "highlight", "u", "effect", "bdr", "shd", "fitText", "vertAlign",
66
+ "rtl", "cs", "em", "lang", "eastAsianLayout", "specVanish", "oMath",
67
+ ];
68
+ /** @type {Map<string, number>} localName -> canonical rank */
69
+ const RPR_RANK = new Map(CT_RPR_ORDER.map((name, i) => [name, i]));
70
+
71
+ // Canonical CT_PPr child order (ECMA-376 §17.3.1.26) — the pPr twin of
72
+ // CT_RPR_ORDER above, and written to the same rule: the table is COMPLETE
73
+ // (every child the schema's sequence defines, in schema order) even though
74
+ // this exporter only ever INSERTS `w:jc`. Every other child is carried
75
+ // through verbatim, so the extra names exist purely so that a future
76
+ // editable pPr field (ind, spacing, ...) already has its slot — exactly the
77
+ // shape CT_RPR_ORDER has, where 30+ names are ranked and only b/i/u/sz are
78
+ // ever emitted.
79
+ //
80
+ // Measured 2026-08-31 before writing this (ADR-0029): 14,321 distinct pPr
81
+ // blobs across the 46-document corpus, 0 of them in non-canonical order —
82
+ // so a rank-based insert is safe on real Word output. The table itself is
83
+ // derived from the SPEC, not from that corpus; the measurement only confirms
84
+ // real documents agree with it.
85
+ const CT_PPR_ORDER = [
86
+ "pStyle", "keepNext", "keepLines", "pageBreakBefore", "framePr", "widowControl",
87
+ "numPr", "suppressLineNumbers", "pBdr", "shd", "tabs", "suppressAutoHyphens",
88
+ "kinsoku", "wordWrap", "overflowPunct", "topLinePunct", "autoSpaceDE",
89
+ "autoSpaceDN", "bidi", "adjustRightInd", "snapToGrid", "spacing", "ind",
90
+ "contextualSpacing", "mirrorIndents", "suppressOverlap", "jc", "textDirection",
91
+ "textAlignment", "textboxTightWrap", "outlineLvl", "divId", "cnfStyle",
92
+ "rPr", "sectPr", "pPrChange",
93
+ ];
94
+ /** @type {Map<string, number>} localName -> canonical rank */
95
+ const PPR_RANK = new Map(CT_PPR_ORDER.map((name, i) => [name, i]));
96
+
97
+ const OPEN_PPR = encoder.encode("<w:pPr>");
98
+ const CLOSE_PPR = encoder.encode("</w:pPr>");
99
+
100
+ const OPEN_P = encoder.encode("<w:p>");
101
+ const CLOSE_P = encoder.encode("</w:p>");
102
+ const OPEN_R = encoder.encode("<w:r>");
103
+ const CLOSE_R = encoder.encode("</w:r>");
104
+ const OPEN_RPR = encoder.encode("<w:rPr>");
105
+ const CLOSE_RPR = encoder.encode("</w:rPr>");
106
+
107
+ /**
108
+ * @param {string} text
109
+ * @returns {string}
110
+ */
111
+ function escapeXml(text) {
112
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
113
+ }
114
+
115
+ /** The `<w:b/>`/`<w:i/>`/`<w:u .../>` element source for a set mark. */
116
+ const MARK_XML = { bold: "<w:b/>", italic: "<w:i/>", underline: '<w:u w:val="single"/>' };
117
+ /** Which mark each CT_RPr child localName represents (for dropping existing ones). */
118
+ const BIU_LOCAL = /** @type {Record<string, "bold" | "italic" | "underline" | undefined>} */ ({ b: "bold", i: "italic", u: "underline" });
119
+
120
+ /**
121
+ * Index every element in the tree by its start offset. pPr/rPr live inside
122
+ * `w:txbxContent`, so this must descend everywhere (unlike `collectAnchors`).
123
+ * @param {import("./xml-scan.js").XmlElement} root
124
+ * @returns {Map<number, import("./xml-scan.js").XmlElement>}
125
+ */
126
+ function indexElementsByStart(root) {
127
+ /** @type {Map<number, import("./xml-scan.js").XmlElement>} */
128
+ const map = new Map();
129
+ const stack = [root];
130
+ while (stack.length > 0) {
131
+ const el = /** @type {import("./xml-scan.js").XmlElement} */ (stack.pop());
132
+ map.set(el.start, el);
133
+ for (const child of el.children) {
134
+ if (child.kind === "element") stack.push(child);
135
+ }
136
+ }
137
+ return map;
138
+ }
139
+
140
+ /**
141
+ * The `w:val` of a direct `w:*` attribute, or undefined.
142
+ * @param {import("./xml-scan.js").XmlElement} el
143
+ * @param {string} localName
144
+ * @returns {string | undefined}
145
+ */
146
+ function wAttr(el, localName) {
147
+ for (const a of el.attributes) {
148
+ if (a.uri === W_NS && a.localName === localName) return a.value;
149
+ }
150
+ return undefined;
151
+ }
152
+
153
+ /**
154
+ * The b/i/u state an rPr element encodes, matching import's `runMarks`
155
+ * (an off-value counts as absent).
156
+ * @param {import("./xml-scan.js").XmlElement} rPrEl
157
+ * @returns {{bold: boolean, italic: boolean, underline: boolean}}
158
+ */
159
+ function rPrMarks(rPrEl) {
160
+ const out = { bold: false, italic: false, underline: false };
161
+ for (const child of rPrEl.children) {
162
+ if (child.kind !== "element" || child.uri !== W_NS) continue;
163
+ const name = BIU_LOCAL[child.localName];
164
+ if (name === undefined) continue;
165
+ const val = wAttr(child, "val");
166
+ if (val === undefined || !MARK_OFF_VALUES.has(val)) out[name] = true;
167
+ }
168
+ return out;
169
+ }
170
+
171
+ /**
172
+ * The existing direct `<w:sz w:val="N"/>` an rPr element carries, as the
173
+ * raw XML attribute STRING — `readRPr`'s own convention (`styles-resolve.
174
+ * js`), never converted here. `undefined` when the element carries none
175
+ * (ADR-0028: unlike b/i/u, `sz`'s absence is not itself a target — the
176
+ * caller decides whether that means "leave untouched" or "no override").
177
+ * @param {import("./xml-scan.js").XmlElement} rPrEl
178
+ * @returns {string | undefined}
179
+ */
180
+ function rPrSz(rPrEl) {
181
+ for (const child of rPrEl.children) {
182
+ if (child.kind !== "element" || child.uri !== W_NS || child.localName !== "sz") continue;
183
+ return wAttr(child, "val");
184
+ }
185
+ return undefined;
186
+ }
187
+
188
+ /**
189
+ * Resolve a container-scoped prop ref to its original blob: the verbatim bytes
190
+ * AND the parsed element (needed to walk/merge rPr children). Null when the run
191
+ * or paragraph had no such property in the source.
192
+ * @param {import("./provenance.js").Provenance} provenance
193
+ * @param {number} containerId
194
+ * @param {string | undefined} ref
195
+ * @param {Map<number, import("./xml-scan.js").XmlElement>} elementByStart
196
+ * @returns {{bytes: Uint8Array, el: import("./xml-scan.js").XmlElement | null} | null}
197
+ */
198
+ function resolveBlob(provenance, containerId, ref, elementByStart) {
199
+ if (ref === undefined) return null;
200
+ const byRef = provenance.propBlobs.get(containerId);
201
+ if (byRef === undefined) return null;
202
+ const range = byRef.get(ref);
203
+ if (range === undefined) return null;
204
+ return {
205
+ bytes: provenance.documentXmlBytes.subarray(range.start, range.end),
206
+ el: elementByStart.get(range.start) ?? null,
207
+ };
208
+ }
209
+
210
+ /**
211
+ * Canonical `<w:b/><w:i/><w:u .../><w:sz .../>` inner for a mark set (only
212
+ * set marks) — ordered by `RPR_RANK`, not source order: `sz` (rank 23) sorts
213
+ * BETWEEN `i` (rank 4) and `u` (rank 26), so a naive "b then i then u then
214
+ * sz" concatenation would have placed a fresh run's size AFTER underline,
215
+ * violating CT_RPr's own schema order (ADR-0028, caught before this landed —
216
+ * the three-way b/i/u concatenation happened to already be rank-ascending,
217
+ * masking that it was never actually sorting).
218
+ * @param {{bold: boolean, italic: boolean, underline: boolean, sz: number | undefined}} want
219
+ * @returns {string}
220
+ */
221
+ function rPrInnerFromWant(want) {
222
+ /** @type {{rank: number, xml: string}[]} */
223
+ const parts = [];
224
+ if (want.bold) parts.push({ rank: /** @type {number} */ (RPR_RANK.get("b")), xml: MARK_XML.bold });
225
+ if (want.italic) parts.push({ rank: /** @type {number} */ (RPR_RANK.get("i")), xml: MARK_XML.italic });
226
+ if (want.underline) parts.push({ rank: /** @type {number} */ (RPR_RANK.get("u")), xml: MARK_XML.underline });
227
+ if (want.sz !== undefined) {
228
+ parts.push({ rank: /** @type {number} */ (RPR_RANK.get("sz")), xml: `<w:sz w:val="${want.sz}"/>` });
229
+ }
230
+ parts.sort((a, b) => a.rank - b.rank);
231
+ return parts.map((p) => p.xml).join("");
232
+ }
233
+
234
+ /**
235
+ * Emit a run's `w:rPr` into `parts`. Unchanged -> the original blob verbatim;
236
+ * changed -> the blob merged with `want` (b/i/u in canonical order, other
237
+ * children byte-for-byte); no blob -> a marks-only rPr, or nothing.
238
+ * @param {Uint8Array[]} parts
239
+ * @param {import("../../model/node.js").Run} run
240
+ * @param {number} containerId
241
+ * @param {import("./provenance.js").Provenance} provenance
242
+ * @param {Map<number, import("./xml-scan.js").XmlElement>} elementByStart
243
+ * @returns {void}
244
+ */
245
+ function emitRunProps(parts, run, containerId, provenance, elementByStart) {
246
+ const marks = run.marks;
247
+ const want = {
248
+ bold: marks.bold === true,
249
+ italic: marks.italic === true,
250
+ underline: marks.underline === true,
251
+ // ADR-0028: undefined ≠ "off" here — b/i/u always have a definite
252
+ // target (on or off), but sz has no such target when the user never
253
+ // touched it; `undefined` means "leave whatever was there untouched",
254
+ // not "force size absent". See the szChanged gate below.
255
+ sz: typeof marks.sz === "number" ? marks.sz : undefined,
256
+ };
257
+ const blob = resolveBlob(provenance, containerId, marks.rPrRef, elementByStart);
258
+
259
+ if (blob === null) {
260
+ const inner = rPrInnerFromWant(want);
261
+ if (inner !== "") parts.push(encoder.encode(`<w:rPr>${inner}</w:rPr>`));
262
+ return;
263
+ }
264
+ if (blob.el === null) {
265
+ parts.push(blob.bytes); // index miss (unexpected): safest is verbatim
266
+ return;
267
+ }
268
+ const have = rPrMarks(blob.el);
269
+ const haveSzRaw = rPrSz(blob.el);
270
+ const haveSz = haveSzRaw !== undefined ? Number.parseInt(haveSzRaw, 10) : undefined;
271
+ // A change only if the model actually holds a direct override AND it
272
+ // differs from what the blob already says — `want.sz === undefined`
273
+ // never forces a change, unlike b/i/u's always-definite want.
274
+ const szChanged = want.sz !== undefined && want.sz !== haveSz;
275
+ if (have.bold === want.bold && have.italic === want.italic && have.underline === want.underline && !szChanged) {
276
+ parts.push(blob.bytes); // UNCHANGED -> verbatim (ADR-0017)
277
+ return;
278
+ }
279
+
280
+ // CHANGED -> re-emit with b/i/u = want (+ sz, when szChanged), canonical
281
+ // order, other children verbatim.
282
+ const bytes = provenance.documentXmlBytes;
283
+ /** @type {{rank: number, xml: string}[]} */
284
+ const toPlace = [];
285
+ if (want.bold) toPlace.push({ rank: /** @type {number} */ (RPR_RANK.get("b")), xml: MARK_XML.bold });
286
+ if (want.italic) toPlace.push({ rank: /** @type {number} */ (RPR_RANK.get("i")), xml: MARK_XML.italic });
287
+ if (want.underline) toPlace.push({ rank: /** @type {number} */ (RPR_RANK.get("u")), xml: MARK_XML.underline });
288
+ if (szChanged) {
289
+ toPlace.push({ rank: /** @type {number} */ (RPR_RANK.get("sz")), xml: `<w:sz w:val="${want.sz}"/>` });
290
+ }
291
+ toPlace.sort((a, b) => a.rank - b.rank);
292
+
293
+ // Would the result be an empty <w:rPr></w:rPr>? (blob held only b/i/u [+
294
+ // an sz now being replaced], all now off/replaced, no other children.)
295
+ // Then omit the rPr entirely — cleaner and identical in meaning. An
296
+ // UNCHANGED sz (szChanged false) still counts as "other" — it survives
297
+ // verbatim in the loop below, same as any child this function does not
298
+ // understand.
299
+ const hasOther = blob.el.children.some(
300
+ (c) =>
301
+ c.kind === "element" &&
302
+ !(c.uri === W_NS && BIU_LOCAL[c.localName] !== undefined) &&
303
+ !(szChanged && c.uri === W_NS && c.localName === "sz"),
304
+ );
305
+ if (!hasOther && toPlace.length === 0) return;
306
+
307
+ parts.push(OPEN_RPR);
308
+ let placed = 0;
309
+ let prevRank = -1;
310
+ for (const child of blob.el.children) {
311
+ if (child.kind !== "element") continue;
312
+ if (child.uri === W_NS && BIU_LOCAL[child.localName] !== undefined) {
313
+ continue; // drop existing b/i/u — `want` replaces them
314
+ }
315
+ if (szChanged && child.uri === W_NS && child.localName === "sz") {
316
+ continue; // drop the existing sz — `want.sz` replaces it
317
+ }
318
+ const rank = child.uri === W_NS && RPR_RANK.has(child.localName)
319
+ ? /** @type {number} */ (RPR_RANK.get(child.localName))
320
+ : prevRank; // unknown child keeps its predecessor's rank (stays adjacent)
321
+ prevRank = rank;
322
+ while (placed < toPlace.length && toPlace[placed].rank < rank) {
323
+ parts.push(encoder.encode(toPlace[placed].xml));
324
+ placed += 1;
325
+ }
326
+ parts.push(bytes.subarray(child.start, child.end)); // other child, verbatim
327
+ }
328
+ while (placed < toPlace.length) {
329
+ parts.push(encoder.encode(toPlace[placed].xml));
330
+ placed += 1;
331
+ }
332
+ parts.push(CLOSE_RPR);
333
+ }
334
+
335
+ /**
336
+ * The existing direct `<w:jc w:val="..."/>` a pPr element carries, as the raw
337
+ * XML attribute STRING (`readPPr`'s own convention). `undefined` when absent.
338
+ * @param {import("./xml-scan.js").XmlElement} pPrEl
339
+ * @returns {string | undefined}
340
+ */
341
+ function pPrJc(pPrEl) {
342
+ for (const child of pPrEl.children) {
343
+ if (child.kind !== "element" || child.uri !== W_NS || child.localName !== "jc") continue;
344
+ return wAttr(child, "val");
345
+ }
346
+ return undefined;
347
+ }
348
+
349
+ /**
350
+ * A named `w:` child of a pPr element, or null.
351
+ * @param {import("./xml-scan.js").XmlElement} pPrEl
352
+ * @param {string} localName
353
+ * @returns {import("./xml-scan.js").XmlElement | null}
354
+ */
355
+ function findPPrChild(pPrEl, localName) {
356
+ for (const child of pPrEl.children) {
357
+ if (child.kind === "element" && child.uri === W_NS && child.localName === localName) {
358
+ return child;
359
+ }
360
+ }
361
+ return null;
362
+ }
363
+
364
+ /**
365
+ * Whether an existing `<w:ind>` already says exactly what `want` asks for.
366
+ * Only the WANTED attributes are compared — an unmodelled attribute on the
367
+ * element is irrelevant to whether a write is needed, and comparing it would
368
+ * force a needless re-emit (and lose ADR-0017's verbatim guarantee) on every
369
+ * export of a document this editor never touched.
370
+ * @param {import("./xml-scan.js").XmlElement | null} existing
371
+ * @param {Record<string, string>} want
372
+ * @returns {boolean}
373
+ */
374
+ function indMatches(existing, want) {
375
+ for (const [attr, value] of Object.entries(want)) {
376
+ const have = existing === null ? undefined : wAttr(existing, attr);
377
+ if (have !== value) {
378
+ return false;
379
+ }
380
+ }
381
+ return true;
382
+ }
383
+
384
+ /**
385
+ * The `w:ind` attribute name each writable `pPrOverride` key maps to
386
+ * (ADR-0030). One element, four attributes — which is what makes this
387
+ * materially harder than `<w:jc w:val>`: changing one of them means re-emitting
388
+ * the whole element, and a naive re-emit would drop every attribute it does
389
+ * not know about.
390
+ * @type {Readonly<Record<string, string>>}
391
+ */
392
+ const IND_ATTR_BY_KEY = Object.freeze({
393
+ "ind.left": "left",
394
+ "ind.right": "right",
395
+ "ind.firstLine": "firstLine",
396
+ "ind.hanging": "hanging",
397
+ });
398
+
399
+ /**
400
+ * The `ind.*` values a paragraph WANTS, as OOXML attribute strings — or null
401
+ * when it carries no `ind` override at all, meaning "leave the blob alone".
402
+ * @param {import("../../model/node.js").Paragraph} para
403
+ * @returns {Record<string, string> | null}
404
+ */
405
+ function wantedInd(para) {
406
+ const override = para.pPrOverride;
407
+ if (override === undefined) {
408
+ return null;
409
+ }
410
+ /** @type {Record<string, string>} */
411
+ const want = {};
412
+ let any = false;
413
+ for (const [key, attr] of Object.entries(IND_ATTR_BY_KEY)) {
414
+ const value = override[key];
415
+ if (value !== undefined) {
416
+ want[attr] = String(value);
417
+ any = true;
418
+ }
419
+ }
420
+ return any ? want : null;
421
+ }
422
+
423
+ /**
424
+ * Re-emits a `<w:ind>` element with `want`'s attributes applied over its
425
+ * existing ones.
426
+ *
427
+ * 🔴 Every attribute the source element carried is written back, including
428
+ * ones this codebase does not model (`w:leftChars`, `w:start`, ...). Measured
429
+ * 2026-08-31: the corpus carries ONLY the four modelled attributes across
430
+ * 46/46 documents, so this costs nothing here — but they exist in the schema,
431
+ * the measurement cannot speak for a corpus-external document, and
432
+ * `XmlAttribute` already holds what is needed. `emitRunProps`' own "every
433
+ * other child, verbatim" rule, one level down: child -> attribute.
434
+ *
435
+ * ⚠ `XmlAttribute.value` is entity-DECODED by `xml-scan`, so it is re-escaped
436
+ * on the way out. For `w:ind`'s integers that is a formality; the mechanism
437
+ * has to be right regardless, because it is the path any future
438
+ * multi-attribute element will take.
439
+ * @param {import("./xml-scan.js").XmlElement | null} existing
440
+ * @param {Record<string, string>} want
441
+ * @returns {string}
442
+ */
443
+ function emitIndElement(existing, want) {
444
+ /** @type {string[]} */
445
+ const attrs = [];
446
+ const written = new Set();
447
+ if (existing !== null) {
448
+ for (const a of existing.attributes) {
449
+ const name = a.prefix === "" ? a.localName : `${a.prefix}:${a.localName}`;
450
+ const isModelled = a.uri === W_NS && want[a.localName] !== undefined;
451
+ const value = isModelled ? want[a.localName] : a.value;
452
+ if (isModelled) {
453
+ written.add(a.localName);
454
+ }
455
+ attrs.push(`${name}="${escapeXml(value).replace(/"/g, "&quot;")}"`);
456
+ }
457
+ }
458
+ for (const [attr, value] of Object.entries(want)) {
459
+ if (!written.has(attr)) {
460
+ attrs.push(`w:${attr}="${escapeXml(value)}"`);
461
+ }
462
+ }
463
+ return `<w:ind ${attrs.join(" ")}/>`;
464
+ }
465
+
466
+ /**
467
+ * Emit a paragraph's `w:pPr` into `parts` — the pPr twin of `emitRunProps`,
468
+ * same three branches: no blob -> a jc-only pPr (or nothing); unchanged ->
469
+ * the original blob VERBATIM (ADR-0017); changed -> the blob re-emitted with
470
+ * `w:jc` replaced at its canonical rank and EVERY OTHER CHILD carried through
471
+ * byte-for-byte.
472
+ *
473
+ * SCOPE (ADR-0029): only `w:jc`. `ind`/`spacing`/`numPr`/`pStyle`/`tabs` are
474
+ * never written — their preservation comes from the mechanism, not from
475
+ * intent: the merge loop below skips ONLY the `jc` child and copies every
476
+ * other one with `bytes.subarray(child.start, child.end)`.
477
+ * @param {Uint8Array[]} parts
478
+ * @param {import("../../model/node.js").Paragraph} para
479
+ * @param {number} containerId
480
+ * @param {import("./provenance.js").Provenance} provenance
481
+ * @param {Map<number, import("./xml-scan.js").XmlElement>} elementByStart
482
+ * @returns {void}
483
+ */
484
+ function emitParagraphProps(parts, para, containerId, provenance, elementByStart) {
485
+ // undefined means "no direct override" — NOT "force absent". Same
486
+ // convention `want.sz` uses in emitRunProps.
487
+ const wantJc = typeof para.jc === "string" ? para.jc : undefined;
488
+ const wantInd = wantedInd(para);
489
+ const blob = resolveBlob(provenance, containerId, para.pPrRef, elementByStart);
490
+
491
+ if (blob === null) {
492
+ // No blob at all: emit only what the model actually declares, in rank
493
+ // order (ind 22 before jc 26).
494
+ if (wantJc === undefined && wantInd === null) {
495
+ return;
496
+ }
497
+ const inner =
498
+ (wantInd === null ? "" : emitIndElement(null, wantInd)) +
499
+ (wantJc === undefined ? "" : `<w:jc w:val="${wantJc}"/>`);
500
+ parts.push(encoder.encode(`<w:pPr>${inner}</w:pPr>`));
501
+ return;
502
+ }
503
+ if (blob.el === null) {
504
+ parts.push(blob.bytes); // index miss (unexpected): safest is verbatim
505
+ return;
506
+ }
507
+
508
+ const haveIndEl = findPPrChild(blob.el, "ind");
509
+ const jcChanged = wantJc !== undefined && wantJc !== pPrJc(blob.el);
510
+ const indChanged = wantInd !== null && !indMatches(haveIndEl, wantInd);
511
+ if (!jcChanged && !indChanged) {
512
+ parts.push(blob.bytes); // UNCHANGED -> verbatim (ADR-0017)
513
+ return;
514
+ }
515
+
516
+ // ONE rank walk places both, so a paragraph whose jc AND ind both changed
517
+ // still comes out in canonical order — two separate walks could not, and a
518
+ // second pass over a rebuilt buffer is the class of bug ADR-0023's own
519
+ // "single edit list, single applier" rule exists to prevent.
520
+ const bytes = provenance.documentXmlBytes;
521
+ /** @type {{ rank: number, xml: string, drop: string }[]} */
522
+ const pending = [];
523
+ if (indChanged) {
524
+ pending.push({
525
+ rank: /** @type {number} */ (PPR_RANK.get("ind")),
526
+ xml: emitIndElement(haveIndEl, /** @type {Record<string, string>} */ (wantInd)),
527
+ drop: "ind",
528
+ });
529
+ }
530
+ if (jcChanged) {
531
+ pending.push({
532
+ rank: /** @type {number} */ (PPR_RANK.get("jc")),
533
+ xml: `<w:jc w:val="${wantJc}"/>`,
534
+ drop: "jc",
535
+ });
536
+ }
537
+ pending.sort((a, b) => a.rank - b.rank);
538
+ const dropped = new Set(pending.map((entry) => entry.drop));
539
+
540
+ parts.push(OPEN_PPR);
541
+ let next = 0;
542
+ let prevRank = -1;
543
+ for (const child of blob.el.children) {
544
+ if (child.kind !== "element") continue;
545
+ if (child.uri === W_NS && dropped.has(child.localName)) {
546
+ continue; // dropped — the pending replacement carries its attributes
547
+ }
548
+ const rank = child.uri === W_NS && PPR_RANK.has(child.localName)
549
+ ? /** @type {number} */ (PPR_RANK.get(child.localName))
550
+ : prevRank; // unknown child keeps its predecessor's rank (stays adjacent)
551
+ prevRank = rank;
552
+ while (next < pending.length && pending[next].rank < rank) {
553
+ parts.push(encoder.encode(pending[next].xml));
554
+ next += 1;
555
+ }
556
+ parts.push(bytes.subarray(child.start, child.end)); // other child, verbatim
557
+ }
558
+ while (next < pending.length) {
559
+ parts.push(encoder.encode(pending[next].xml));
560
+ next += 1;
561
+ }
562
+ parts.push(CLOSE_PPR);
563
+ }
564
+
565
+ /**
566
+ * One paragraph -> `<w:p>[pPr][runs]</w:p>`. `w:pPr` is the paragraph's blob
567
+ * verbatim (never edited); empty runs (the single empty run normalize keeps for
568
+ * an empty paragraph, ADR-0021) emit nothing, so an empty paragraph is
569
+ * `<w:p>[pPr]</w:p>` — a valid, caret-able empty paragraph.
570
+ * @param {Uint8Array[]} parts
571
+ * @param {import("../../model/node.js").Paragraph} para
572
+ * @param {number} containerId
573
+ * @param {import("./provenance.js").Provenance} provenance
574
+ * @param {Map<number, import("./xml-scan.js").XmlElement>} elementByStart
575
+ * @returns {void}
576
+ */
577
+ function emitParagraph(parts, para, containerId, provenance, elementByStart) {
578
+ parts.push(OPEN_P);
579
+ // Must precede runs (CT_P). Verbatim unless the model carries a direct jc
580
+ // override that differs from the blob's — see emitParagraphProps.
581
+ emitParagraphProps(parts, para, containerId, provenance, elementByStart);
582
+ for (const run of para.content) {
583
+ if (run.text === "") continue;
584
+ parts.push(OPEN_R);
585
+ emitRunProps(parts, run, containerId, provenance, elementByStart);
586
+ parts.push(encoder.encode(`<w:t xml:space="preserve">${escapeXml(run.text)}</w:t>`));
587
+ parts.push(CLOSE_R);
588
+ }
589
+ parts.push(CLOSE_P);
590
+ }
591
+
592
+ /**
593
+ * Every `w:txbxContent` descendant of `el`, NOT descending into one once found
594
+ * (a text box nested inside a text box is 0/46 in the corpus, ADR-0020 §2 — and
595
+ * `walkDocumentOrder` excludes it for exactly that reason; this mirrors it).
596
+ * @param {import("./xml-scan.js").XmlElement} el
597
+ * @param {import("./xml-scan.js").XmlElement[]} out
598
+ * @returns {import("./xml-scan.js").XmlElement[]}
599
+ */
600
+ function collectTxbxContents(el, out) {
601
+ for (const child of el.children) {
602
+ if (child.kind !== "element") {
603
+ continue;
604
+ }
605
+ if (child.uri === W_NS && child.localName === "txbxContent") {
606
+ out.push(child);
607
+ } else {
608
+ collectTxbxContents(child, out);
609
+ }
610
+ }
611
+ return out;
612
+ }
613
+
614
+ /**
615
+ * @param {Uint8Array[]} parts
616
+ * @returns {Uint8Array}
617
+ */
618
+ function concatBytes(parts) {
619
+ let total = 0;
620
+ for (const p of parts) {
621
+ total += p.length;
622
+ }
623
+ const out = new Uint8Array(total);
624
+ let at = 0;
625
+ for (const p of parts) {
626
+ out.set(p, at);
627
+ at += p.length;
628
+ }
629
+ return out;
630
+ }
631
+
632
+ /**
633
+ * New document.xml bytes with ONLY each dirty container's txbxContent re-written
634
+ * from `doc` (the EDITED document); every other byte byte-identical to the
635
+ * original. `provenance` must be the one `importDocx` returned for `doc`'s source
636
+ * (its `documentXmlBytes`/`anchorRanges`/`propBlobs` address the original bytes).
637
+ * An empty `dirtyIds` returns the original bytes unchanged (a genuine no-op).
638
+ *
639
+ * @param {import("./provenance.js").Provenance} provenance
640
+ * @param {import("../../model/node.js").Doc} doc the edited document
641
+ * @param {Iterable<import("../../model/node.js").NodeId>} dirtyIds
642
+ * @returns {Uint8Array}
643
+ */
644
+ export function spliceDirtyText(provenance, doc, dirtyIds) {
645
+ return spliceDirty(provenance, doc, { textIds: dirtyIds }).bytes;
646
+ }
647
+
648
+ /**
649
+ * The `wp:anchor` of container `id`, resolved through provenance. Throws — never
650
+ * returns null — because every failure here means the provenance and the document
651
+ * disagree, which must be loud.
652
+ * @param {import("./provenance.js").Provenance} provenance
653
+ * @param {Map<number, import("./xml-scan.js").XmlElement>} anchorByStart
654
+ * @param {import("../../model/node.js").NodeId} id
655
+ * @returns {import("./xml-scan.js").XmlElement}
656
+ */
657
+ function anchorFor(provenance, anchorByStart, id) {
658
+ const range = provenance.anchorRanges.get(id);
659
+ if (range === undefined) {
660
+ throw new Error(`splice: container ${id} has no anchor range (not an imported box)`);
661
+ }
662
+ const anchor = anchorByStart.get(range.start);
663
+ if (anchor === undefined) {
664
+ throw new Error(`splice: no anchor at byte ${range.start} for container ${id}`);
665
+ }
666
+ return anchor;
667
+ }
668
+
669
+ /**
670
+ * The `wp:positionH`/`wp:positionV` child holding this axis's `wp:posOffset`.
671
+ * @param {import("./xml-scan.js").XmlElement} anchor
672
+ * @param {"x" | "y"} axis
673
+ * @returns {import("./xml-scan.js").XmlElement | null}
674
+ */
675
+ function posOffsetElement(anchor, axis) {
676
+ const position = findChild(anchor, WP_NS, axis === "x" ? "positionH" : "positionV");
677
+ if (position === null || position === undefined) {
678
+ return null;
679
+ }
680
+ return findChild(position, WP_NS, "posOffset") ?? null;
681
+ }
682
+
683
+ /**
684
+ * Whether this anchor's graphic is a `wpg:wgp` GROUP.
685
+ *
686
+ * The geometry writer must refuse groups: a group child's model coordinate is a
687
+ * COMPOSED value (`import.js`: `x = anchorX + (offX - chOffX) * scaleX`), so
688
+ * writing it into the anchor's own `wp:posOffset` would move the wrong thing.
689
+ *
690
+ * 🔴 This cannot reuse the text path's guard: that one counts `w:txbxContent`
691
+ * under the anchor ("need 1"), which a group holding ONE text box passes
692
+ * cleanly — measured, that proxy has never fired on this corpus. Grouping is
693
+ * tested here directly.
694
+ * @param {import("./xml-scan.js").XmlElement} anchor
695
+ * @returns {boolean}
696
+ */
697
+ function isGroupedAnchor(anchor) {
698
+ const graphic = findChild(anchor, A_NS, "graphic");
699
+ if (graphic === null || graphic === undefined) {
700
+ return false;
701
+ }
702
+ const graphicData = findChild(graphic, A_NS, "graphicData");
703
+ if (graphicData === null || graphicData === undefined) {
704
+ return false;
705
+ }
706
+ const group = findChild(graphicData, WPG_NS, "wgp");
707
+ return group !== null && group !== undefined;
708
+ }
709
+
710
+ /**
711
+ * @typedef {object} SkippedGeometry
712
+ * @property {import("../../model/node.js").NodeId} containerId
713
+ * @property {string[]} keys the layout keys whose new value was NOT written
714
+ * @property {"grouped" | "locked" | "unsupported-key"} reason
715
+ */
716
+
717
+ /**
718
+ * Byte edits for the geometry the user actually changed, plus the list of
719
+ * changes this slice REFUSES to write (never silently dropped — the caller
720
+ * surfaces them, see `export.js`'s report).
721
+ *
722
+ * Only `x`/`y` are written, by replacing the digits inside `wp:posOffset`. The
723
+ * anchor itself is never regenerated: ADR-0023's 4a experiment (Word refused a
724
+ * document whose anchor had lost three attributes) constrains REGENERATION, and
725
+ * a digit replacement touches no attribute at all.
726
+ *
727
+ * `cx`/`cy` are refused on purpose. Measured 2026-08-29: size has TWO live
728
+ * copies inside `mc:Choice` — `wp:extent@cx/@cy` AND `wps:spPr/a:xfrm/a:ext`
729
+ * (7,990/7,990 containers carry both) — so writing one leaves the other stale.
730
+ * Writing both is its own slice.
731
+ *
732
+ * @param {import("./provenance.js").Provenance} provenance
733
+ * @param {import("../../model/node.js").Doc} doc the EDITED document
734
+ * @param {import("../../model/node.js").Doc} originalDoc the IMPORTED document (read-back baseline)
735
+ * @param {Map<import("../../model/node.js").NodeId, Set<string>>} geometry id -> changed layout keys
736
+ * @param {Map<number, import("./xml-scan.js").XmlElement>} anchorByStart
737
+ * @param {SkippedGeometry[]} skipped appended to
738
+ * @returns {{start: number, end: number, bytes: Uint8Array}[]}
739
+ */
740
+ function buildGeometryEdits(provenance, doc, originalDoc, geometry, anchorByStart, skipped) {
741
+ /** @type {{start: number, end: number, bytes: Uint8Array}[]} */
742
+ const edits = [];
743
+ const encoder = new TextEncoder();
744
+ const decoder = new TextDecoder();
745
+
746
+ for (const [id, changedKeys] of geometry) {
747
+ if (containerById(doc, id) === null) {
748
+ throw new Error(`splice: dirty geometry id ${id} is not a container in doc`);
749
+ }
750
+ const layout = doc.layout[id];
751
+ const before = originalDoc.layout[id];
752
+ if (layout === undefined || before === undefined) {
753
+ // A box the imported document did not position (or a brand-new one) has no
754
+ // original bytes to splice — new-anchor writing is a later slice.
755
+ skipped.push({ containerId: id, keys: [...changedKeys], reason: "unsupported-key" });
756
+ continue;
757
+ }
758
+
759
+ const anchor = anchorFor(provenance, anchorByStart, id);
760
+ if (isGroupedAnchor(anchor)) {
761
+ skipped.push({ containerId: id, keys: [...changedKeys], reason: "grouped" });
762
+ continue;
763
+ }
764
+ if (layout.locked === true) {
765
+ // `locked` means the box carries its own rotation/flip, which this codebase
766
+ // never renders (FIDELITY: DEGRADED). Writing posOffset would move the
767
+ // UNROTATED bounding box — not what the editor drew. The UI withholds the
768
+ // drag affordance but the keyboard nudge path does not check `locked`, so
769
+ // the refusal has to live here, not in the UI.
770
+ skipped.push({ containerId: id, keys: [...changedKeys], reason: "locked" });
771
+ continue;
772
+ }
773
+
774
+ /** @type {string[]} */
775
+ const refusedKeys = [];
776
+ for (const key of changedKeys) {
777
+ if (key !== "x" && key !== "y") {
778
+ refusedKeys.push(key);
779
+ continue;
780
+ }
781
+ const element = posOffsetElement(anchor, key);
782
+ if (element === null) {
783
+ refusedKeys.push(key);
784
+ continue;
785
+ }
786
+
787
+ // READ-BACK: the digits currently in these bytes must decode to the value
788
+ // the IMPORTED model holds for this key. Exact by construction (import
789
+ // computed layout[key] from precisely this text), so a mismatch means the
790
+ // provenance and the document disagree — a bug, not a user situation.
791
+ const currentText = decoder.decode(provenance.documentXmlBytes.subarray(element.contentStart, element.contentEnd));
792
+ const currentEmu = Number.parseInt(currentText, 10);
793
+ if (!Number.isFinite(currentEmu) || emuToPt(currentEmu) !== before[key]) {
794
+ throw new Error(
795
+ `splice: read-back mismatch for container ${id} ${key} — document says ${currentText} ` +
796
+ `(${emuToPt(currentEmu)} pt) but the imported model says ${before[key]} pt`,
797
+ );
798
+ }
799
+
800
+ edits.push({
801
+ start: element.contentStart,
802
+ end: element.contentEnd,
803
+ bytes: encoder.encode(String(ptToEmu(/** @type {number} */ (layout[key])))),
804
+ });
805
+ }
806
+ if (refusedKeys.length > 0) {
807
+ skipped.push({ containerId: id, keys: refusedKeys, reason: "unsupported-key" });
808
+ }
809
+ }
810
+ return edits;
811
+ }
812
+
813
+ /**
814
+ * New document.xml bytes with the touched containers' text AND geometry
815
+ * re-written from `doc`; every other byte byte-identical to the original.
816
+ *
817
+ * @param {import("./provenance.js").Provenance} provenance
818
+ * @param {import("../../model/node.js").Doc} doc the edited document
819
+ * @param {object} dirty
820
+ * @param {Iterable<import("../../model/node.js").NodeId>} [dirty.textIds]
821
+ * @param {Map<import("../../model/node.js").NodeId, Set<string>>} [dirty.geometry]
822
+ * @param {import("../../model/node.js").Doc} [dirty.originalDoc] required when `geometry` is non-empty
823
+ * @returns {{bytes: Uint8Array, skippedGeometry: SkippedGeometry[]}} `bytes` is
824
+ * the ORIGINAL reference when nothing was written (export.js depends on that
825
+ * identity to skip repacking entirely)
826
+ */
827
+ export function spliceDirty(provenance, doc, { textIds = [], geometry = new Map(), originalDoc } = {}) {
828
+ const original = provenance.documentXmlBytes;
829
+ const ids = [...new Set(textIds)];
830
+ /** @type {SkippedGeometry[]} */
831
+ const skippedGeometry = [];
832
+ if (ids.length === 0 && geometry.size === 0) {
833
+ return { bytes: original, skippedGeometry };
834
+ }
835
+
836
+ const { root } = scanXml(original);
837
+ const elementByStart = indexElementsByStart(root);
838
+ /** @type {Map<number, import("./xml-scan.js").XmlElement>} */
839
+ const anchorByStart = new Map();
840
+ for (const anchor of collectAnchors(root)) {
841
+ anchorByStart.set(anchor.start, anchor);
842
+ }
843
+
844
+ /** @type {{start: number, end: number, bytes: Uint8Array}[]} */
845
+ const edits = [];
846
+ for (const id of ids) {
847
+ const container = containerById(doc, id);
848
+ if (container === null) {
849
+ throw new Error(`spliceDirtyText: dirty id ${id} is not a container in doc`);
850
+ }
851
+ const anchor = anchorFor(provenance, anchorByStart, id);
852
+ const txbxes = collectTxbxContents(anchor, []);
853
+ if (txbxes.length !== 1) {
854
+ // Narrowest slice: exactly one text box per touched anchor. A grouped
855
+ // multi-box anchor (several txbxContent sharing one anchor range) cannot
856
+ // be disambiguated from the anchor range alone — that is a later slice.
857
+ throw new Error(
858
+ `spliceDirtyText: container ${id}'s anchor holds ${txbxes.length} txbxContent (need 1) — grouped multi-box is a later slice`,
859
+ );
860
+ }
861
+ const txbx = txbxes[0];
862
+ /** @type {Uint8Array[]} */
863
+ const parts = [];
864
+ for (const para of container.content) {
865
+ emitParagraph(parts, para, id, provenance, elementByStart);
866
+ }
867
+ edits.push({ start: txbx.contentStart, end: txbx.contentEnd, bytes: concatBytes(parts) });
868
+ }
869
+
870
+ if (geometry.size > 0) {
871
+ if (originalDoc === undefined) {
872
+ throw new Error("spliceDirty: geometry edits need `originalDoc` (the imported document) for the read-back check");
873
+ }
874
+ edits.push(...buildGeometryEdits(provenance, doc, originalDoc, geometry, anchorByStart, skippedGeometry));
875
+ }
876
+
877
+ // ONE application pass for text AND geometry together — see applyEdits for why
878
+ // a second pass over the rebuilt buffer would be a silent corruption.
879
+ return { bytes: applyEdits(original, edits), skippedGeometry };
880
+ }