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