@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,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
|
+
|
package/dist/io/ooxml/edits.js
CHANGED
|
@@ -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
|
+
}
|