@sametalmdr/word-editor 0.2.0-alpha → 0.3.0-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +108 -70
  2. package/LICENSE +21 -21
  3. package/README.md +286 -247
  4. package/dist/canvas/box-clipboard.js +238 -238
  5. package/dist/canvas/box-selection.js +80 -80
  6. package/dist/canvas/canvas-editor.js +502 -502
  7. package/dist/canvas/controller.js +717 -717
  8. package/dist/canvas/geometry.js +131 -131
  9. package/dist/canvas/gesture.d.ts +0 -1
  10. package/dist/canvas/gesture.js +289 -289
  11. package/dist/canvas/handles.js +212 -212
  12. package/dist/canvas/menu.js +82 -82
  13. package/dist/canvas/mode-controller.js +81 -81
  14. package/dist/canvas/nudge.js +108 -108
  15. package/dist/canvas/overlay.js +149 -149
  16. package/dist/canvas/snap.js +132 -132
  17. package/dist/canvas/viewport.js +112 -112
  18. package/dist/canvas/z-order.js +171 -171
  19. package/dist/commands/alignment.js +150 -150
  20. package/dist/commands/box.js +272 -272
  21. package/dist/commands/clipboard.js +93 -93
  22. package/dist/commands/graphemes.js +112 -112
  23. package/dist/commands/marks.js +153 -153
  24. package/dist/commands/paragraph-props.js +176 -176
  25. package/dist/commands/size.js +368 -368
  26. package/dist/commands/text.js +291 -291
  27. package/dist/docx.d.ts +1 -0
  28. package/dist/docx.js +15 -13
  29. package/dist/editor/editing-host.js +434 -434
  30. package/dist/element.js +41 -41
  31. package/dist/fonts/OFL-Carlito.txt +94 -0
  32. package/dist/fonts/calibri-metrics.d.ts +27 -0
  33. package/dist/fonts/calibri-metrics.js +88 -0
  34. package/dist/history/history.js +433 -433
  35. package/dist/index.js +63 -63
  36. package/dist/input/beforeinput.js +224 -224
  37. package/dist/input/clipboard.js +107 -107
  38. package/dist/input/composition.js +341 -341
  39. package/dist/input/keymap.js +54 -54
  40. package/dist/io/ooxml/custgeom.js +72 -72
  41. package/dist/io/ooxml/drawingml.js +232 -232
  42. package/dist/io/ooxml/edits.js +67 -67
  43. package/dist/io/ooxml/export-splice.js +880 -880
  44. package/dist/io/ooxml/export.js +365 -365
  45. package/dist/io/ooxml/fill.d.ts +93 -8
  46. package/dist/io/ooxml/fill.js +618 -562
  47. package/dist/io/ooxml/fit.d.ts +252 -0
  48. package/dist/io/ooxml/fit.js +598 -0
  49. package/dist/io/ooxml/import-gate.js +200 -200
  50. package/dist/io/ooxml/import.js +691 -691
  51. package/dist/io/ooxml/inflate.js +126 -126
  52. package/dist/io/ooxml/media.js +87 -87
  53. package/dist/io/ooxml/ns.d.ts +2 -0
  54. package/dist/io/ooxml/ns.js +26 -21
  55. package/dist/io/ooxml/numbering.js +290 -290
  56. package/dist/io/ooxml/opc-write.js +171 -171
  57. package/dist/io/ooxml/provenance.js +93 -93
  58. package/dist/io/ooxml/recipe.js +708 -708
  59. package/dist/io/ooxml/relationships.js +59 -59
  60. package/dist/io/ooxml/run-fonts.d.ts +89 -0
  61. package/dist/io/ooxml/run-fonts.js +378 -0
  62. package/dist/io/ooxml/slots.d.ts +4 -0
  63. package/dist/io/ooxml/slots.js +582 -581
  64. package/dist/io/ooxml/styles-resolve.js +257 -257
  65. package/dist/io/ooxml/units.js +43 -43
  66. package/dist/io/ooxml/value.js +120 -120
  67. package/dist/io/ooxml/xml-scan.js +616 -616
  68. package/dist/io/ooxml/zip-container.js +228 -228
  69. package/dist/io/ooxml/zip-reject.js +106 -106
  70. package/dist/measure/placement.js +83 -83
  71. package/dist/measure/report.js +75 -75
  72. package/dist/model/ids.js +70 -70
  73. package/dist/model/normalize.js +146 -146
  74. package/dist/model/position.js +283 -283
  75. package/dist/model/transaction.js +209 -209
  76. package/dist/platform/capabilities.js +25 -25
  77. package/dist/react/index.js +21 -21
  78. package/dist/selection/controller.js +315 -315
  79. package/dist/selection/dom-map.js +334 -334
  80. package/dist/state/editor-state.js +109 -109
  81. package/dist/styles.css +66 -66
  82. package/package.json +94 -93
