@sametalmdr/word-editor 0.2.0-alpha → 0.3.0-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +108 -70
- package/LICENSE +21 -21
- package/README.md +286 -247
- package/dist/canvas/box-clipboard.js +238 -238
- package/dist/canvas/box-selection.js +80 -80
- package/dist/canvas/canvas-editor.js +502 -502
- package/dist/canvas/controller.js +717 -717
- package/dist/canvas/geometry.js +131 -131
- package/dist/canvas/gesture.d.ts +0 -1
- package/dist/canvas/gesture.js +289 -289
- package/dist/canvas/handles.js +212 -212
- package/dist/canvas/menu.js +82 -82
- package/dist/canvas/mode-controller.js +81 -81
- package/dist/canvas/nudge.js +108 -108
- package/dist/canvas/overlay.js +149 -149
- package/dist/canvas/snap.js +132 -132
- package/dist/canvas/viewport.js +112 -112
- package/dist/canvas/z-order.js +171 -171
- package/dist/commands/alignment.js +150 -150
- package/dist/commands/box.js +272 -272
- package/dist/commands/clipboard.js +93 -93
- package/dist/commands/graphemes.js +112 -112
- package/dist/commands/marks.js +153 -153
- package/dist/commands/paragraph-props.js +176 -176
- package/dist/commands/size.js +368 -368
- package/dist/commands/text.js +291 -291
- package/dist/docx.d.ts +1 -0
- package/dist/docx.js +15 -13
- package/dist/editor/editing-host.js +434 -434
- package/dist/element.js +41 -41
- package/dist/fonts/OFL-Carlito.txt +94 -0
- package/dist/fonts/calibri-metrics.d.ts +27 -0
- package/dist/fonts/calibri-metrics.js +88 -0
- package/dist/history/history.js +433 -433
- package/dist/index.js +63 -63
- package/dist/input/beforeinput.js +224 -224
- package/dist/input/clipboard.js +107 -107
- package/dist/input/composition.js +341 -341
- package/dist/input/keymap.js +54 -54
- package/dist/io/ooxml/custgeom.js +72 -72
- package/dist/io/ooxml/drawingml.js +232 -232
- package/dist/io/ooxml/edits.js +67 -67
- package/dist/io/ooxml/export-splice.js +880 -880
- package/dist/io/ooxml/export.js +365 -365
- package/dist/io/ooxml/fill.d.ts +93 -8
- package/dist/io/ooxml/fill.js +618 -562
- package/dist/io/ooxml/fit.d.ts +252 -0
- package/dist/io/ooxml/fit.js +598 -0
- package/dist/io/ooxml/import-gate.js +200 -200
- package/dist/io/ooxml/import.js +691 -691
- package/dist/io/ooxml/inflate.js +126 -126
- package/dist/io/ooxml/media.js +87 -87
- package/dist/io/ooxml/ns.d.ts +2 -0
- package/dist/io/ooxml/ns.js +26 -21
- package/dist/io/ooxml/numbering.js +290 -290
- package/dist/io/ooxml/opc-write.js +171 -171
- package/dist/io/ooxml/provenance.js +93 -93
- package/dist/io/ooxml/recipe.js +708 -708
- package/dist/io/ooxml/relationships.js +59 -59
- package/dist/io/ooxml/run-fonts.d.ts +89 -0
- package/dist/io/ooxml/run-fonts.js +378 -0
- package/dist/io/ooxml/slots.d.ts +4 -0
- package/dist/io/ooxml/slots.js +582 -581
- package/dist/io/ooxml/styles-resolve.js +257 -257
- package/dist/io/ooxml/units.js +43 -43
- package/dist/io/ooxml/value.js +120 -120
- package/dist/io/ooxml/xml-scan.js +616 -616
- package/dist/io/ooxml/zip-container.js +228 -228
- package/dist/io/ooxml/zip-reject.js +106 -106
- package/dist/measure/placement.js +83 -83
- package/dist/measure/report.js +75 -75
- package/dist/model/ids.js +70 -70
- package/dist/model/normalize.js +146 -146
- package/dist/model/position.js +283 -283
- package/dist/model/transaction.js +209 -209
- package/dist/platform/capabilities.js +25 -25
- package/dist/react/index.js +21 -21
- package/dist/selection/controller.js +315 -315
- package/dist/selection/dom-map.js +334 -334
- package/dist/state/editor-state.js +109 -109
- package/dist/styles.css +66 -66
- package/package.json +94 -93
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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, """)}"`);
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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, """)}"`);
|
|
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
|
+
}
|