@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,691 +1,691 @@
1
- // Uint8Array (a .docx file's bytes) -> a real Doc. Ported field-for-field
2
- // from scripts/docx-to-canvas-json.py's convert() (ADR-0020 §2's now-retired
3
- // differential parity gate enforced this — see Karar 9's 2026-08-27 update).
4
- // Piece six (2026-08-27) closed the gaps Python's own port had carried
5
- // silently: a missing posOffset/extent/graphicData, an unparseable group or
6
- // group-child transform, or an unsupported a:graphicData kind now REJECTS
7
- // the document (import-gate.js) instead of a bare `continue` — all measured
8
- // 0/8,737 in the real corpus, so this changes nothing for it, only for a
9
- // corpus-external document that used to lose content with no signal at all.
10
- // A text box with zero non-empty runs is a RUN-LESS text box, not a
11
- // separate class (ADR-0021, 2026-08-27): imported as an editable Container
12
- // like any other, its id surfaced in report.emptyContainers so a consumer
13
- // can still flag it as an unfilled form field. Only a decorative shape with
14
- // neither a custGeom path nor a fill is now reported-not-modeled
15
- // (report.emptyShapes) — nothing was mis-placed there, there is nothing to
16
- // place. wps:bodyPr is still never read; paragraph/run formatting is still
17
- // bold/italic/underline only — extending past those remains a later piece's
18
- // job.
19
- //
20
- // The one impure file in src/io/ooxml/ that wires the pure zip/scan/convert
21
- // layers together (Karar 8) — DecompressionStream lives in inflate.js, which
22
- // this file calls, but this file itself touches no platform global directly.
23
-
24
- import { DOC_DEFAULT_STYLE_REF } from "../../model/node.js";
25
- import { parseZipContainer, resolveMainDocumentPart, resolveStylesPart } from "./zip-container.js";
26
- import { inflateEntry } from "./inflate.js";
27
- import { scanXml, getAttr, findChild, findChildren } from "./xml-scan.js";
28
- import { readSections, pageForAnchor } from "./sections.js";
29
- import { collectAnchors, parseXfrm, paragraphsOf } from "./drawingml.js";
30
- import {
31
- checkGroupIdentity,
32
- isShapeLocked,
33
- rejectAnchorPlacement,
34
- rejectMissingGraphicData,
35
- rejectMissingGroupTransform,
36
- rejectMissingShapeTransform,
37
- rejectUnsupportedGraphicDataKind,
38
- } from "./import-gate.js";
39
- import { decorationPaths } from "./custgeom.js";
40
- import { createAnchorRanges, recordAnchorRange, createPropBlobs, recordPropBlob } from "./provenance.js";
41
- import { loadStyles, resolve, readPPr, readRPr } from "./styles-resolve.js";
42
- import { W_NS, WP_NS, A_NS, WPS_NS, WPG_NS, PIC_NS, R_NS } from "./ns.js";
43
- import { parseRelationships } from "./relationships.js";
44
- import { parseNumbering, createNumberingCounter, DEFAULT_BULLET } from "./numbering.js";
45
- import { emuToPt } from "./units.js";
46
- import { createDoc, createContainer, createParagraph, createRun } from "../../model/node.js";
47
- import { createIdFactory } from "../../model/ids.js";
48
-
49
- // EMU <-> pt lives in units.js — ONE copy, shared with the geometry export's
50
- // write direction (`ptToEmu`), so the two directions cannot drift apart.
51
-
52
- /**
53
- * @param {import("./xml-scan.js").XmlElement} el
54
- * @returns {string}
55
- */
56
- function textOf(el) {
57
- let text = "";
58
- for (const child of el.children) {
59
- if (child.kind === "text" || child.kind === "cdata") text += child.value;
60
- }
61
- return text;
62
- }
63
-
64
- /**
65
- * @param {import("./xml-scan.js").XmlElement} picEl
66
- * @returns {string | null}
67
- */
68
- function picName(picEl) {
69
- const nvPicPr = findChild(picEl, PIC_NS, "nvPicPr");
70
- const cNvPr = nvPicPr && findChild(nvPicPr, PIC_NS, "cNvPr");
71
- return cNvPr ? (getAttr(cNvPr, "", "name") ?? null) : null;
72
- }
73
-
74
- /**
75
- * A picture's `a:blip r:embed` relationship id (`pic:blipFill/a:blip/@r:embed`) —
76
- * the only link from a `pic:pic` to its bytes — or null when absent.
77
- * @param {import("./xml-scan.js").XmlElement} picEl
78
- * @returns {string | null}
79
- */
80
- function picEmbedId(picEl) {
81
- const blipFill = findChild(picEl, PIC_NS, "blipFill");
82
- const blip = blipFill && findChild(blipFill, A_NS, "blip");
83
- return blip ? (getAttr(blip, R_NS, "embed") ?? null) : null;
84
- }
85
-
86
- /**
87
- * The `word/media/*` part an embed id resolves to, or null (no id, or the id is
88
- * not in this document's relationships).
89
- * @param {string | null} embedId
90
- * @param {Map<string, string>} relsMap
91
- * @returns {string | null}
92
- */
93
- function resolveMedia(embedId, relsMap) {
94
- if (embedId === null) return null;
95
- return relsMap.get(embedId) ?? null;
96
- }
97
-
98
- /**
99
- * @param {import("./xml-scan.js").XmlElement | undefined} spPr
100
- * @returns {string | null}
101
- */
102
- function solidFillHex(spPr) {
103
- if (!spPr) return null;
104
- const fill = findChild(spPr, A_NS, "solidFill");
105
- if (!fill) return null;
106
- const clr = findChild(fill, A_NS, "srgbClr");
107
- return clr ? (getAttr(clr, "", "val") ?? null) : null;
108
- }
109
-
110
- /**
111
- * @typedef {object} SkippedImage
112
- * @property {number} anchorIndex
113
- * @property {string} context
114
- * @property {string | null} name
115
- * @property {"PRESERVED" | "DEGRADED"} fidelity ADR-0020 Karar 6: PRESERVED
116
- * if this image's own anchor produced no container (untouched anchor,
117
- * byte-identical on export — §3.5); DEGRADED if the SAME anchor also
118
- * produced at least one editable container — touching that container
119
- * marks the whole anchor `dirty`, and P6 (not built yet) would silently
120
- * drop this image when it regenerates the anchor's XML, since no image
121
- * render branch exists to re-emit it (Karar 6: "model dalı AÇILMIYOR")
122
- * @property {number[]} sharedContainerIds container ids from the SAME
123
- * anchor, if any — empty for PRESERVED, non-empty for DEGRADED
124
- * @property {{start: number, end: number}} anchorRange the OWN anchor's
125
- * byte range in document.xml (§3.5's provenance unit — the same range
126
- * `anchorRanges` records for a container/decoration from this anchor)
127
- */
128
-
129
- /**
130
- * What this report's numbers are, and are not, verified against — same
131
- * pattern as `src/measure/controller.js`'s own `typographySource` (ADR-0019
132
- * Karar 8): a result is not empty, but its basis can still be incomplete,
133
- * and hiding that is the same class of error as silently reporting zero.
134
- * Fixed per importer version, not computed per document — these are gaps in
135
- * WHAT THE IMPORTER READS, not facts about any one .docx. Update this list
136
- * the moment one of these is closed (piece six closed the five silent-skip
137
- * items that used to belong here — this is what's left).
138
- * @type {readonly string[]}
139
- */
140
- const UNVERIFIED_ASSUMPTIONS = [
141
- "wps:bodyPr is not read: inset/autofit/text-anchor are assumed absent (verified only across the reference corpus, ADR-0020 DOĞRULANMALI #25 — not checked against this specific document)",
142
- "a:prstGeom and wps:style theme fill/line references (a:fillRef/a:lnRef) are not read — a decorative shape's fill may be incomplete",
143
- "run formatting is bold/italic/underline plus Doc.styles resolution only — no other direct run properties are read",
144
- "page assignment (pageForAnchor's section-boundary count) is not cross-checked against this document's external ground truth — ADR-0020 Karar 3(c): that cross-check exists only as offline corpus tooling (corpus:pages), which this document has no recorded value in",
145
- "numPr rendering (numbering.js): a bullet level whose numbering.xml lvlText is a font-mapped Private Use Area codepoint (measured 387/4,239 real corpus bullet levels, e.g. Wingdings U+F076) falls back to a default bullet character, not the true glyph — the font that maps it is never loaded. Ordinal levels (decimal/lowerLetter/lowerRoman/upperLetter, 27/4,266 real corpus levels) DO get a real running count now (createNumberingCounter, document-order, multi-level restart on a shallower same-numId level) — w:lvlOverride and w:lvlRestart are not implemented (0/474 real corpus w:num entries carry either), and w:numId reuse across genuinely unrelated lists (if any exist beyond this corpus) is not distinguished from one continuous list",
146
- ];
147
-
148
- /**
149
- * @typedef {object} ImportReport
150
- * @property {string[]} unsupportedCommands
151
- * @property {{containerId: number, name: string | null, rot: number, flipH: boolean, flipV: boolean}[]} lockedShapes
152
- * @property {SkippedImage[]} skippedImages
153
- * @property {EmptyShape[]} emptyShapes
154
- * @property {number[]} emptyContainers container ids that were built from a
155
- * text box carrying zero non-empty runs — an unfilled form field
156
- * (2,482/7,990 real corpus text boxes). ADR-0021: these ARE modeled now,
157
- * editable like any container; this list only lets a consumer surface them
158
- * as fill targets, it is NOT a "skipped" list.
159
- * @property {readonly string[]} unverifiedAssumptions
160
- */
161
-
162
- /**
163
- * A DECORATIVE shape with no visible content at all — ADR-0020 §1's
164
- * silent-skip audit (2026-08-27): a `continue` dropped these with no trace
165
- * before. A decoration with neither a `custGeom` path nor a fill has nothing
166
- * to render (0/2,697 real corpus decorative shapes today — this arm is
167
- * written but unwitnessed by the corpus). Reported, not rejected — nothing
168
- * was mis-placed, there was simply nothing to place. (An empty TEXT box is
169
- * NOT here: ADR-0021 imports it as an editable Container, surfaced instead in
170
- * report.emptyContainers — `kind` stays a union for forward compatibility,
171
- * but only "decoration" is produced today.)
172
- * @typedef {object} EmptyShape
173
- * @property {number} anchorIndex
174
- * @property {"text" | "decoration"} kind
175
- * @property {string | null} name
176
- */
177
-
178
- /**
179
- * @typedef {object} ConvertResult
180
- * @property {import("../../model/node.js").Doc} doc
181
- * @property {import("../../model/ids.js").IdFactory} ids seeded past every
182
- * id this import minted (paragraphs, containers, decorations) — same
183
- * contract as `document-json.js`'s `fromJSON`, so an imported Doc is
184
- * immediately usable with `CanvasController` (paste, etc.) without the
185
- * caller re-deriving a starting id by walking the tree itself
186
- * @property {import("./provenance.js").AnchorRanges} anchorRanges
187
- * @property {ImportReport} report
188
- * @property {import("./provenance.js").Provenance} provenance §3.5 retention
189
- * for P6 export: the untouched bytes to splice/repack from, plus the byte
190
- * ranges the model only references by id/PropRef. `convertDocument` fills
191
- * the document-level fields; `importDocx` adds `packageBytes`/`entries`.
192
- * Additive — existing consumers ({doc, ids, anchorRanges, report}) unaffected.
193
- */
194
-
195
- /**
196
- * The pure half: an already-scanned document.xml tree (+ its raw bytes, for
197
- * PropRef content interning) and an already-scanned styles.xml tree -> a
198
- * real Doc. Split out from `importDocx` so tests can exercise this directly
199
- * against hand-built XML (via `scanXml`) without wrapping every fixture in a
200
- * real zip container — `importDocx` below is the thin, impure zip/inflate/
201
- * scan wrapper around this.
202
- * @param {import("./xml-scan.js").XmlElement} root
203
- * @param {Uint8Array} xmlBytes
204
- * @param {import("./xml-scan.js").XmlElement} stylesRoot
205
- * @param {Map<string, string>} [relsMap]
206
- * @param {import("./xml-scan.js").XmlElement | null} [numberingRoot] word/numbering.xml's
207
- * root, if the package carries one (30/46 real corpus documents) — optional,
208
- * a numPr paragraph in a document with no numbering.xml still renders (the
209
- * default-bullet fallback, numbering.js's resolveMarker)
210
- * @returns {ConvertResult}
211
- */
212
- export function convertDocument(root, xmlBytes, stylesRoot, relsMap = new Map(), numberingRoot = null) {
213
- const { page, sectPrStarts } = readSections(root);
214
- const anchors = collectAnchors(root);
215
- const sx = loadStyles(stylesRoot);
216
- // One counter instance for the WHOLE document (numbering.js's own doc
217
- // comment): a list's "next item" is a document-order concept, not a
218
- // per-container/per-page one — measured against real Word, a list
219
- // continues numbering across a container/page boundary. `numDefs` empty
220
- // (no numbering.xml, or an unresolved numId/ilvl) makes every `next()`
221
- // call return undefined uniformly — the DEFAULT_BULLET fallback below
222
- // does not need a separate "no numbering.xml" branch.
223
- const numberingCounter = createNumberingCounter(
224
- numberingRoot !== null ? parseNumbering(numberingRoot) : new Map(),
225
- );
226
- const decoder = new TextDecoder("utf-8");
227
-
228
- // PropRef interning (ADR-0020 Karar 7): pPrRef by the pPr byte slice's own
229
- // CONTENT (which already includes any w:pStyle child, so a paragraph's own
230
- // declared style is baked into the key without needing it separately —
231
- // two paragraphs with byte-identical pPr always resolve identically
232
- // within one document, since defaultParagraphStyle is document-wide
233
- // constant). rPrRef by (effective styleId, rPr byte content) — a run's own
234
- // rPr carries no reference to its enclosing paragraph's style, so that
235
- // context must be part of the key explicitly, unlike pPrRef.
236
- /** @type {Map<string, string>} */
237
- const pPrRefByKey = new Map();
238
- let nextPPrRefN = 1;
239
- /** @type {Map<string, string>} */
240
- const rPrRefByKey = new Map();
241
- let nextRPrRefN = 1;
242
- /** @type {import("../../model/node.js").StyleTable} */
243
- const styleTable = {};
244
- // ADR-0028: the document's own docDefaults, under a sentinel ref no real
245
- // interned pPrRef/rPrRef can ever collide with (those are always
246
- // `p${n}`/`r${n}`, above). commands/size.js's effectiveSize falls back to
247
- // this ONLY when a run carries neither a direct marks.sz override nor an
248
- // rPrRef at all — a freshly typed paragraph, not an imported one (every
249
- // real corpus run already carries an rPrRef whose OWN chain already
250
- // resolves through docDefaults via `resolve()`, measured 81,973/81,973).
251
- // BOTH halves under the one sentinel: `readRPr` never emits a pPr key and
252
- // `readPPr` never emits an rPr key, so the two flat records cannot collide
253
- // (`sz` comes only from rPr, `jc`/`ind.*`/`spacing.*` only from pPr).
254
- // ADR-0029 EKLEME A — and this dal is NOT dead code: measured 46/46 real
255
- // corpus documents declare an explicit docDefaults `jc` (all "left").
256
- styleTable[DOC_DEFAULT_STYLE_REF] = { ...sx.docDefaults.rPr, ...sx.docDefaults.pPr };
257
-
258
- /**
259
- * @param {import("./xml-scan.js").XmlElement | undefined} el
260
- * @returns {string}
261
- */
262
- function contentOf(el) {
263
- return el ? decoder.decode(xmlBytes.subarray(el.start, el.end)) : "";
264
- }
265
-
266
- /**
267
- * @param {Record<string, import("./styles-resolve.js").ResolvedValue>} eff
268
- * @returns {Record<string, string>}
269
- */
270
- function flattenEffective(eff) {
271
- /** @type {Record<string, string>} */
272
- const out = {};
273
- for (const [f, [v]] of Object.entries(eff)) out[f] = v;
274
- return out;
275
- }
276
-
277
- /**
278
- * @param {string | undefined} styleId
279
- * @param {import("./xml-scan.js").XmlElement | undefined} pPrEl
280
- * @returns {string}
281
- */
282
- function internPPrRef(styleId, pPrEl) {
283
- const key = contentOf(pPrEl);
284
- const cached = pPrRefByKey.get(key);
285
- if (cached !== undefined) {
286
- // A hit here is only ever a NON-numPr paragraph — see below, a numPr
287
- // paragraph's ref is never written into this cache in the first place.
288
- return cached;
289
- }
290
- // numId/ilvl computed AFTER the resolve() cascade (not straight off
291
- // readPPr's direct output) so a numId inherited from a paragraph STYLE
292
- // (not just the paragraph's own direct pPr) is honored too — same
293
- // override rule jc/ind already follow.
294
- const props = flattenEffective(resolve(sx.docDefaults.pPr, sx.styles, styleId, readPPr(pPrEl), "pPr"));
295
- const ref = `p${nextPPrRefN++}`;
296
- if (props["numPr.numId"] !== undefined) {
297
- // NEVER interned, even though this exact pPrEl's bytes may recur:
298
- // numberingCounter.next() must advance once per PARAGRAPH OCCURRENCE
299
- // (numbering.js), but consecutive items in the same list commonly
300
- // carry byte-identical pPr XML (same numId/ilvl/jc/ind) — caching by
301
- // byte content, as the non-numPr path below still correctly does,
302
- // would freeze every one of them onto whichever marker the FIRST
303
- // occurrence computed (the exact bug this rebuild closes).
304
- const resolved = numberingCounter.next(props["numPr.numId"], props["numPr.ilvl"] ?? "0");
305
- // Not resolved (missing numbering.xml, orphan numId/ilvl, or a format
306
- // numbering.js could not map) -> DEFAULT_BULLET, never guessed, same
307
- // fallback numbering.js itself uses. Flagged as a capability gap in
308
- // UNVERIFIED_ASSUMPTIONS below, not per-document.
309
- props["numPr.marker"] = resolved?.marker ?? DEFAULT_BULLET;
310
- styleTable[ref] = props;
311
- return ref;
312
- }
313
- pPrRefByKey.set(key, ref);
314
- styleTable[ref] = props;
315
- return ref;
316
- }
317
-
318
- /**
319
- * @param {string | undefined} styleId
320
- * @param {import("./xml-scan.js").XmlElement | undefined} rPrEl
321
- * @returns {string}
322
- */
323
- function internRPrRef(styleId, rPrEl) {
324
- const key = `${styleId ?? ""}\u0000${contentOf(rPrEl)}`;
325
- let ref = rPrRefByKey.get(key);
326
- if (ref === undefined) {
327
- ref = `r${nextRPrRefN++}`;
328
- rPrRefByKey.set(key, ref);
329
- styleTable[ref] = flattenEffective(resolve(sx.docDefaults.rPr, sx.styles, styleId, readRPr(rPrEl), "rPr"));
330
- }
331
- return ref;
332
- }
333
-
334
- let nextId = 2; // 1 is the document itself — IdFactory(2) parity
335
- /** @type {import("../../model/node.js").Container[]} */
336
- const containers = [];
337
- /** @type {import("../../model/node.js").LayoutMap} */
338
- const layout = {};
339
- /** @type {import("../../model/node.js").Decoration[]} */
340
- const decorations = [];
341
- /** @type {import("../../model/node.js").DocImage[]} */
342
- const images = [];
343
- const anchorRanges = createAnchorRanges();
344
- const propBlobs = createPropBlobs();
345
- /** @type {Set<string>} */
346
- const unsupportedCommands = new Set();
347
- /** @type {ImportReport["lockedShapes"]} */
348
- const lockedShapes = [];
349
- /** @type {ImportReport["skippedImages"]} */
350
- const skippedImages = [];
351
- /** @type {ImportReport["emptyShapes"]} */
352
- const emptyShapes = [];
353
- /** @type {ImportReport["emptyContainers"]} */
354
- const emptyContainers = [];
355
-
356
- for (let anchorIndex = 0; anchorIndex < anchors.length; anchorIndex++) {
357
- const anchor = anchors[anchorIndex];
358
- const pageNumber = pageForAnchor(sectPrStarts, anchor.start, page.count);
359
-
360
- const positionH = findChild(anchor, WP_NS, "positionH");
361
- const posOffsetH = positionH && findChild(positionH, WP_NS, "posOffset");
362
- const positionV = findChild(anchor, WP_NS, "positionV");
363
- const posOffsetV = positionV && findChild(positionV, WP_NS, "posOffset");
364
- const extent = findChild(anchor, WP_NS, "extent");
365
- if (!posOffsetH || !posOffsetV || !extent) {
366
- rejectAnchorPlacement(!posOffsetH, !posOffsetV, !extent, `anchor ${anchorIndex}`);
367
- }
368
-
369
- const anchorX = Number.parseInt(textOf(posOffsetH) || "0", 10);
370
- const anchorY = Number.parseInt(textOf(posOffsetV) || "0", 10);
371
- const z = Number.parseInt(getAttr(anchor, "", "relativeHeight") ?? "0", 10);
372
-
373
- const graphic = findChild(anchor, A_NS, "graphic");
374
- const graphicData = graphic && findChild(graphic, A_NS, "graphicData");
375
- if (!graphicData) rejectMissingGraphicData(`anchor ${anchorIndex}`);
376
-
377
- /** @type {{el: import("./xml-scan.js").XmlElement, x: number, y: number, cx: number, cy: number, ownXfrm: {rot: number, flipH: boolean, flipV: boolean}}[]} */
378
- const shapes = [];
379
- // Every container this SAME anchor produces, and every pic:pic this same
380
- // anchor contains — collected during this anchor's own processing below,
381
- // then reconciled once (after the shapes loop) so each image's fidelity
382
- // bucket reflects ALL of this anchor's containers, not just whichever
383
- // were created before the image happened to be encountered.
384
- /** @type {number[]} */
385
- const containerIdsThisAnchor = [];
386
- /** @type {{context: string, name: string | null, embedId: string | null, geom: {x: number, y: number, cx: number, cy: number} | null}[]} */
387
- const pendingImagesThisAnchor = [];
388
-
389
- const group = findChild(graphicData, WPG_NS, "wgp");
390
- if (group) {
391
- const gXfrm = parseXfrm(findChild(group, WPG_NS, "grpSpPr"));
392
- if (!gXfrm) rejectMissingGroupTransform(`anchor ${anchorIndex} group`);
393
- const gCnvPr = findChild(group, WPG_NS, "cNvPr");
394
- const gId = gCnvPr ? (getAttr(gCnvPr, "", "id") ?? "?") : "?";
395
- const gName = gCnvPr ? (getAttr(gCnvPr, "", "name") ?? "?") : "?";
396
- const { scaleX, scaleY } = checkGroupIdentity(
397
- gXfrm,
398
- group,
399
- `anchor ${anchorIndex} group id=${gId} name=${JSON.stringify(gName)}`,
400
- );
401
-
402
- for (const pic of findChildren(group, PIC_NS, "pic")) {
403
- // Group-child placement uses the SAME transform a wsp sibling does
404
- // (the group is identity, checkGroupIdentity above). A pic with no
405
- // xfrm has no computable placement -> geom null -> report-only skip.
406
- const pXfrm = parseXfrm(findChild(pic, PIC_NS, "spPr"));
407
- pendingImagesThisAnchor.push({
408
- context: `group id=${gId} name=${JSON.stringify(gName)}`,
409
- name: picName(pic),
410
- embedId: picEmbedId(pic),
411
- geom: pXfrm === null ? null : {
412
- x: anchorX + (pXfrm.offX - gXfrm.chOffX) * scaleX,
413
- y: anchorY + (pXfrm.offY - gXfrm.chOffY) * scaleY,
414
- cx: pXfrm.extCx * scaleX,
415
- cy: pXfrm.extCy * scaleY,
416
- },
417
- });
418
- }
419
- for (const child of findChildren(group, WPS_NS, "wsp")) {
420
- const cXfrm = parseXfrm(findChild(child, WPS_NS, "spPr"));
421
- if (!cXfrm) rejectMissingShapeTransform(`anchor ${anchorIndex} group id=${gId} name=${JSON.stringify(gName)}`);
422
- shapes.push({
423
- el: child,
424
- x: anchorX + (cXfrm.offX - gXfrm.chOffX) * scaleX,
425
- y: anchorY + (cXfrm.offY - gXfrm.chOffY) * scaleY,
426
- cx: cXfrm.extCx * scaleX,
427
- cy: cXfrm.extCy * scaleY,
428
- ownXfrm: cXfrm,
429
- });
430
- }
431
- } else {
432
- const wsp = findChild(graphicData, WPS_NS, "wsp");
433
- if (!wsp) {
434
- const pic = findChild(graphicData, PIC_NS, "pic");
435
- if (!pic) rejectUnsupportedGraphicDataKind(`anchor ${anchorIndex}`);
436
- // A non-grouped anchor's graphicData holds EITHER wps:wsp OR pic:pic
437
- // (this branch is only reached when wsp was absent) — it structurally
438
- // cannot ALSO have produced a container from this same anchor, so it is
439
- // always PRESERVED. Placement is the anchor's own posOffset + extent.
440
- // Rendered (Doc.images) when its bytes resolve; otherwise report-only.
441
- const mediaPartName = resolveMedia(picEmbedId(pic), relsMap);
442
- if (mediaPartName !== null) {
443
- images.push({
444
- id: nextId++,
445
- page: pageNumber,
446
- x: emuToPt(anchorX),
447
- y: emuToPt(anchorY),
448
- cx: emuToPt(Number.parseInt(getAttr(extent, "", "cx") ?? "0", 10)),
449
- cy: emuToPt(Number.parseInt(getAttr(extent, "", "cy") ?? "0", 10)),
450
- z,
451
- mediaPartName,
452
- });
453
- } else {
454
- skippedImages.push({
455
- anchorIndex,
456
- context: "top-level (ungrouped)",
457
- name: picName(pic),
458
- fidelity: "PRESERVED",
459
- sharedContainerIds: [],
460
- anchorRange: { start: anchor.start, end: anchor.end },
461
- });
462
- }
463
- continue;
464
- }
465
- const ownXfrm = parseXfrm(findChild(wsp, WPS_NS, "spPr")) ?? { rot: 0, flipH: false, flipV: false };
466
- shapes.push({
467
- el: wsp,
468
- x: anchorX,
469
- y: anchorY,
470
- cx: Number.parseInt(getAttr(extent, "", "cx") ?? "0", 10),
471
- cy: Number.parseInt(getAttr(extent, "", "cy") ?? "0", 10),
472
- ownXfrm,
473
- });
474
- }
475
-
476
- for (const { el: wsp, x, y, cx, cy, ownXfrm } of shapes) {
477
- const spPr = findChild(wsp, WPS_NS, "spPr");
478
- const txbxWrap = findChild(wsp, WPS_NS, "txbx");
479
- const txbx = txbxWrap && findChild(txbxWrap, W_NS, "txbxContent");
480
-
481
- const shapeRot = ownXfrm.rot ?? 0;
482
- const shapeFlipH = ownXfrm.flipH ?? false;
483
- const shapeFlipV = ownXfrm.flipV ?? false;
484
- const locked = isShapeLocked({ rot: shapeRot, flipH: shapeFlipH, flipV: shapeFlipV });
485
-
486
- if (txbx) {
487
- const parsedParagraphs = paragraphsOf(txbx);
488
- const paragraphIds = [];
489
- // (ref, element) pairs for this container's pPr/rPr byte slices (ADR-0017
490
- // opaque blobs). Collected here because the byte ranges are in hand at the
491
- // intern sites, but recorded into `propBlobs` only after `containerId` is
492
- // minted below — the map runs before that id exists.
493
- /** @type {{ref: string, el: import("./xml-scan.js").XmlElement}[]} */
494
- const propBlobEls = [];
495
- const paragraphs = parsedParagraphs.map((p) => {
496
- const id = nextId++;
497
- paragraphIds.push(id);
498
- const styleId = p.pStyleId ?? sx.defaultParagraphStyle ?? undefined;
499
- const pPrRef = internPPrRef(styleId, p.pPrEl);
500
- if (p.pPrEl) propBlobEls.push({ ref: pPrRef, el: p.pPrEl });
501
- return createParagraph(
502
- id,
503
- p.runs.map((r) => {
504
- const rPrRef = internRPrRef(styleId, r.rPrEl);
505
- if (r.rPrEl) propBlobEls.push({ ref: rPrRef, el: r.rPrEl });
506
- return createRun(r.text, { ...r.marks, rPrRef });
507
- }),
508
- { pPrRef },
509
- );
510
- });
511
- // ADR-0021 (2026-08-27): a text box with zero non-empty runs is a
512
- // RUN-LESS text box, not a separate class — M7 (commit 8bcc087)
513
- // measured every supposed "empty box" signature on all 5,508 FILLED
514
- // boxes too, so none of them distinguishes it. It becomes an editable
515
- // Container like any other; normalize.js keeps an empty paragraph's
516
- // single empty run and render/dom.js prints a <br> filler, so the
517
- // model and renderer already accept this (a filled box with a blank
518
- // paragraph produces the same zero-run Paragraph today). Its id is
519
- // surfaced in emptyContainers so a consumer can still flag it as an
520
- // unfilled form field — the very reason these documents are opened —
521
- // but it is NOT skipped: it is clickable and editable now.
522
- const hasAnyRuns = parsedParagraphs.some((p) => p.runs.length > 0);
523
-
524
- const containerId = nextId++;
525
- containers.push(createContainer(containerId, paragraphs));
526
- recordAnchorRange(anchorRanges, containerId, anchor);
527
- for (const { ref, el } of propBlobEls) recordPropBlob(propBlobs, containerId, ref, el);
528
- containerIdsThisAnchor.push(containerId);
529
- if (!hasAnyRuns) emptyContainers.push(containerId);
530
-
531
- /** @type {import("../../model/node.js").BoxLayout} */
532
- const layoutEntry = { page: pageNumber, x: emuToPt(x), y: emuToPt(y), cx: emuToPt(cx), cy: emuToPt(cy), z };
533
- if (locked) {
534
- layoutEntry.rot = shapeRot;
535
- layoutEntry.flipH = shapeFlipH;
536
- layoutEntry.flipV = shapeFlipV;
537
- layoutEntry.locked = true;
538
- const cnvpr = findChild(wsp, WPS_NS, "cNvPr");
539
- lockedShapes.push({
540
- containerId,
541
- name: cnvpr ? (getAttr(cnvpr, "", "name") ?? null) : null,
542
- rot: shapeRot,
543
- flipH: shapeFlipH,
544
- flipV: shapeFlipV,
545
- });
546
- }
547
- layout[containerId] = layoutEntry;
548
- continue;
549
- }
550
-
551
- const { paths, unsupported } = decorationPaths(spPr);
552
- for (const u of unsupported) unsupportedCommands.add(u);
553
- const fillHex = solidFillHex(spPr);
554
- if (paths.length === 0 && fillHex === null) {
555
- // Symmetric to the text-box case above — a decorative shape with
556
- // neither a custGeom path nor a direct fill renders as nothing.
557
- // Measured 2026-08-27: 0/2,697 real corpus decorative shapes
558
- // trigger this (kural değil enstrüman until the corpus witnesses
559
- // one — see OOXML mutation registry).
560
- const cnvpr = findChild(wsp, WPS_NS, "cNvPr");
561
- emptyShapes.push({ anchorIndex, kind: "decoration", name: cnvpr ? (getAttr(cnvpr, "", "name") ?? null) : null });
562
- continue;
563
- }
564
-
565
- const decorationId = nextId++;
566
- decorations.push({
567
- id: decorationId,
568
- page: pageNumber,
569
- x: emuToPt(x),
570
- y: emuToPt(y),
571
- cx: emuToPt(cx),
572
- cy: emuToPt(cy),
573
- z,
574
- fillHex,
575
- paths,
576
- });
577
- recordAnchorRange(anchorRanges, decorationId, anchor);
578
- }
579
-
580
- // Reconciled once per anchor, after every shape (and so every container
581
- // this anchor could produce) has been processed — ADR-0020 Karar 6: an
582
- // image is DEGRADED exactly when its OWN anchor also produced at least
583
- // one container (an editable text box); touching that container marks
584
- // the whole anchor dirty for a future P6 export, which has no image
585
- // render branch to re-emit this picture with. Measured 2026-08-27
586
- // (46/46 real corpus documents): 0/466 images are DEGRADED under this
587
- // rule — every wps:wsp sibling of every pic:pic in this corpus is a
588
- // decorative shape (no txbx element at all), never a text box. The rule
589
- // is still implemented for the general case (a corpus-external document,
590
- // or this app someday allowing decorative-shape edits, could trigger it).
591
- const anchorIsDegraded = containerIdsThisAnchor.length > 0;
592
- for (const pending of pendingImagesThisAnchor) {
593
- const mediaPartName = resolveMedia(pending.embedId, relsMap);
594
- // Rendered (Doc.images) only when PRESERVED and BOTH bytes and placement
595
- // resolve. DEGRADED (shares an anchor with an editable container — 0/466
596
- // in the corpus) or an unresolvable embed/geometry stays a report-only
597
- // skip, keeping ADR-0020 Karar 6's at-risk signal for a future export.
598
- if (!anchorIsDegraded && mediaPartName !== null && pending.geom !== null) {
599
- images.push({
600
- id: nextId++,
601
- page: pageNumber,
602
- x: emuToPt(pending.geom.x),
603
- y: emuToPt(pending.geom.y),
604
- cx: emuToPt(pending.geom.cx),
605
- cy: emuToPt(pending.geom.cy),
606
- z,
607
- mediaPartName,
608
- });
609
- } else {
610
- skippedImages.push({
611
- anchorIndex,
612
- context: pending.context,
613
- name: pending.name,
614
- fidelity: anchorIsDegraded ? "DEGRADED" : "PRESERVED",
615
- sharedContainerIds: [...containerIdsThisAnchor],
616
- anchorRange: { start: anchor.start, end: anchor.end },
617
- });
618
- }
619
- }
620
- }
621
-
622
- const doc = createDoc(1, containers, layout, { page, decorations, images, styles: styleTable });
623
-
624
- return {
625
- doc,
626
- ids: createIdFactory(nextId),
627
- anchorRanges,
628
- report: {
629
- unsupportedCommands: [...unsupportedCommands].sort(),
630
- lockedShapes,
631
- skippedImages,
632
- emptyShapes,
633
- emptyContainers,
634
- unverifiedAssumptions: UNVERIFIED_ASSUMPTIONS,
635
- },
636
- // §3.5 retention (document-level half — `importDocx` adds packageBytes/entries).
637
- // documentXmlBytes is the exact inflated word/document.xml piece 3 splices;
638
- // dirty starts empty (pieces 4b/5 fill it).
639
- provenance: { documentXmlBytes: xmlBytes, anchorRanges, propBlobs, dirty: new Set() },
640
- };
641
- }
642
-
643
- /**
644
- * The relationships (`Id -> resolved part name`) for the main document part.
645
- * Its rels part is `<dir>/_rels/<name>.rels` beside the part; an absent one (a
646
- * document with no relationships at all) yields an empty map. Targets resolve
647
- * against the part's own directory (`<dir>`), the OPC rule.
648
- * @param {Uint8Array} bytes
649
- * @param {import("./zip-container.js").ZipEntry[]} entries
650
- * @param {string} mainPartName e.g. `"word/document.xml"`
651
- * @returns {Promise<Map<string, string>>}
652
- */
653
- async function loadRelationships(bytes, entries, mainPartName) {
654
- const slash = mainPartName.lastIndexOf("/");
655
- const dir = slash === -1 ? "" : mainPartName.slice(0, slash);
656
- const name = slash === -1 ? mainPartName : mainPartName.slice(slash + 1);
657
- const relsName = `${dir === "" ? "" : `${dir}/`}_rels/${name}.rels`;
658
- const relsEntry = entries.find((e) => e.name === relsName);
659
- if (relsEntry === undefined) return new Map();
660
- const relsBytes = await inflateEntry(bytes, relsEntry);
661
- const { root } = scanXml(relsBytes);
662
- return parseRelationships(root, dir);
663
- }
664
-
665
- /**
666
- * @param {Uint8Array} bytes a whole .docx file's bytes
667
- * @returns {Promise<ConvertResult>}
668
- */
669
- export async function importDocx(bytes) {
670
- const entries = parseZipContainer(bytes);
671
- const mainPart = resolveMainDocumentPart(entries);
672
- const stylesPart = resolveStylesPart(entries);
673
- const xmlBytes = await inflateEntry(bytes, mainPart);
674
- const stylesBytes = await inflateEntry(bytes, stylesPart);
675
- const { root } = scanXml(xmlBytes);
676
- const { root: stylesRoot } = scanXml(stylesBytes);
677
- const relsMap = await loadRelationships(bytes, entries, mainPart.name);
678
- // word/numbering.xml is OPTIONAL (30/46 real corpus documents carry it) —
679
- // a fixed path lookup like resolveStylesPart's, but tolerant of absence
680
- // rather than throwing: a numPr paragraph in a document with no
681
- // numbering.xml still renders (numbering.js's default-bullet fallback).
682
- const numberingPart = entries.find((e) => e.name === "word/numbering.xml");
683
- const numberingRoot =
684
- numberingPart !== undefined ? scanXml(await inflateEntry(bytes, numberingPart)).root : null;
685
- const result = convertDocument(root, xmlBytes, stylesRoot, relsMap, numberingRoot);
686
- // Retain the original OPC package (§3.5 step 1) so piece 2 (repack) can
687
- // byte-copy every untouched part verbatim — convertDocument only had the
688
- // inflated document.xml. `bytes` is the caller's array (a reference, not a
689
- // copy); `entries` is re-derivable from it but kept to avoid a re-parse.
690
- return { ...result, provenance: { ...result.provenance, packageBytes: bytes, entries } };
691
- }
1
+ // Uint8Array (a .docx file's bytes) -> a real Doc. Ported field-for-field
2
+ // from scripts/docx-to-canvas-json.py's convert() (ADR-0020 §2's now-retired
3
+ // differential parity gate enforced this — see Karar 9's 2026-08-27 update).
4
+ // Piece six (2026-08-27) closed the gaps Python's own port had carried
5
+ // silently: a missing posOffset/extent/graphicData, an unparseable group or
6
+ // group-child transform, or an unsupported a:graphicData kind now REJECTS
7
+ // the document (import-gate.js) instead of a bare `continue` — all measured
8
+ // 0/8,737 in the real corpus, so this changes nothing for it, only for a
9
+ // corpus-external document that used to lose content with no signal at all.
10
+ // A text box with zero non-empty runs is a RUN-LESS text box, not a
11
+ // separate class (ADR-0021, 2026-08-27): imported as an editable Container
12
+ // like any other, its id surfaced in report.emptyContainers so a consumer
13
+ // can still flag it as an unfilled form field. Only a decorative shape with
14
+ // neither a custGeom path nor a fill is now reported-not-modeled
15
+ // (report.emptyShapes) — nothing was mis-placed there, there is nothing to
16
+ // place. wps:bodyPr is still never read; paragraph/run formatting is still
17
+ // bold/italic/underline only — extending past those remains a later piece's
18
+ // job.
19
+ //
20
+ // The one impure file in src/io/ooxml/ that wires the pure zip/scan/convert
21
+ // layers together (Karar 8) — DecompressionStream lives in inflate.js, which
22
+ // this file calls, but this file itself touches no platform global directly.
23
+
24
+ import { DOC_DEFAULT_STYLE_REF } from "../../model/node.js";
25
+ import { parseZipContainer, resolveMainDocumentPart, resolveStylesPart } from "./zip-container.js";
26
+ import { inflateEntry } from "./inflate.js";
27
+ import { scanXml, getAttr, findChild, findChildren } from "./xml-scan.js";
28
+ import { readSections, pageForAnchor } from "./sections.js";
29
+ import { collectAnchors, parseXfrm, paragraphsOf } from "./drawingml.js";
30
+ import {
31
+ checkGroupIdentity,
32
+ isShapeLocked,
33
+ rejectAnchorPlacement,
34
+ rejectMissingGraphicData,
35
+ rejectMissingGroupTransform,
36
+ rejectMissingShapeTransform,
37
+ rejectUnsupportedGraphicDataKind,
38
+ } from "./import-gate.js";
39
+ import { decorationPaths } from "./custgeom.js";
40
+ import { createAnchorRanges, recordAnchorRange, createPropBlobs, recordPropBlob } from "./provenance.js";
41
+ import { loadStyles, resolve, readPPr, readRPr } from "./styles-resolve.js";
42
+ import { W_NS, WP_NS, A_NS, WPS_NS, WPG_NS, PIC_NS, R_NS } from "./ns.js";
43
+ import { parseRelationships } from "./relationships.js";
44
+ import { parseNumbering, createNumberingCounter, DEFAULT_BULLET } from "./numbering.js";
45
+ import { emuToPt } from "./units.js";
46
+ import { createDoc, createContainer, createParagraph, createRun } from "../../model/node.js";
47
+ import { createIdFactory } from "../../model/ids.js";
48
+
49
+ // EMU <-> pt lives in units.js — ONE copy, shared with the geometry export's
50
+ // write direction (`ptToEmu`), so the two directions cannot drift apart.
51
+
52
+ /**
53
+ * @param {import("./xml-scan.js").XmlElement} el
54
+ * @returns {string}
55
+ */
56
+ function textOf(el) {
57
+ let text = "";
58
+ for (const child of el.children) {
59
+ if (child.kind === "text" || child.kind === "cdata") text += child.value;
60
+ }
61
+ return text;
62
+ }
63
+
64
+ /**
65
+ * @param {import("./xml-scan.js").XmlElement} picEl
66
+ * @returns {string | null}
67
+ */
68
+ function picName(picEl) {
69
+ const nvPicPr = findChild(picEl, PIC_NS, "nvPicPr");
70
+ const cNvPr = nvPicPr && findChild(nvPicPr, PIC_NS, "cNvPr");
71
+ return cNvPr ? (getAttr(cNvPr, "", "name") ?? null) : null;
72
+ }
73
+
74
+ /**
75
+ * A picture's `a:blip r:embed` relationship id (`pic:blipFill/a:blip/@r:embed`) —
76
+ * the only link from a `pic:pic` to its bytes — or null when absent.
77
+ * @param {import("./xml-scan.js").XmlElement} picEl
78
+ * @returns {string | null}
79
+ */
80
+ function picEmbedId(picEl) {
81
+ const blipFill = findChild(picEl, PIC_NS, "blipFill");
82
+ const blip = blipFill && findChild(blipFill, A_NS, "blip");
83
+ return blip ? (getAttr(blip, R_NS, "embed") ?? null) : null;
84
+ }
85
+
86
+ /**
87
+ * The `word/media/*` part an embed id resolves to, or null (no id, or the id is
88
+ * not in this document's relationships).
89
+ * @param {string | null} embedId
90
+ * @param {Map<string, string>} relsMap
91
+ * @returns {string | null}
92
+ */
93
+ function resolveMedia(embedId, relsMap) {
94
+ if (embedId === null) return null;
95
+ return relsMap.get(embedId) ?? null;
96
+ }
97
+
98
+ /**
99
+ * @param {import("./xml-scan.js").XmlElement | undefined} spPr
100
+ * @returns {string | null}
101
+ */
102
+ function solidFillHex(spPr) {
103
+ if (!spPr) return null;
104
+ const fill = findChild(spPr, A_NS, "solidFill");
105
+ if (!fill) return null;
106
+ const clr = findChild(fill, A_NS, "srgbClr");
107
+ return clr ? (getAttr(clr, "", "val") ?? null) : null;
108
+ }
109
+
110
+ /**
111
+ * @typedef {object} SkippedImage
112
+ * @property {number} anchorIndex
113
+ * @property {string} context
114
+ * @property {string | null} name
115
+ * @property {"PRESERVED" | "DEGRADED"} fidelity ADR-0020 Karar 6: PRESERVED
116
+ * if this image's own anchor produced no container (untouched anchor,
117
+ * byte-identical on export — §3.5); DEGRADED if the SAME anchor also
118
+ * produced at least one editable container — touching that container
119
+ * marks the whole anchor `dirty`, and P6 (not built yet) would silently
120
+ * drop this image when it regenerates the anchor's XML, since no image
121
+ * render branch exists to re-emit it (Karar 6: "model dalı AÇILMIYOR")
122
+ * @property {number[]} sharedContainerIds container ids from the SAME
123
+ * anchor, if any — empty for PRESERVED, non-empty for DEGRADED
124
+ * @property {{start: number, end: number}} anchorRange the OWN anchor's
125
+ * byte range in document.xml (§3.5's provenance unit — the same range
126
+ * `anchorRanges` records for a container/decoration from this anchor)
127
+ */
128
+
129
+ /**
130
+ * What this report's numbers are, and are not, verified against — same
131
+ * pattern as `src/measure/controller.js`'s own `typographySource` (ADR-0019
132
+ * Karar 8): a result is not empty, but its basis can still be incomplete,
133
+ * and hiding that is the same class of error as silently reporting zero.
134
+ * Fixed per importer version, not computed per document — these are gaps in
135
+ * WHAT THE IMPORTER READS, not facts about any one .docx. Update this list
136
+ * the moment one of these is closed (piece six closed the five silent-skip
137
+ * items that used to belong here — this is what's left).
138
+ * @type {readonly string[]}
139
+ */
140
+ const UNVERIFIED_ASSUMPTIONS = [
141
+ "wps:bodyPr is not read: inset/autofit/text-anchor are assumed absent (verified only across the reference corpus, ADR-0020 DOĞRULANMALI #25 — not checked against this specific document)",
142
+ "a:prstGeom and wps:style theme fill/line references (a:fillRef/a:lnRef) are not read — a decorative shape's fill may be incomplete",
143
+ "run formatting is bold/italic/underline plus Doc.styles resolution only — no other direct run properties are read",
144
+ "page assignment (pageForAnchor's section-boundary count) is not cross-checked against this document's external ground truth — ADR-0020 Karar 3(c): that cross-check exists only as offline corpus tooling (corpus:pages), which this document has no recorded value in",
145
+ "numPr rendering (numbering.js): a bullet level whose numbering.xml lvlText is a font-mapped Private Use Area codepoint (measured 387/4,239 real corpus bullet levels, e.g. Wingdings U+F076) falls back to a default bullet character, not the true glyph — the font that maps it is never loaded. Ordinal levels (decimal/lowerLetter/lowerRoman/upperLetter, 27/4,266 real corpus levels) DO get a real running count now (createNumberingCounter, document-order, multi-level restart on a shallower same-numId level) — w:lvlOverride and w:lvlRestart are not implemented (0/474 real corpus w:num entries carry either), and w:numId reuse across genuinely unrelated lists (if any exist beyond this corpus) is not distinguished from one continuous list",
146
+ ];
147
+
148
+ /**
149
+ * @typedef {object} ImportReport
150
+ * @property {string[]} unsupportedCommands
151
+ * @property {{containerId: number, name: string | null, rot: number, flipH: boolean, flipV: boolean}[]} lockedShapes
152
+ * @property {SkippedImage[]} skippedImages
153
+ * @property {EmptyShape[]} emptyShapes
154
+ * @property {number[]} emptyContainers container ids that were built from a
155
+ * text box carrying zero non-empty runs — an unfilled form field
156
+ * (2,482/7,990 real corpus text boxes). ADR-0021: these ARE modeled now,
157
+ * editable like any container; this list only lets a consumer surface them
158
+ * as fill targets, it is NOT a "skipped" list.
159
+ * @property {readonly string[]} unverifiedAssumptions
160
+ */
161
+
162
+ /**
163
+ * A DECORATIVE shape with no visible content at all — ADR-0020 §1's
164
+ * silent-skip audit (2026-08-27): a `continue` dropped these with no trace
165
+ * before. A decoration with neither a `custGeom` path nor a fill has nothing
166
+ * to render (0/2,697 real corpus decorative shapes today — this arm is
167
+ * written but unwitnessed by the corpus). Reported, not rejected — nothing
168
+ * was mis-placed, there was simply nothing to place. (An empty TEXT box is
169
+ * NOT here: ADR-0021 imports it as an editable Container, surfaced instead in
170
+ * report.emptyContainers — `kind` stays a union for forward compatibility,
171
+ * but only "decoration" is produced today.)
172
+ * @typedef {object} EmptyShape
173
+ * @property {number} anchorIndex
174
+ * @property {"text" | "decoration"} kind
175
+ * @property {string | null} name
176
+ */
177
+
178
+ /**
179
+ * @typedef {object} ConvertResult
180
+ * @property {import("../../model/node.js").Doc} doc
181
+ * @property {import("../../model/ids.js").IdFactory} ids seeded past every
182
+ * id this import minted (paragraphs, containers, decorations) — same
183
+ * contract as `document-json.js`'s `fromJSON`, so an imported Doc is
184
+ * immediately usable with `CanvasController` (paste, etc.) without the
185
+ * caller re-deriving a starting id by walking the tree itself
186
+ * @property {import("./provenance.js").AnchorRanges} anchorRanges
187
+ * @property {ImportReport} report
188
+ * @property {import("./provenance.js").Provenance} provenance §3.5 retention
189
+ * for P6 export: the untouched bytes to splice/repack from, plus the byte
190
+ * ranges the model only references by id/PropRef. `convertDocument` fills
191
+ * the document-level fields; `importDocx` adds `packageBytes`/`entries`.
192
+ * Additive — existing consumers ({doc, ids, anchorRanges, report}) unaffected.
193
+ */
194
+
195
+ /**
196
+ * The pure half: an already-scanned document.xml tree (+ its raw bytes, for
197
+ * PropRef content interning) and an already-scanned styles.xml tree -> a
198
+ * real Doc. Split out from `importDocx` so tests can exercise this directly
199
+ * against hand-built XML (via `scanXml`) without wrapping every fixture in a
200
+ * real zip container — `importDocx` below is the thin, impure zip/inflate/
201
+ * scan wrapper around this.
202
+ * @param {import("./xml-scan.js").XmlElement} root
203
+ * @param {Uint8Array} xmlBytes
204
+ * @param {import("./xml-scan.js").XmlElement} stylesRoot
205
+ * @param {Map<string, string>} [relsMap]
206
+ * @param {import("./xml-scan.js").XmlElement | null} [numberingRoot] word/numbering.xml's
207
+ * root, if the package carries one (30/46 real corpus documents) — optional,
208
+ * a numPr paragraph in a document with no numbering.xml still renders (the
209
+ * default-bullet fallback, numbering.js's resolveMarker)
210
+ * @returns {ConvertResult}
211
+ */
212
+ export function convertDocument(root, xmlBytes, stylesRoot, relsMap = new Map(), numberingRoot = null) {
213
+ const { page, sectPrStarts } = readSections(root);
214
+ const anchors = collectAnchors(root);
215
+ const sx = loadStyles(stylesRoot);
216
+ // One counter instance for the WHOLE document (numbering.js's own doc
217
+ // comment): a list's "next item" is a document-order concept, not a
218
+ // per-container/per-page one — measured against real Word, a list
219
+ // continues numbering across a container/page boundary. `numDefs` empty
220
+ // (no numbering.xml, or an unresolved numId/ilvl) makes every `next()`
221
+ // call return undefined uniformly — the DEFAULT_BULLET fallback below
222
+ // does not need a separate "no numbering.xml" branch.
223
+ const numberingCounter = createNumberingCounter(
224
+ numberingRoot !== null ? parseNumbering(numberingRoot) : new Map(),
225
+ );
226
+ const decoder = new TextDecoder("utf-8");
227
+
228
+ // PropRef interning (ADR-0020 Karar 7): pPrRef by the pPr byte slice's own
229
+ // CONTENT (which already includes any w:pStyle child, so a paragraph's own
230
+ // declared style is baked into the key without needing it separately —
231
+ // two paragraphs with byte-identical pPr always resolve identically
232
+ // within one document, since defaultParagraphStyle is document-wide
233
+ // constant). rPrRef by (effective styleId, rPr byte content) — a run's own
234
+ // rPr carries no reference to its enclosing paragraph's style, so that
235
+ // context must be part of the key explicitly, unlike pPrRef.
236
+ /** @type {Map<string, string>} */
237
+ const pPrRefByKey = new Map();
238
+ let nextPPrRefN = 1;
239
+ /** @type {Map<string, string>} */
240
+ const rPrRefByKey = new Map();
241
+ let nextRPrRefN = 1;
242
+ /** @type {import("../../model/node.js").StyleTable} */
243
+ const styleTable = {};
244
+ // ADR-0028: the document's own docDefaults, under a sentinel ref no real
245
+ // interned pPrRef/rPrRef can ever collide with (those are always
246
+ // `p${n}`/`r${n}`, above). commands/size.js's effectiveSize falls back to
247
+ // this ONLY when a run carries neither a direct marks.sz override nor an
248
+ // rPrRef at all — a freshly typed paragraph, not an imported one (every
249
+ // real corpus run already carries an rPrRef whose OWN chain already
250
+ // resolves through docDefaults via `resolve()`, measured 81,973/81,973).
251
+ // BOTH halves under the one sentinel: `readRPr` never emits a pPr key and
252
+ // `readPPr` never emits an rPr key, so the two flat records cannot collide
253
+ // (`sz` comes only from rPr, `jc`/`ind.*`/`spacing.*` only from pPr).
254
+ // ADR-0029 EKLEME A — and this dal is NOT dead code: measured 46/46 real
255
+ // corpus documents declare an explicit docDefaults `jc` (all "left").
256
+ styleTable[DOC_DEFAULT_STYLE_REF] = { ...sx.docDefaults.rPr, ...sx.docDefaults.pPr };
257
+
258
+ /**
259
+ * @param {import("./xml-scan.js").XmlElement | undefined} el
260
+ * @returns {string}
261
+ */
262
+ function contentOf(el) {
263
+ return el ? decoder.decode(xmlBytes.subarray(el.start, el.end)) : "";
264
+ }
265
+
266
+ /**
267
+ * @param {Record<string, import("./styles-resolve.js").ResolvedValue>} eff
268
+ * @returns {Record<string, string>}
269
+ */
270
+ function flattenEffective(eff) {
271
+ /** @type {Record<string, string>} */
272
+ const out = {};
273
+ for (const [f, [v]] of Object.entries(eff)) out[f] = v;
274
+ return out;
275
+ }
276
+
277
+ /**
278
+ * @param {string | undefined} styleId
279
+ * @param {import("./xml-scan.js").XmlElement | undefined} pPrEl
280
+ * @returns {string}
281
+ */
282
+ function internPPrRef(styleId, pPrEl) {
283
+ const key = contentOf(pPrEl);
284
+ const cached = pPrRefByKey.get(key);
285
+ if (cached !== undefined) {
286
+ // A hit here is only ever a NON-numPr paragraph — see below, a numPr
287
+ // paragraph's ref is never written into this cache in the first place.
288
+ return cached;
289
+ }
290
+ // numId/ilvl computed AFTER the resolve() cascade (not straight off
291
+ // readPPr's direct output) so a numId inherited from a paragraph STYLE
292
+ // (not just the paragraph's own direct pPr) is honored too — same
293
+ // override rule jc/ind already follow.
294
+ const props = flattenEffective(resolve(sx.docDefaults.pPr, sx.styles, styleId, readPPr(pPrEl), "pPr"));
295
+ const ref = `p${nextPPrRefN++}`;
296
+ if (props["numPr.numId"] !== undefined) {
297
+ // NEVER interned, even though this exact pPrEl's bytes may recur:
298
+ // numberingCounter.next() must advance once per PARAGRAPH OCCURRENCE
299
+ // (numbering.js), but consecutive items in the same list commonly
300
+ // carry byte-identical pPr XML (same numId/ilvl/jc/ind) — caching by
301
+ // byte content, as the non-numPr path below still correctly does,
302
+ // would freeze every one of them onto whichever marker the FIRST
303
+ // occurrence computed (the exact bug this rebuild closes).
304
+ const resolved = numberingCounter.next(props["numPr.numId"], props["numPr.ilvl"] ?? "0");
305
+ // Not resolved (missing numbering.xml, orphan numId/ilvl, or a format
306
+ // numbering.js could not map) -> DEFAULT_BULLET, never guessed, same
307
+ // fallback numbering.js itself uses. Flagged as a capability gap in
308
+ // UNVERIFIED_ASSUMPTIONS below, not per-document.
309
+ props["numPr.marker"] = resolved?.marker ?? DEFAULT_BULLET;
310
+ styleTable[ref] = props;
311
+ return ref;
312
+ }
313
+ pPrRefByKey.set(key, ref);
314
+ styleTable[ref] = props;
315
+ return ref;
316
+ }
317
+
318
+ /**
319
+ * @param {string | undefined} styleId
320
+ * @param {import("./xml-scan.js").XmlElement | undefined} rPrEl
321
+ * @returns {string}
322
+ */
323
+ function internRPrRef(styleId, rPrEl) {
324
+ const key = `${styleId ?? ""}\u0000${contentOf(rPrEl)}`;
325
+ let ref = rPrRefByKey.get(key);
326
+ if (ref === undefined) {
327
+ ref = `r${nextRPrRefN++}`;
328
+ rPrRefByKey.set(key, ref);
329
+ styleTable[ref] = flattenEffective(resolve(sx.docDefaults.rPr, sx.styles, styleId, readRPr(rPrEl), "rPr"));
330
+ }
331
+ return ref;
332
+ }
333
+
334
+ let nextId = 2; // 1 is the document itself — IdFactory(2) parity
335
+ /** @type {import("../../model/node.js").Container[]} */
336
+ const containers = [];
337
+ /** @type {import("../../model/node.js").LayoutMap} */
338
+ const layout = {};
339
+ /** @type {import("../../model/node.js").Decoration[]} */
340
+ const decorations = [];
341
+ /** @type {import("../../model/node.js").DocImage[]} */
342
+ const images = [];
343
+ const anchorRanges = createAnchorRanges();
344
+ const propBlobs = createPropBlobs();
345
+ /** @type {Set<string>} */
346
+ const unsupportedCommands = new Set();
347
+ /** @type {ImportReport["lockedShapes"]} */
348
+ const lockedShapes = [];
349
+ /** @type {ImportReport["skippedImages"]} */
350
+ const skippedImages = [];
351
+ /** @type {ImportReport["emptyShapes"]} */
352
+ const emptyShapes = [];
353
+ /** @type {ImportReport["emptyContainers"]} */
354
+ const emptyContainers = [];
355
+
356
+ for (let anchorIndex = 0; anchorIndex < anchors.length; anchorIndex++) {
357
+ const anchor = anchors[anchorIndex];
358
+ const pageNumber = pageForAnchor(sectPrStarts, anchor.start, page.count);
359
+
360
+ const positionH = findChild(anchor, WP_NS, "positionH");
361
+ const posOffsetH = positionH && findChild(positionH, WP_NS, "posOffset");
362
+ const positionV = findChild(anchor, WP_NS, "positionV");
363
+ const posOffsetV = positionV && findChild(positionV, WP_NS, "posOffset");
364
+ const extent = findChild(anchor, WP_NS, "extent");
365
+ if (!posOffsetH || !posOffsetV || !extent) {
366
+ rejectAnchorPlacement(!posOffsetH, !posOffsetV, !extent, `anchor ${anchorIndex}`);
367
+ }
368
+
369
+ const anchorX = Number.parseInt(textOf(posOffsetH) || "0", 10);
370
+ const anchorY = Number.parseInt(textOf(posOffsetV) || "0", 10);
371
+ const z = Number.parseInt(getAttr(anchor, "", "relativeHeight") ?? "0", 10);
372
+
373
+ const graphic = findChild(anchor, A_NS, "graphic");
374
+ const graphicData = graphic && findChild(graphic, A_NS, "graphicData");
375
+ if (!graphicData) rejectMissingGraphicData(`anchor ${anchorIndex}`);
376
+
377
+ /** @type {{el: import("./xml-scan.js").XmlElement, x: number, y: number, cx: number, cy: number, ownXfrm: {rot: number, flipH: boolean, flipV: boolean}}[]} */
378
+ const shapes = [];
379
+ // Every container this SAME anchor produces, and every pic:pic this same
380
+ // anchor contains — collected during this anchor's own processing below,
381
+ // then reconciled once (after the shapes loop) so each image's fidelity
382
+ // bucket reflects ALL of this anchor's containers, not just whichever
383
+ // were created before the image happened to be encountered.
384
+ /** @type {number[]} */
385
+ const containerIdsThisAnchor = [];
386
+ /** @type {{context: string, name: string | null, embedId: string | null, geom: {x: number, y: number, cx: number, cy: number} | null}[]} */
387
+ const pendingImagesThisAnchor = [];
388
+
389
+ const group = findChild(graphicData, WPG_NS, "wgp");
390
+ if (group) {
391
+ const gXfrm = parseXfrm(findChild(group, WPG_NS, "grpSpPr"));
392
+ if (!gXfrm) rejectMissingGroupTransform(`anchor ${anchorIndex} group`);
393
+ const gCnvPr = findChild(group, WPG_NS, "cNvPr");
394
+ const gId = gCnvPr ? (getAttr(gCnvPr, "", "id") ?? "?") : "?";
395
+ const gName = gCnvPr ? (getAttr(gCnvPr, "", "name") ?? "?") : "?";
396
+ const { scaleX, scaleY } = checkGroupIdentity(
397
+ gXfrm,
398
+ group,
399
+ `anchor ${anchorIndex} group id=${gId} name=${JSON.stringify(gName)}`,
400
+ );
401
+
402
+ for (const pic of findChildren(group, PIC_NS, "pic")) {
403
+ // Group-child placement uses the SAME transform a wsp sibling does
404
+ // (the group is identity, checkGroupIdentity above). A pic with no
405
+ // xfrm has no computable placement -> geom null -> report-only skip.
406
+ const pXfrm = parseXfrm(findChild(pic, PIC_NS, "spPr"));
407
+ pendingImagesThisAnchor.push({
408
+ context: `group id=${gId} name=${JSON.stringify(gName)}`,
409
+ name: picName(pic),
410
+ embedId: picEmbedId(pic),
411
+ geom: pXfrm === null ? null : {
412
+ x: anchorX + (pXfrm.offX - gXfrm.chOffX) * scaleX,
413
+ y: anchorY + (pXfrm.offY - gXfrm.chOffY) * scaleY,
414
+ cx: pXfrm.extCx * scaleX,
415
+ cy: pXfrm.extCy * scaleY,
416
+ },
417
+ });
418
+ }
419
+ for (const child of findChildren(group, WPS_NS, "wsp")) {
420
+ const cXfrm = parseXfrm(findChild(child, WPS_NS, "spPr"));
421
+ if (!cXfrm) rejectMissingShapeTransform(`anchor ${anchorIndex} group id=${gId} name=${JSON.stringify(gName)}`);
422
+ shapes.push({
423
+ el: child,
424
+ x: anchorX + (cXfrm.offX - gXfrm.chOffX) * scaleX,
425
+ y: anchorY + (cXfrm.offY - gXfrm.chOffY) * scaleY,
426
+ cx: cXfrm.extCx * scaleX,
427
+ cy: cXfrm.extCy * scaleY,
428
+ ownXfrm: cXfrm,
429
+ });
430
+ }
431
+ } else {
432
+ const wsp = findChild(graphicData, WPS_NS, "wsp");
433
+ if (!wsp) {
434
+ const pic = findChild(graphicData, PIC_NS, "pic");
435
+ if (!pic) rejectUnsupportedGraphicDataKind(`anchor ${anchorIndex}`);
436
+ // A non-grouped anchor's graphicData holds EITHER wps:wsp OR pic:pic
437
+ // (this branch is only reached when wsp was absent) — it structurally
438
+ // cannot ALSO have produced a container from this same anchor, so it is
439
+ // always PRESERVED. Placement is the anchor's own posOffset + extent.
440
+ // Rendered (Doc.images) when its bytes resolve; otherwise report-only.
441
+ const mediaPartName = resolveMedia(picEmbedId(pic), relsMap);
442
+ if (mediaPartName !== null) {
443
+ images.push({
444
+ id: nextId++,
445
+ page: pageNumber,
446
+ x: emuToPt(anchorX),
447
+ y: emuToPt(anchorY),
448
+ cx: emuToPt(Number.parseInt(getAttr(extent, "", "cx") ?? "0", 10)),
449
+ cy: emuToPt(Number.parseInt(getAttr(extent, "", "cy") ?? "0", 10)),
450
+ z,
451
+ mediaPartName,
452
+ });
453
+ } else {
454
+ skippedImages.push({
455
+ anchorIndex,
456
+ context: "top-level (ungrouped)",
457
+ name: picName(pic),
458
+ fidelity: "PRESERVED",
459
+ sharedContainerIds: [],
460
+ anchorRange: { start: anchor.start, end: anchor.end },
461
+ });
462
+ }
463
+ continue;
464
+ }
465
+ const ownXfrm = parseXfrm(findChild(wsp, WPS_NS, "spPr")) ?? { rot: 0, flipH: false, flipV: false };
466
+ shapes.push({
467
+ el: wsp,
468
+ x: anchorX,
469
+ y: anchorY,
470
+ cx: Number.parseInt(getAttr(extent, "", "cx") ?? "0", 10),
471
+ cy: Number.parseInt(getAttr(extent, "", "cy") ?? "0", 10),
472
+ ownXfrm,
473
+ });
474
+ }
475
+
476
+ for (const { el: wsp, x, y, cx, cy, ownXfrm } of shapes) {
477
+ const spPr = findChild(wsp, WPS_NS, "spPr");
478
+ const txbxWrap = findChild(wsp, WPS_NS, "txbx");
479
+ const txbx = txbxWrap && findChild(txbxWrap, W_NS, "txbxContent");
480
+
481
+ const shapeRot = ownXfrm.rot ?? 0;
482
+ const shapeFlipH = ownXfrm.flipH ?? false;
483
+ const shapeFlipV = ownXfrm.flipV ?? false;
484
+ const locked = isShapeLocked({ rot: shapeRot, flipH: shapeFlipH, flipV: shapeFlipV });
485
+
486
+ if (txbx) {
487
+ const parsedParagraphs = paragraphsOf(txbx);
488
+ const paragraphIds = [];
489
+ // (ref, element) pairs for this container's pPr/rPr byte slices (ADR-0017
490
+ // opaque blobs). Collected here because the byte ranges are in hand at the
491
+ // intern sites, but recorded into `propBlobs` only after `containerId` is
492
+ // minted below — the map runs before that id exists.
493
+ /** @type {{ref: string, el: import("./xml-scan.js").XmlElement}[]} */
494
+ const propBlobEls = [];
495
+ const paragraphs = parsedParagraphs.map((p) => {
496
+ const id = nextId++;
497
+ paragraphIds.push(id);
498
+ const styleId = p.pStyleId ?? sx.defaultParagraphStyle ?? undefined;
499
+ const pPrRef = internPPrRef(styleId, p.pPrEl);
500
+ if (p.pPrEl) propBlobEls.push({ ref: pPrRef, el: p.pPrEl });
501
+ return createParagraph(
502
+ id,
503
+ p.runs.map((r) => {
504
+ const rPrRef = internRPrRef(styleId, r.rPrEl);
505
+ if (r.rPrEl) propBlobEls.push({ ref: rPrRef, el: r.rPrEl });
506
+ return createRun(r.text, { ...r.marks, rPrRef });
507
+ }),
508
+ { pPrRef },
509
+ );
510
+ });
511
+ // ADR-0021 (2026-08-27): a text box with zero non-empty runs is a
512
+ // RUN-LESS text box, not a separate class — M7 (commit 8bcc087)
513
+ // measured every supposed "empty box" signature on all 5,508 FILLED
514
+ // boxes too, so none of them distinguishes it. It becomes an editable
515
+ // Container like any other; normalize.js keeps an empty paragraph's
516
+ // single empty run and render/dom.js prints a <br> filler, so the
517
+ // model and renderer already accept this (a filled box with a blank
518
+ // paragraph produces the same zero-run Paragraph today). Its id is
519
+ // surfaced in emptyContainers so a consumer can still flag it as an
520
+ // unfilled form field — the very reason these documents are opened —
521
+ // but it is NOT skipped: it is clickable and editable now.
522
+ const hasAnyRuns = parsedParagraphs.some((p) => p.runs.length > 0);
523
+
524
+ const containerId = nextId++;
525
+ containers.push(createContainer(containerId, paragraphs));
526
+ recordAnchorRange(anchorRanges, containerId, anchor);
527
+ for (const { ref, el } of propBlobEls) recordPropBlob(propBlobs, containerId, ref, el);
528
+ containerIdsThisAnchor.push(containerId);
529
+ if (!hasAnyRuns) emptyContainers.push(containerId);
530
+
531
+ /** @type {import("../../model/node.js").BoxLayout} */
532
+ const layoutEntry = { page: pageNumber, x: emuToPt(x), y: emuToPt(y), cx: emuToPt(cx), cy: emuToPt(cy), z };
533
+ if (locked) {
534
+ layoutEntry.rot = shapeRot;
535
+ layoutEntry.flipH = shapeFlipH;
536
+ layoutEntry.flipV = shapeFlipV;
537
+ layoutEntry.locked = true;
538
+ const cnvpr = findChild(wsp, WPS_NS, "cNvPr");
539
+ lockedShapes.push({
540
+ containerId,
541
+ name: cnvpr ? (getAttr(cnvpr, "", "name") ?? null) : null,
542
+ rot: shapeRot,
543
+ flipH: shapeFlipH,
544
+ flipV: shapeFlipV,
545
+ });
546
+ }
547
+ layout[containerId] = layoutEntry;
548
+ continue;
549
+ }
550
+
551
+ const { paths, unsupported } = decorationPaths(spPr);
552
+ for (const u of unsupported) unsupportedCommands.add(u);
553
+ const fillHex = solidFillHex(spPr);
554
+ if (paths.length === 0 && fillHex === null) {
555
+ // Symmetric to the text-box case above — a decorative shape with
556
+ // neither a custGeom path nor a direct fill renders as nothing.
557
+ // Measured 2026-08-27: 0/2,697 real corpus decorative shapes
558
+ // trigger this (kural değil enstrüman until the corpus witnesses
559
+ // one — see OOXML mutation registry).
560
+ const cnvpr = findChild(wsp, WPS_NS, "cNvPr");
561
+ emptyShapes.push({ anchorIndex, kind: "decoration", name: cnvpr ? (getAttr(cnvpr, "", "name") ?? null) : null });
562
+ continue;
563
+ }
564
+
565
+ const decorationId = nextId++;
566
+ decorations.push({
567
+ id: decorationId,
568
+ page: pageNumber,
569
+ x: emuToPt(x),
570
+ y: emuToPt(y),
571
+ cx: emuToPt(cx),
572
+ cy: emuToPt(cy),
573
+ z,
574
+ fillHex,
575
+ paths,
576
+ });
577
+ recordAnchorRange(anchorRanges, decorationId, anchor);
578
+ }
579
+
580
+ // Reconciled once per anchor, after every shape (and so every container
581
+ // this anchor could produce) has been processed — ADR-0020 Karar 6: an
582
+ // image is DEGRADED exactly when its OWN anchor also produced at least
583
+ // one container (an editable text box); touching that container marks
584
+ // the whole anchor dirty for a future P6 export, which has no image
585
+ // render branch to re-emit this picture with. Measured 2026-08-27
586
+ // (46/46 real corpus documents): 0/466 images are DEGRADED under this
587
+ // rule — every wps:wsp sibling of every pic:pic in this corpus is a
588
+ // decorative shape (no txbx element at all), never a text box. The rule
589
+ // is still implemented for the general case (a corpus-external document,
590
+ // or this app someday allowing decorative-shape edits, could trigger it).
591
+ const anchorIsDegraded = containerIdsThisAnchor.length > 0;
592
+ for (const pending of pendingImagesThisAnchor) {
593
+ const mediaPartName = resolveMedia(pending.embedId, relsMap);
594
+ // Rendered (Doc.images) only when PRESERVED and BOTH bytes and placement
595
+ // resolve. DEGRADED (shares an anchor with an editable container — 0/466
596
+ // in the corpus) or an unresolvable embed/geometry stays a report-only
597
+ // skip, keeping ADR-0020 Karar 6's at-risk signal for a future export.
598
+ if (!anchorIsDegraded && mediaPartName !== null && pending.geom !== null) {
599
+ images.push({
600
+ id: nextId++,
601
+ page: pageNumber,
602
+ x: emuToPt(pending.geom.x),
603
+ y: emuToPt(pending.geom.y),
604
+ cx: emuToPt(pending.geom.cx),
605
+ cy: emuToPt(pending.geom.cy),
606
+ z,
607
+ mediaPartName,
608
+ });
609
+ } else {
610
+ skippedImages.push({
611
+ anchorIndex,
612
+ context: pending.context,
613
+ name: pending.name,
614
+ fidelity: anchorIsDegraded ? "DEGRADED" : "PRESERVED",
615
+ sharedContainerIds: [...containerIdsThisAnchor],
616
+ anchorRange: { start: anchor.start, end: anchor.end },
617
+ });
618
+ }
619
+ }
620
+ }
621
+
622
+ const doc = createDoc(1, containers, layout, { page, decorations, images, styles: styleTable });
623
+
624
+ return {
625
+ doc,
626
+ ids: createIdFactory(nextId),
627
+ anchorRanges,
628
+ report: {
629
+ unsupportedCommands: [...unsupportedCommands].sort(),
630
+ lockedShapes,
631
+ skippedImages,
632
+ emptyShapes,
633
+ emptyContainers,
634
+ unverifiedAssumptions: UNVERIFIED_ASSUMPTIONS,
635
+ },
636
+ // §3.5 retention (document-level half — `importDocx` adds packageBytes/entries).
637
+ // documentXmlBytes is the exact inflated word/document.xml piece 3 splices;
638
+ // dirty starts empty (pieces 4b/5 fill it).
639
+ provenance: { documentXmlBytes: xmlBytes, anchorRanges, propBlobs, dirty: new Set() },
640
+ };
641
+ }
642
+
643
+ /**
644
+ * The relationships (`Id -> resolved part name`) for the main document part.
645
+ * Its rels part is `<dir>/_rels/<name>.rels` beside the part; an absent one (a
646
+ * document with no relationships at all) yields an empty map. Targets resolve
647
+ * against the part's own directory (`<dir>`), the OPC rule.
648
+ * @param {Uint8Array} bytes
649
+ * @param {import("./zip-container.js").ZipEntry[]} entries
650
+ * @param {string} mainPartName e.g. `"word/document.xml"`
651
+ * @returns {Promise<Map<string, string>>}
652
+ */
653
+ async function loadRelationships(bytes, entries, mainPartName) {
654
+ const slash = mainPartName.lastIndexOf("/");
655
+ const dir = slash === -1 ? "" : mainPartName.slice(0, slash);
656
+ const name = slash === -1 ? mainPartName : mainPartName.slice(slash + 1);
657
+ const relsName = `${dir === "" ? "" : `${dir}/`}_rels/${name}.rels`;
658
+ const relsEntry = entries.find((e) => e.name === relsName);
659
+ if (relsEntry === undefined) return new Map();
660
+ const relsBytes = await inflateEntry(bytes, relsEntry);
661
+ const { root } = scanXml(relsBytes);
662
+ return parseRelationships(root, dir);
663
+ }
664
+
665
+ /**
666
+ * @param {Uint8Array} bytes a whole .docx file's bytes
667
+ * @returns {Promise<ConvertResult>}
668
+ */
669
+ export async function importDocx(bytes) {
670
+ const entries = parseZipContainer(bytes);
671
+ const mainPart = resolveMainDocumentPart(entries);
672
+ const stylesPart = resolveStylesPart(entries);
673
+ const xmlBytes = await inflateEntry(bytes, mainPart);
674
+ const stylesBytes = await inflateEntry(bytes, stylesPart);
675
+ const { root } = scanXml(xmlBytes);
676
+ const { root: stylesRoot } = scanXml(stylesBytes);
677
+ const relsMap = await loadRelationships(bytes, entries, mainPart.name);
678
+ // word/numbering.xml is OPTIONAL (30/46 real corpus documents carry it) —
679
+ // a fixed path lookup like resolveStylesPart's, but tolerant of absence
680
+ // rather than throwing: a numPr paragraph in a document with no
681
+ // numbering.xml still renders (numbering.js's default-bullet fallback).
682
+ const numberingPart = entries.find((e) => e.name === "word/numbering.xml");
683
+ const numberingRoot =
684
+ numberingPart !== undefined ? scanXml(await inflateEntry(bytes, numberingPart)).root : null;
685
+ const result = convertDocument(root, xmlBytes, stylesRoot, relsMap, numberingRoot);
686
+ // Retain the original OPC package (§3.5 step 1) so piece 2 (repack) can
687
+ // byte-copy every untouched part verbatim — convertDocument only had the
688
+ // inflated document.xml. `bytes` is the caller's array (a reference, not a
689
+ // copy); `entries` is re-derivable from it but kept to avoid a re-parse.
690
+ return { ...result, provenance: { ...result.provenance, packageBytes: bytes, entries } };
691
+ }