@@ -1,232 +1,232 @@
1
- // wp:anchor -> geometry, group-child coordinates, paragraph/run/mark
2
- // extraction. Ported from scripts/docx-to-canvas-json.py's convert()/
3
- // parse_xfrm()/paragraphs_of()/run_marks() — same fields, same "findall is
4
- // DIRECT CHILDREN ONLY" semantics, same silent-continue gaps (PARITY FIRST,
5
- // ADR-0020 §2: this piece must not fix what Python doesn't read yet —
6
- // pic:pic, bodyPr, anything beyond bold/italic/underline).
7
- //
8
- // `walkDocumentOrder` is THE single-pass replacement for Python's already
9
- // bugfixed-but-still-two-list `collect()` + regex `anchor_starts` pair
10
- // (ADR-0020 §1a/B1). Every element carries its own byte offset (xml-scan.js),
11
- // so there is no second list to build, and nothing to zip by index — the
12
- // anchor IS its own true offset. sections.js imports this same walker for
13
- // sectPr collection so the txbxContent/mc:Fallback exclusion rule is written
14
- // ONCE, not twice (the exact "two copies drifted" risk network-patterns.js
15
- // already had once in this project).
16
-
17
- import { getAttr, isElement as isEl, findChild, findChildren } from "./xml-scan.js";
18
- import { W_NS, WP_NS, A_NS, WPS_NS, WPG_NS, PIC_NS, MC_NS } from "./ns.js";
19
- import { rejectInlineDrawing } from "./import-gate.js";
20
-
21
- /**
22
- * @param {import("./xml-scan.js").XmlElement} el
23
- * @param {string} uri
24
- * @param {string} localName
25
- * @param {import("./xml-scan.js").XmlElement} path
26
- * @returns {string}
27
- */
28
- function requireAttr(el, uri, localName, path) {
29
- const v = getAttr(el, uri, localName);
30
- if (v === undefined) {
31
- throw new Error(`drawingml: <${path.prefix ? path.prefix + ":" : ""}${path.localName}> is missing required attribute ${JSON.stringify(localName)} (bayt ${el.start})`);
32
- }
33
- return v;
34
- }
35
-
36
- /**
37
- * Single-pass, document-order walk. Never descends into `w:txbxContent`
38
- * (a drawing nested inside a text box's own content — parity with Python's
39
- * `collect()`, which excludes it for the same reason its separate
40
- * `anchor_starts` regex scan masks it out first). For `mc:AlternateContent`,
41
- * descends ONLY into the first `mc:Choice` found — never `mc:Fallback` (the
42
- * VML duplicate) — matching `el.find(q("mc","Choice"))`'s "first match,
43
- * Requires ignored" simplification exactly, not a spec-correct MC processor.
44
- * @param {import("./xml-scan.js").XmlElement} el
45
- * @param {(el: import("./xml-scan.js").XmlElement) => void} visit
46
- * @returns {void}
47
- */
48
- export function walkDocumentOrder(el, visit) {
49
- visit(el);
50
- if (isEl(el, W_NS, "txbxContent")) return;
51
- if (isEl(el, MC_NS, "AlternateContent")) {
52
- const choice = findChild(el, MC_NS, "Choice");
53
- if (choice) walkDocumentOrder(choice, visit);
54
- return;
55
- }
56
- for (const child of el.children) {
57
- if (child.kind === "element") walkDocumentOrder(child, visit);
58
- }
59
- }
60
-
61
- /**
62
- * Every `wp:anchor`, in true document order — a `w:drawing`'s own direct
63
- * children, collected as `walkDocumentOrder` reaches each `w:drawing`.
64
- *
65
- * A `w:drawing` wrapping `wp:inline` instead is rejected here rather than
66
- * silently producing zero anchors for it (ADR-0020 Karar 4: 0/46 real corpus
67
- * documents use inline placement, so a document that does is corpus-external
68
- * and this importer has no coordinate to give it).
69
- * @param {import("./xml-scan.js").XmlElement} root
70
- * @returns {import("./xml-scan.js").XmlElement[]}
71
- */
72
- export function collectAnchors(root) {
73
- /** @type {import("./xml-scan.js").XmlElement[]} */
74
- const anchors = [];
75
- walkDocumentOrder(root, (el) => {
76
- if (isEl(el, W_NS, "drawing")) {
77
- if (findChildren(el, WP_NS, "inline").length > 0) {
78
- rejectInlineDrawing(`w:drawing at byte ${el.start}`);
79
- }
80
- anchors.push(...findChildren(el, WP_NS, "anchor"));
81
- }
82
- });
83
- return anchors;
84
- }
85
-
86
- /**
87
- * @typedef {object} Xfrm
88
- * @property {number} offX
89
- * @property {number} offY
90
- * @property {number} extCx
91
- * @property {number} extCy
92
- * @property {number} chOffX
93
- * @property {number} chOffY
94
- * @property {number} chExtCx
95
- * @property {number} chExtCy
96
- * @property {number} rot
97
- * @property {boolean} flipH
98
- * @property {boolean} flipV
99
- */
100
-
101
- /**
102
- * off/ext/chOff/chExt, plus rot/flipH/flipV — read unconditionally (ported
103
- * from parse_xfrm; docs/corpus-inventory.md Bölüm 8 found these were never
104
- * read at all, a real gap: a rotated shape's position was computed as if it
105
- * were not rotated, with no signal anywhere that anything had been dropped).
106
- * Values are carried through as-is, not interpreted.
107
- * @param {import("./xml-scan.js").XmlElement | undefined} spPr
108
- * @returns {Xfrm | null}
109
- */
110
- export function parseXfrm(spPr) {
111
- if (!spPr) return null;
112
- const xfrm = findChild(spPr, A_NS, "xfrm");
113
- if (!xfrm) return null;
114
- const off = findChild(xfrm, A_NS, "off");
115
- const ext = findChild(xfrm, A_NS, "ext");
116
- if (!off || !ext) return null;
117
- const chOff = findChild(xfrm, A_NS, "chOff");
118
- const chExt = findChild(xfrm, A_NS, "chExt");
119
-
120
- const extCx = Number.parseInt(requireAttr(ext, "", "cx", ext), 10);
121
- const extCy = Number.parseInt(requireAttr(ext, "", "cy", ext), 10);
122
-
123
- return {
124
- offX: Number.parseInt(requireAttr(off, "", "x", off), 10),
125
- offY: Number.parseInt(requireAttr(off, "", "y", off), 10),
126
- extCx,
127
- extCy,
128
- chOffX: chOff ? Number.parseInt(requireAttr(chOff, "", "x", chOff), 10) : 0,
129
- chOffY: chOff ? Number.parseInt(requireAttr(chOff, "", "y", chOff), 10) : 0,
130
- chExtCx: chExt ? Number.parseInt(requireAttr(chExt, "", "cx", chExt), 10) : extCx,
131
- chExtCy: chExt ? Number.parseInt(requireAttr(chExt, "", "cy", chExt), 10) : extCy,
132
- rot: Number.parseInt(getAttr(xfrm, "", "rot") ?? "0", 10),
133
- flipH: getAttr(xfrm, "", "flipH") === "1",
134
- flipV: getAttr(xfrm, "", "flipV") === "1",
135
- };
136
- }
137
-
138
- // Python's `val not in ("0", "false", "none")` guard against an explicit off-value.
139
- const MARK_OFF_VALUES = new Set(["0", "false", "none"]);
140
-
141
- /**
142
- * @param {import("./xml-scan.js").XmlElement} runEl
143
- * @returns {{bold?: true, italic?: true, underline?: true}}
144
- */
145
- export function runMarks(runEl) {
146
- const rPr = findChild(runEl, W_NS, "rPr");
147
- /** @type {{bold?: true, italic?: true, underline?: true}} */
148
- const marks = {};
149
- if (!rPr) return marks;
150
- for (const [tag, name] of /** @type {const} */ ([
151
- ["b", "bold"],
152
- ["i", "italic"],
153
- ["u", "underline"],
154
- ])) {
155
- const el = findChild(rPr, W_NS, tag);
156
- if (!el) continue;
157
- const val = getAttr(el, W_NS, "val");
158
- if (val === undefined || !MARK_OFF_VALUES.has(val)) {
159
- marks[name] = true;
160
- }
161
- }
162
- return marks;
163
- }
164
-
165
- /**
166
- * A run's own text — every DIRECT `w:t` child's content concatenated. A
167
- * `w:t` with no children (self-closing) contributes "". CDATA content is
168
- * folded in exactly like plain text — ElementTree's own `.text` does this
169
- * transparently (CDATA is an escaping mechanism, not a distinct parsed
170
- * value), so parity requires the same here even though this scanner models
171
- * CDATA as its own node kind.
172
- * @param {import("./xml-scan.js").XmlElement} runEl
173
- * @returns {string}
174
- */
175
- function runText(runEl) {
176
- let text = "";
177
- for (const t of findChildren(runEl, W_NS, "t")) {
178
- for (const child of t.children) {
179
- if (child.kind === "text" || child.kind === "cdata") text += child.value;
180
- }
181
- }
182
- return text;
183
- }
184
-
185
- /**
186
- * @typedef {object} ParsedRun
187
- * @property {string} text
188
- * @property {{bold?: true, italic?: true, underline?: true}} marks
189
- * @property {import("./xml-scan.js").XmlElement | undefined} rPrEl raw
190
- * element, for styles-resolve.js's PropRef interning — this file stays
191
- * styles-agnostic, it only hands back what it already walked past
192
- */
193
-
194
- /**
195
- * @typedef {object} ParsedParagraph
196
- * @property {ParsedRun[]} runs
197
- * @property {import("./xml-scan.js").XmlElement | undefined} pPrEl raw element
198
- * @property {string | undefined} pStyleId the RAW `w:pStyle` value if this
199
- * paragraph declares one — undefined (not defaulted) when absent; applying
200
- * `sx.defaultParagraphStyle` is the caller's job (import.js), since this
201
- * function has no styles.xml in scope
202
- */
203
-
204
- /**
205
- * Every DIRECT `w:p` child of a `w:txbxContent`, and every DIRECT `w:r`
206
- * child of each paragraph — ported from paragraphs_of(), including its
207
- * "runs with no w:t text are dropped entirely, even from the marks dict"
208
- * behavior. Does NOT mint ids (that stays the caller's job, matching
209
- * IdFactory's own "paragraph ids minted before the container id" ordering,
210
- * which lives in import.js where the container id is also minted).
211
- * @param {import("./xml-scan.js").XmlElement} txbxContent
212
- * @returns {ParsedParagraph[]}
213
- */
214
- export function paragraphsOf(txbxContent) {
215
- /** @type {ParsedParagraph[]} */
216
- const out = [];
217
- for (const p of findChildren(txbxContent, W_NS, "p")) {
218
- const pPrEl = findChild(p, W_NS, "pPr");
219
- const pStyleEl = pPrEl && findChild(pPrEl, W_NS, "pStyle");
220
- const pStyleId = pStyleEl ? getAttr(pStyleEl, W_NS, "val") : undefined;
221
- /** @type {ParsedRun[]} */
222
- const runs = [];
223
- for (const r of findChildren(p, W_NS, "r")) {
224
- const text = runText(r);
225
- if (text === "") continue;
226
- runs.push({ text, marks: runMarks(r), rPrEl: findChild(r, W_NS, "rPr") });
227
- }
228
- out.push({ runs, pPrEl, pStyleId });
229
- }
230
- return out;
231
- }
232
-
1
+ // wp:anchor -> geometry, group-child coordinates, paragraph/run/mark
2
+ // extraction. Ported from scripts/docx-to-canvas-json.py's convert()/
3
+ // parse_xfrm()/paragraphs_of()/run_marks() — same fields, same "findall is
4
+ // DIRECT CHILDREN ONLY" semantics, same silent-continue gaps (PARITY FIRST,
5
+ // ADR-0020 §2: this piece must not fix what Python doesn't read yet —
6
+ // pic:pic, bodyPr, anything beyond bold/italic/underline).
7
+ //
8
+ // `walkDocumentOrder` is THE single-pass replacement for Python's already
9
+ // bugfixed-but-still-two-list `collect()` + regex `anchor_starts` pair
10
+ // (ADR-0020 §1a/B1). Every element carries its own byte offset (xml-scan.js),
11
+ // so there is no second list to build, and nothing to zip by index — the
12
+ // anchor IS its own true offset. sections.js imports this same walker for
13
+ // sectPr collection so the txbxContent/mc:Fallback exclusion rule is written
14
+ // ONCE, not twice (the exact "two copies drifted" risk network-patterns.js
15
+ // already had once in this project).
16
+
17
+ import { getAttr, isElement as isEl, findChild, findChildren } from "./xml-scan.js";
18
+ import { W_NS, WP_NS, A_NS, WPS_NS, WPG_NS, PIC_NS, MC_NS } from "./ns.js";
19
+ import { rejectInlineDrawing } from "./import-gate.js";
20
+
21
+ /**
22
+ * @param {import("./xml-scan.js").XmlElement} el
23
+ * @param {string} uri
24
+ * @param {string} localName
25
+ * @param {import("./xml-scan.js").XmlElement} path
26
+ * @returns {string}
27
+ */
28
+ function requireAttr(el, uri, localName, path) {
29
+ const v = getAttr(el, uri, localName);
30
+ if (v === undefined) {
31
+ throw new Error(`drawingml: <${path.prefix ? path.prefix + ":" : ""}${path.localName}> is missing required attribute ${JSON.stringify(localName)} (bayt ${el.start})`);
32
+ }
33
+ return v;
34
+ }
35
+
36
+ /**
37
+ * Single-pass, document-order walk. Never descends into `w:txbxContent`
38
+ * (a drawing nested inside a text box's own content — parity with Python's
39
+ * `collect()`, which excludes it for the same reason its separate
40
+ * `anchor_starts` regex scan masks it out first). For `mc:AlternateContent`,
41
+ * descends ONLY into the first `mc:Choice` found — never `mc:Fallback` (the
42
+ * VML duplicate) — matching `el.find(q("mc","Choice"))`'s "first match,
43
+ * Requires ignored" simplification exactly, not a spec-correct MC processor.
44
+ * @param {import("./xml-scan.js").XmlElement} el
45
+ * @param {(el: import("./xml-scan.js").XmlElement) => void} visit
46
+ * @returns {void}
47
+ */
48
+ export function walkDocumentOrder(el, visit) {
49
+ visit(el);
50
+ if (isEl(el, W_NS, "txbxContent")) return;
51
+ if (isEl(el, MC_NS, "AlternateContent")) {
52
+ const choice = findChild(el, MC_NS, "Choice");
53
+ if (choice) walkDocumentOrder(choice, visit);
54
+ return;
55
+ }
56
+ for (const child of el.children) {
57
+ if (child.kind === "element") walkDocumentOrder(child, visit);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Every `wp:anchor`, in true document order — a `w:drawing`'s own direct
63
+ * children, collected as `walkDocumentOrder` reaches each `w:drawing`.
64
+ *
65
+ * A `w:drawing` wrapping `wp:inline` instead is rejected here rather than
66
+ * silently producing zero anchors for it (ADR-0020 Karar 4: 0/46 real corpus
67
+ * documents use inline placement, so a document that does is corpus-external
68
+ * and this importer has no coordinate to give it).
69
+ * @param {import("./xml-scan.js").XmlElement} root
70
+ * @returns {import("./xml-scan.js").XmlElement[]}
71
+ */
72
+ export function collectAnchors(root) {
73
+ /** @type {import("./xml-scan.js").XmlElement[]} */
74
+ const anchors = [];
75
+ walkDocumentOrder(root, (el) => {
76
+ if (isEl(el, W_NS, "drawing")) {
77
+ if (findChildren(el, WP_NS, "inline").length > 0) {
78
+ rejectInlineDrawing(`w:drawing at byte ${el.start}`);
79
+ }
80
+ anchors.push(...findChildren(el, WP_NS, "anchor"));
81
+ }
82
+ });
83
+ return anchors;
84
+ }
85
+
86
+ /**
87
+ * @typedef {object} Xfrm
88
+ * @property {number} offX
89
+ * @property {number} offY
90
+ * @property {number} extCx
91
+ * @property {number} extCy
92
+ * @property {number} chOffX
93
+ * @property {number} chOffY
94
+ * @property {number} chExtCx
95
+ * @property {number} chExtCy
96
+ * @property {number} rot
97
+ * @property {boolean} flipH
98
+ * @property {boolean} flipV
99
+ */
100
+
101
+ /**
102
+ * off/ext/chOff/chExt, plus rot/flipH/flipV — read unconditionally (ported
103
+ * from parse_xfrm; docs/corpus-inventory.md Bölüm 8 found these were never
104
+ * read at all, a real gap: a rotated shape's position was computed as if it
105
+ * were not rotated, with no signal anywhere that anything had been dropped).
106
+ * Values are carried through as-is, not interpreted.
107
+ * @param {import("./xml-scan.js").XmlElement | undefined} spPr
108
+ * @returns {Xfrm | null}
109
+ */
110
+ export function parseXfrm(spPr) {
111
+ if (!spPr) return null;
112
+ const xfrm = findChild(spPr, A_NS, "xfrm");
113
+ if (!xfrm) return null;
114
+ const off = findChild(xfrm, A_NS, "off");
115
+ const ext = findChild(xfrm, A_NS, "ext");
116
+ if (!off || !ext) return null;
117
+ const chOff = findChild(xfrm, A_NS, "chOff");
118
+ const chExt = findChild(xfrm, A_NS, "chExt");
119
+
120
+ const extCx = Number.parseInt(requireAttr(ext, "", "cx", ext), 10);
121
+ const extCy = Number.parseInt(requireAttr(ext, "", "cy", ext), 10);
122
+
123
+ return {
124
+ offX: Number.parseInt(requireAttr(off, "", "x", off), 10),
125
+ offY: Number.parseInt(requireAttr(off, "", "y", off), 10),
126
+ extCx,
127
+ extCy,
128
+ chOffX: chOff ? Number.parseInt(requireAttr(chOff, "", "x", chOff), 10) : 0,
129
+ chOffY: chOff ? Number.parseInt(requireAttr(chOff, "", "y", chOff), 10) : 0,
130
+ chExtCx: chExt ? Number.parseInt(requireAttr(chExt, "", "cx", chExt), 10) : extCx,
131
+ chExtCy: chExt ? Number.parseInt(requireAttr(chExt, "", "cy", chExt), 10) : extCy,
132
+ rot: Number.parseInt(getAttr(xfrm, "", "rot") ?? "0", 10),
133
+ flipH: getAttr(xfrm, "", "flipH") === "1",
134
+ flipV: getAttr(xfrm, "", "flipV") === "1",
135
+ };
136
+ }
137
+
138
+ // Python's `val not in ("0", "false", "none")` guard against an explicit off-value.
139
+ const MARK_OFF_VALUES = new Set(["0", "false", "none"]);
140
+
141
+ /**
142
+ * @param {import("./xml-scan.js").XmlElement} runEl
143
+ * @returns {{bold?: true, italic?: true, underline?: true}}
144
+ */
145
+ export function runMarks(runEl) {
146
+ const rPr = findChild(runEl, W_NS, "rPr");
147
+ /** @type {{bold?: true, italic?: true, underline?: true}} */
148
+ const marks = {};
149
+ if (!rPr) return marks;
150
+ for (const [tag, name] of /** @type {const} */ ([
151
+ ["b", "bold"],
152
+ ["i", "italic"],
153
+ ["u", "underline"],
154
+ ])) {
155
+ const el = findChild(rPr, W_NS, tag);
156
+ if (!el) continue;
157
+ const val = getAttr(el, W_NS, "val");
158
+ if (val === undefined || !MARK_OFF_VALUES.has(val)) {
159
+ marks[name] = true;
160
+ }
161
+ }
162
+ return marks;
163
+ }
164
+
165
+ /**
166
+ * A run's own text — every DIRECT `w:t` child's content concatenated. A
167
+ * `w:t` with no children (self-closing) contributes "". CDATA content is
168
+ * folded in exactly like plain text — ElementTree's own `.text` does this
169
+ * transparently (CDATA is an escaping mechanism, not a distinct parsed
170
+ * value), so parity requires the same here even though this scanner models
171
+ * CDATA as its own node kind.
172
+ * @param {import("./xml-scan.js").XmlElement} runEl
173
+ * @returns {string}
174
+ */
175
+ function runText(runEl) {
176
+ let text = "";
177
+ for (const t of findChildren(runEl, W_NS, "t")) {
178
+ for (const child of t.children) {
179
+ if (child.kind === "text" || child.kind === "cdata") text += child.value;
180
+ }
181
+ }
182
+ return text;
183
+ }
184
+
185
+ /**
186
+ * @typedef {object} ParsedRun
187
+ * @property {string} text
188
+ * @property {{bold?: true, italic?: true, underline?: true}} marks
189
+ * @property {import("./xml-scan.js").XmlElement | undefined} rPrEl raw
190
+ * element, for styles-resolve.js's PropRef interning — this file stays
191
+ * styles-agnostic, it only hands back what it already walked past
192
+ */
193
+
194
+ /**
195
+ * @typedef {object} ParsedParagraph
196
+ * @property {ParsedRun[]} runs
197
+ * @property {import("./xml-scan.js").XmlElement | undefined} pPrEl raw element
198
+ * @property {string | undefined} pStyleId the RAW `w:pStyle` value if this
199
+ * paragraph declares one — undefined (not defaulted) when absent; applying
200
+ * `sx.defaultParagraphStyle` is the caller's job (import.js), since this
201
+ * function has no styles.xml in scope
202
+ */
203
+
204
+ /**
205
+ * Every DIRECT `w:p` child of a `w:txbxContent`, and every DIRECT `w:r`
206
+ * child of each paragraph — ported from paragraphs_of(), including its
207
+ * "runs with no w:t text are dropped entirely, even from the marks dict"
208
+ * behavior. Does NOT mint ids (that stays the caller's job, matching
209
+ * IdFactory's own "paragraph ids minted before the container id" ordering,
210
+ * which lives in import.js where the container id is also minted).
211
+ * @param {import("./xml-scan.js").XmlElement} txbxContent
212
+ * @returns {ParsedParagraph[]}
213
+ */
214
+ export function paragraphsOf(txbxContent) {
215
+ /** @type {ParsedParagraph[]} */
216
+ const out = [];
217
+ for (const p of findChildren(txbxContent, W_NS, "p")) {
218
+ const pPrEl = findChild(p, W_NS, "pPr");
219
+ const pStyleEl = pPrEl && findChild(pPrEl, W_NS, "pStyle");
220
+ const pStyleId = pStyleEl ? getAttr(pStyleEl, W_NS, "val") : undefined;
221
+ /** @type {ParsedRun[]} */
222
+ const runs = [];
223
+ for (const r of findChildren(p, W_NS, "r")) {
224
+ const text = runText(r);
225
+ if (text === "") continue;
226
+ runs.push({ text, marks: runMarks(r), rPrEl: findChild(r, W_NS, "rPr") });
227
+ }
228
+ out.push({ runs, pPrEl, pStyleId });
229
+ }
230
+ return out;
231
+ }
232
+
@@ -1,67 +1,67 @@
1
- // Byte-range edits over an original buffer, shared by the editor's export
2
- // (export-splice.js) and the template fill engine (fill.js, ADR-0037).
3
- // Moved from export-splice.js in FR2 so both writers apply edits through the
4
- // same validated pass.
5
-
6
- /**
7
- * Applies `edits` to `original` and returns the new bytes.
8
- *
9
- * ONE list, ONE pass, and every edit's `[start, end)` addresses `original` —
10
- * never a partially rebuilt buffer. The output is allocated once at its final
11
- * length and assembled front to back: the original's bytes between edits, then
12
- * each edit's own bytes. Until FR2 the edits were applied from the highest
13
- * offset down, re-concatenating the WHOLE buffer per edit — O(edits × size),
14
- * which the template fill engine (hundreds of edits on a 1.75 MB document.xml)
15
- * could not afford. The result is the same for every accepted edit list,
16
- * including a zero-length insert listed before a replacement at the same
17
- * offset (test/io/edits.test.js keeps the old algorithm as its oracle).
18
- *
19
- * 🔴 The disjointness is ASSERTED, not assumed. It used to be argued in a
20
- * comment ("two edits can never overlap — distinct anchors, distinct ranges"),
21
- * which stopped being true the moment geometry joined text: a moved-AND-edited
22
- * box now produces TWO edits inside ONE anchor. They are genuinely disjoint
23
- * (measured 2026-08-29: `max(posOffsetH.end, posOffsetV.end, extent.end) <=
24
- * txbxContent.contentStart` for 7,990/7,990 corpus containers), but a
25
- * measurement is not a runtime guarantee — a duplicated id or a stale range
26
- * would silently corrupt the document, so the invariant is checked here.
27
- *
28
- * @param {Uint8Array} original
29
- * @param {{start: number, end: number, bytes: Uint8Array}[]} edits
30
- * @returns {Uint8Array} `original` itself when there is nothing to apply
31
- */
32
- export function applyEdits(original, edits) {
33
- if (edits.length === 0) {
34
- return original;
35
- }
36
- const sorted = [...edits].sort((a, b) => a.start - b.start);
37
- let total = original.length;
38
- for (let i = 0; i < sorted.length; i += 1) {
39
- const edit = sorted[i];
40
- if (!(edit.start >= 0 && edit.end >= edit.start && edit.end <= original.length)) {
41
- throw new Error(`splice: edit range [${edit.start}, ${edit.end}) is outside the document (${original.length} bytes)`);
42
- }
43
- if (i > 0) {
44
- const previous = sorted[i - 1];
45
- const duplicate = previous.start === edit.start && previous.end === edit.end;
46
- if (duplicate || previous.end > edit.start) {
47
- throw new Error(
48
- `splice: overlapping edits [${previous.start}, ${previous.end}) and [${edit.start}, ${edit.end}) — ` +
49
- "a duplicated dirty id or a stale range would corrupt the document",
50
- );
51
- }
52
- }
53
- total += edit.bytes.length - (edit.end - edit.start);
54
- }
55
- const out = new Uint8Array(total);
56
- let at = 0;
57
- let cursor = 0;
58
- for (const { start, end, bytes } of sorted) {
59
- out.set(original.subarray(cursor, start), at);
60
- at += start - cursor;
61
- out.set(bytes, at);
62
- at += bytes.length;
63
- cursor = end;
64
- }
65
- out.set(original.subarray(cursor), at);
66
- return out;
67
- }
1
+ // Byte-range edits over an original buffer, shared by the editor's export
2
+ // (export-splice.js) and the template fill engine (fill.js, ADR-0037).
3
+ // Moved from export-splice.js in FR2 so both writers apply edits through the
4
+ // same validated pass.
5
+
6
+ /**
7
+ * Applies `edits` to `original` and returns the new bytes.
8
+ *
9
+ * ONE list, ONE pass, and every edit's `[start, end)` addresses `original` —
10
+ * never a partially rebuilt buffer. The output is allocated once at its final
11
+ * length and assembled front to back: the original's bytes between edits, then
12
+ * each edit's own bytes. Until FR2 the edits were applied from the highest
13
+ * offset down, re-concatenating the WHOLE buffer per edit — O(edits × size),
14
+ * which the template fill engine (hundreds of edits on a 1.75 MB document.xml)
15
+ * could not afford. The result is the same for every accepted edit list,
16
+ * including a zero-length insert listed before a replacement at the same
17
+ * offset (test/io/edits.test.js keeps the old algorithm as its oracle).
18
+ *
19
+ * 🔴 The disjointness is ASSERTED, not assumed. It used to be argued in a
20
+ * comment ("two edits can never overlap — distinct anchors, distinct ranges"),
21
+ * which stopped being true the moment geometry joined text: a moved-AND-edited
22
+ * box now produces TWO edits inside ONE anchor. They are genuinely disjoint
23
+ * (measured 2026-08-29: `max(posOffsetH.end, posOffsetV.end, extent.end) <=
24
+ * txbxContent.contentStart` for 7,990/7,990 corpus containers), but a
25
+ * measurement is not a runtime guarantee — a duplicated id or a stale range
26
+ * would silently corrupt the document, so the invariant is checked here.
27
+ *
28
+ * @param {Uint8Array} original
29
+ * @param {{start: number, end: number, bytes: Uint8Array}[]} edits
30
+ * @returns {Uint8Array} `original` itself when there is nothing to apply
31
+ */
32
+ export function applyEdits(original, edits) {
33
+ if (edits.length === 0) {
34
+ return original;
35
+ }
36
+ const sorted = [...edits].sort((a, b) => a.start - b.start);
37
+ let total = original.length;
38
+ for (let i = 0; i < sorted.length; i += 1) {
39
+ const edit = sorted[i];
40
+ if (!(edit.start >= 0 && edit.end >= edit.start && edit.end <= original.length)) {
41
+ throw new Error(`splice: edit range [${edit.start}, ${edit.end}) is outside the document (${original.length} bytes)`);
42
+ }
43
+ if (i > 0) {
44
+ const previous = sorted[i - 1];
45
+ const duplicate = previous.start === edit.start && previous.end === edit.end;
46
+ if (duplicate || previous.end > edit.start) {
47
+ throw new Error(
48
+ `splice: overlapping edits [${previous.start}, ${previous.end}) and [${edit.start}, ${edit.end}) — ` +
49
+ "a duplicated dirty id or a stale range would corrupt the document",
50
+ );
51
+ }
52
+ }
53
+ total += edit.bytes.length - (edit.end - edit.start);
54
+ }
55
+ const out = new Uint8Array(total);
56
+ let at = 0;
57
+ let cursor = 0;
58
+ for (const { start, end, bytes } of sorted) {
59
+ out.set(original.subarray(cursor, start), at);
60
+ at += start - cursor;
61
+ out.set(bytes, at);
62
+ at += bytes.length;
63
+ cursor = end;
64
+ }
65
+ out.set(original.subarray(cursor), at);
66
+ return out;
67
+ }