@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/export.js
CHANGED
|
@@ -1,365 +1,365 @@
|
|
|
1
|
-
// P6 piece (3): export orchestration. The whole import -> edit -> export path
|
|
2
|
-
// in one call: which containers changed (dirty), splice each one's new
|
|
3
|
-
// txbxContent into the untouched document.xml (piece 4b), then repack into a
|
|
4
|
-
// valid .docx (piece 2). This is where the FIRST real .docx comes out.
|
|
5
|
-
//
|
|
6
|
-
// LIMITS (narrow, by design): plain-text only (no rPr/pPr — piece 7); existing
|
|
7
|
-
// containers' text changes only (no new anchor).
|
|
8
|
-
//
|
|
9
|
-
// DIRTY SOURCE (piece 5 / Dilim B, 2026-08-29): content dirty is the model's
|
|
10
|
-
// own structural sharing (identity inequality); geometry dirty is a VALUE
|
|
11
|
-
// compare of the layout branch (`dirtyGeometry`). Both stay the PRODUCTION
|
|
12
|
-
// source — a taşı-sonra-geri-al must export byte-identical, which only a
|
|
13
|
-
// value compare gives for free. `recordForwardDirty`/`verifyInverseDirty`
|
|
14
|
-
// below are the HISTORY-driven half ADR-0023 originally expected to be the
|
|
15
|
-
// dirty source itself; it turned out narrower and more useful as a
|
|
16
|
-
// TRACEABILITY layer: `recordForwardDirty` remembers which keys a forward
|
|
17
|
-
// setLayout named, `verifyInverseDirty` checks an undo's inverse step never
|
|
18
|
-
// names a key no forward step named — HIST7's own guarantee
|
|
19
|
-
// ("invertSetLayout restores only the patched keys"), made observable at the
|
|
20
|
-
// one point in the codebase that rule has a real consequence. A same-set
|
|
21
|
-
// check ("history exactly equals value-dirty") was tried and REJECTED,
|
|
22
|
-
// measured to false-positive on two healthy paths: `commands/box.js`'s own
|
|
23
|
-
// full-key patch (`{x, y}` for an x-only move, so drag coalescing keeps
|
|
24
|
-
// working) and a taşı-sonra-geri-al itself (history stays populated, value
|
|
25
|
-
// compare goes empty — §3.5's own guarantee, not a bug). `exportDocx`'s
|
|
26
|
-
// optional `historyDirty` gate is the third, complementary check: it catches
|
|
27
|
-
// a layout-mutating path that was never wired to this tracker at all.
|
|
28
|
-
|
|
29
|
-
import { spliceDirty } from "./export-splice.js";
|
|
30
|
-
import { writePackage } from "./opc-write.js";
|
|
31
|
-
import { resolveMainDocumentPart } from "./zip-container.js";
|
|
32
|
-
import { containerById, LAYOUT_KEYS } from "../../model/node.js";
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* The layout keys the geometry splice can WRITE today.
|
|
36
|
-
*
|
|
37
|
-
* `x`/`y` live in `wp:posOffset` as plain digits and have exactly one live copy
|
|
38
|
-
* (measured: `a:off` is `x="0" y="0"` on 7,990/7,990 corpus containers).
|
|
39
|
-
* @type {ReadonlySet<string>}
|
|
40
|
-
*/
|
|
41
|
-
export const WRITABLE_LAYOUT_KEYS = new Set(["x", "y"]);
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Every other layout key, with the reason it is refused. Together with
|
|
45
|
-
* `WRITABLE_LAYOUT_KEYS` this must PARTITION `LAYOUT_KEYS` — a key that is
|
|
46
|
-
* neither written nor deliberately refused would export as silent nothing,
|
|
47
|
-
* which is exactly the bug this slice exists to fix, moved one layer up. The
|
|
48
|
-
* partition is a gate in `test/io/export-geometry.test.js`.
|
|
49
|
-
* @type {Readonly<Record<string, string>>}
|
|
50
|
-
*/
|
|
51
|
-
export const REFUSED_LAYOUT_KEYS = Object.freeze({
|
|
52
|
-
cx: "size has TWO live copies in mc:Choice (wp:extent AND wps:spPr/a:xfrm/a:ext, 7990/7990) — writing one leaves the other stale; own slice",
|
|
53
|
-
cy: "same as cx",
|
|
54
|
-
z: "z is wp:anchor@relativeHeight, an ATTRIBUTE — xml-scan keeps no attribute offsets, so it needs the open-tag re-emit path; own slice",
|
|
55
|
-
page: "changing the page means MOVING the w:drawing inside the body, not editing an attribute — permanently out of this design",
|
|
56
|
-
rot: "rotation is never rendered by this editor (FIDELITY: DEGRADED); writing it would claim a fidelity we do not have",
|
|
57
|
-
flipH: "same as rot",
|
|
58
|
-
flipV: "same as rot",
|
|
59
|
-
locked: "a model-only affordance flag; it has no OOXML counterpart to write",
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* The `pPrOverride` keys the pPr splice can WRITE today (ADR-0030).
|
|
64
|
-
*
|
|
65
|
-
* All four live as attributes of a single `<w:ind>` element, and the corpus
|
|
66
|
-
* carries no others on it: measured 2026-08-31 across 46/46 documents, `w:ind`
|
|
67
|
-
* appears with exactly these four attribute names in 9 distinct combinations,
|
|
68
|
-
* every one a subset of this set — `w:start`/`w:end`/`w:leftChars`/
|
|
69
|
-
* `w:rightChars`/`w:firstLineChars`/`w:hangingChars` are 0/46. So these four
|
|
70
|
-
* fully cover this corpus; the emitter still carries unmodelled attributes
|
|
71
|
-
* forward, because they exist in the schema and the protection is free.
|
|
72
|
-
* @type {ReadonlySet<string>}
|
|
73
|
-
*/
|
|
74
|
-
export const WRITABLE_PPR_KEYS = new Set([
|
|
75
|
-
"ind.left",
|
|
76
|
-
"ind.right",
|
|
77
|
-
"ind.firstLine",
|
|
78
|
-
"ind.hanging",
|
|
79
|
-
]);
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Every other key `readPPr` can produce, with the reason it is refused.
|
|
83
|
-
* Together with `WRITABLE_PPR_KEYS` this must PARTITION `readPPr`'s own output
|
|
84
|
-
* — the same contract, and the same reason, as
|
|
85
|
-
* `WRITABLE_LAYOUT_KEYS`/`REFUSED_LAYOUT_KEYS` above: a key that is neither
|
|
86
|
-
* written nor deliberately refused would export as silent nothing. The
|
|
87
|
-
* partition is a gate in `test/io/export-rpr.test.js` ("GATE (ADR-0030): ...
|
|
88
|
-
* PARTITION readPPr's own output"; a separate `export-ppr.test.js` was never
|
|
89
|
-
* created), driven by calling
|
|
90
|
-
* `readPPr` on a pPr carrying every field rather than by a hand-written list.
|
|
91
|
-
* @type {Readonly<Record<string, string>>}
|
|
92
|
-
*/
|
|
93
|
-
export const REFUSED_PPR_KEYS = Object.freeze({
|
|
94
|
-
jc: "not an override key at all — alignment is its own Paragraph.jc field (ADR-0029/ADR-0030 Karar 2), emitted by emitParagraphProps' own jc branch",
|
|
95
|
-
"spacing.before": "own slice; the spacing measurement is recorded in ADR-0030 but no decision was made",
|
|
96
|
-
"spacing.after": "same as spacing.before (and measured to have exactly ONE distinct corpus value, \"0\")",
|
|
97
|
-
"spacing.line": "same as spacing.before, and coupled to spacing.lineRule — the two always co-occur (4256/4256)",
|
|
98
|
-
"spacing.lineRule":
|
|
99
|
-
"the UI's 1.0/1.15/1.5 multipliers only mean anything under lineRule=auto, but 52% of corpus line-carrying paragraphs use `exact`; writing a multiplier would CHANGE the rule. Measured, not decided — ADR-0030 kapsam dışı",
|
|
100
|
-
"numPr.ilvl": "list numbering is a separate subsystem (numbering.xml), never a direct paragraph override",
|
|
101
|
-
"numPr.numId": "same as numPr.ilvl",
|
|
102
|
-
"numPr.marker":
|
|
103
|
-
"not a readPPr key at all — import.js ADDS it after the style cascade, resolved from numbering.xml; display-only, never written back",
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Container ids whose content changed between the imported document and the
|
|
108
|
-
* current one. Relies on the model's structural sharing: an edit rebuilds only
|
|
109
|
-
* the touched container (and its ancestors), leaving every other container `===`
|
|
110
|
-
* its original — so identity inequality means "touched". A container present in
|
|
111
|
-
* `currentDoc` but not `originalDoc` (a NEW box) is reported dirty too; export
|
|
112
|
-
* then rejects it (no anchor range) — new-anchor writing is a later slice.
|
|
113
|
-
*
|
|
114
|
-
* @param {import("../../model/node.js").Doc} originalDoc the imported document
|
|
115
|
-
* @param {import("../../model/node.js").Doc} currentDoc the edited document
|
|
116
|
-
* @returns {Set<import("../../model/node.js").NodeId>}
|
|
117
|
-
*/
|
|
118
|
-
export function dirtyContainers(originalDoc, currentDoc) {
|
|
119
|
-
/** @type {Set<import("../../model/node.js").NodeId>} */
|
|
120
|
-
const dirty = new Set();
|
|
121
|
-
for (const container of currentDoc.content) {
|
|
122
|
-
const original = containerById(originalDoc, container.id);
|
|
123
|
-
if (original === null || original !== container) {
|
|
124
|
-
dirty.add(container.id);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
return dirty;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Which boxes' GEOMETRY changed, and which layout keys changed on each.
|
|
132
|
-
*
|
|
133
|
-
* The layout branch is compared by VALUE, key by key — the mirror of
|
|
134
|
-
* `dirtyContainers`' identity compare on the content branch, and for the same
|
|
135
|
-
* reason: `applySetLayout` rebuilds only `doc.layout`, so a move leaves every
|
|
136
|
-
* `Container` `===` and the content-side signal cannot see it at all.
|
|
137
|
-
*
|
|
138
|
-
* Value (not identity) compare on purpose, twice over:
|
|
139
|
-
* - `patchLayout` mints a NEW entry object even when a drag returns a box to
|
|
140
|
-
* where it started, so identity would report a move that did not happen;
|
|
141
|
-
* - the commands deliberately patch the FULL key set (`{x, y}` for a move even
|
|
142
|
-
* when only x changed) so `HistoryEngine`'s drag coalescing keeps working —
|
|
143
|
-
* value compare narrows that back down to the keys that really moved, which
|
|
144
|
-
* is what §3.5's dirty-KEY guarantee needs.
|
|
145
|
-
*
|
|
146
|
-
* Iterates `currentDoc.content`, never `provenance.anchorRanges`: that map also
|
|
147
|
-
* holds DECORATION ids, and decorations (like images) are render-only and must
|
|
148
|
-
* never be written (ADR-0015 / ADR-0020 K6).
|
|
149
|
-
*
|
|
150
|
-
* @param {import("../../model/node.js").Doc} originalDoc the imported document
|
|
151
|
-
* @param {import("../../model/node.js").Doc} currentDoc the edited document
|
|
152
|
-
* @returns {Map<import("../../model/node.js").NodeId, Set<string>>} id -> changed keys
|
|
153
|
-
*/
|
|
154
|
-
export function dirtyGeometry(originalDoc, currentDoc) {
|
|
155
|
-
/** @type {Map<import("../../model/node.js").NodeId, Set<string>>} */
|
|
156
|
-
const dirty = new Map();
|
|
157
|
-
for (const container of currentDoc.content) {
|
|
158
|
-
const before = originalDoc.layout[container.id];
|
|
159
|
-
const after = currentDoc.layout[container.id];
|
|
160
|
-
if (before === undefined || after === undefined || before === after) {
|
|
161
|
-
continue; // not a positioned box, brand new, or provably untouched
|
|
162
|
-
}
|
|
163
|
-
/** @type {Set<string>} */
|
|
164
|
-
const changed = new Set();
|
|
165
|
-
for (const key of LAYOUT_KEYS) {
|
|
166
|
-
if (before[key] !== after[key]) {
|
|
167
|
-
changed.add(key);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
if (changed.size > 0) {
|
|
171
|
-
dirty.set(container.id, changed);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
return dirty;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Folds the layout keys ONE transaction's steps NAME into a running dirty map —
|
|
179
|
-
* P6 parça (5) / Dilim B's history-driven tracking.
|
|
180
|
-
*
|
|
181
|
-
* 🔴 This is NOT the production dirty source (that stays `dirtyGeometry`'s value
|
|
182
|
-
* compare — see the module doc comment on why). Its job is narrower and real:
|
|
183
|
-
* it is what makes `invertSetLayout`'s "restores only the PATCHED keys, not
|
|
184
|
-
* every key" guarantee (`step-invert.js`) have an actual consumer. Before this,
|
|
185
|
-
* that guarantee was checked only by a shape assertion inspecting a returned
|
|
186
|
-
* step's own `patch` object (`step-invert.test.js`) — HIST7's mutation record
|
|
187
|
-
* could not point at any behavior it broke, because nothing downstream ever
|
|
188
|
-
* READ which keys a step named. This function is that reader: call it on every
|
|
189
|
-
* dispatched transaction's `steps`/`docBefore` (forward, undo, AND redo — a
|
|
190
|
-
* `HistoryEngine.undo()`/`redo()` result is a real `Transaction`, walked the
|
|
191
|
-
* same way), and the accumulated set becomes:
|
|
192
|
-
* - a REAL behavioral test for HIST7 (see the mutation's own test file), and
|
|
193
|
-
* - `exportDocx`'s optional consistency gate (`historyDirty` param below):
|
|
194
|
-
* every key the value compare finds dirty must appear here too, or a
|
|
195
|
-
* layout-mutating path exists that this tracker was never wired to.
|
|
196
|
-
*
|
|
197
|
-
* Only `setLayout` steps carry layout keys; every other step type is a no-op
|
|
198
|
-
* here (mirrors `dirtyContainerIds`' own switch).
|
|
199
|
-
*
|
|
200
|
-
* @param {Map<import("../../model/node.js").NodeId, Set<string>>} dirty mutated in place
|
|
201
|
-
* @param {import("../../model/node.js").Doc} docBefore unused today (kept for
|
|
202
|
-
* symmetry with `dirtyContainerIds(doc, step)` and because a future step type
|
|
203
|
-
* may need to resolve state that only `docBefore` has)
|
|
204
|
-
* @param {import("../../model/step.js").Step[]} steps
|
|
205
|
-
* @returns {Map<import("../../model/node.js").NodeId, Set<string>>} `dirty`, for chaining
|
|
206
|
-
*/
|
|
207
|
-
export function recordForwardDirty(dirty, docBefore, steps) {
|
|
208
|
-
void docBefore;
|
|
209
|
-
for (const step of steps) {
|
|
210
|
-
if (step.type !== "setLayout") {
|
|
211
|
-
continue;
|
|
212
|
-
}
|
|
213
|
-
const keys = dirty.get(step.containerId) ?? new Set();
|
|
214
|
-
for (const key of Object.keys(step.patch)) {
|
|
215
|
-
keys.add(key);
|
|
216
|
-
}
|
|
217
|
-
dirty.set(step.containerId, keys);
|
|
218
|
-
}
|
|
219
|
-
return dirty;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Verifies an INVERSE transaction's `setLayout` steps (a `HistoryEngine.undo()`
|
|
224
|
-
* result) name ONLY keys a forward step already recorded via
|
|
225
|
-
* `recordForwardDirty` — i.e. `invertSetLayout` never invents a key no forward
|
|
226
|
-
* step ever patched.
|
|
227
|
-
*
|
|
228
|
-
* This is HIST7's guarantee made observable, and observable in a way a naive
|
|
229
|
-
* "history set equals value-dirty set" check is NOT: that check has two false-
|
|
230
|
-
* positive sources that are both completely healthy —
|
|
231
|
-
* (a) `commands/box.js` deliberately patches the FULL key set (`{x, y}` for a
|
|
232
|
-
* move even when only x changed, so `HistoryEngine`'s drag coalescing
|
|
233
|
-
* keeps working) — a value compare narrows this, but `dirty` here still
|
|
234
|
-
* legitimately carries `y` even when only `x` moved;
|
|
235
|
-
* (b) a taşı-sonra-geri-al leaves `dirty` populated (traceability) while the
|
|
236
|
-
* value compare is empty (nothing to write) — that is §3.5's own "zero
|
|
237
|
-
* touch = zero difference" guarantee, not a bug.
|
|
238
|
-
* Both would make a same-set check throw on healthy input. The check that
|
|
239
|
-
* actually isolates HIST7's own claim is narrower: an INVERSE step may only
|
|
240
|
-
* name keys its OWN forward step already named — box.js's `{x, y}` patch is
|
|
241
|
-
* still fine (both keys were forward-named), but a whole-entry inverse
|
|
242
|
-
* (`{...before}`, the HIST7 mutation) names `page`/`cx`/`cy`/`z`/… that no
|
|
243
|
-
* forward step for that container ever patched, and THAT throws.
|
|
244
|
-
*
|
|
245
|
-
* @param {Map<import("../../model/node.js").NodeId, Set<string>>} known the
|
|
246
|
-
* Map `recordForwardDirty` has been accumulating — read, never mutated here
|
|
247
|
-
* @param {import("../../model/node.js").Doc} docBefore unused today, kept for
|
|
248
|
-
* symmetry with `recordForwardDirty`/`dirtyContainerIds`
|
|
249
|
-
* @param {import("../../model/step.js").Step[]} steps an inverse transaction's steps
|
|
250
|
-
* @returns {void}
|
|
251
|
-
*/
|
|
252
|
-
export function verifyInverseDirty(known, docBefore, steps) {
|
|
253
|
-
void docBefore;
|
|
254
|
-
for (const step of steps) {
|
|
255
|
-
if (step.type !== "setLayout") {
|
|
256
|
-
continue;
|
|
257
|
-
}
|
|
258
|
-
const knownKeys = known.get(step.containerId);
|
|
259
|
-
for (const key of Object.keys(step.patch)) {
|
|
260
|
-
if (knownKeys === undefined || !knownKeys.has(key)) {
|
|
261
|
-
throw new Error(
|
|
262
|
-
`verifyInverseDirty: container ${step.containerId}'s inverse names '${key}', which no forward ` +
|
|
263
|
-
"step ever patched — invertSetLayout is restoring a key it should not (HIST7)",
|
|
264
|
-
);
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* @typedef {object} DegradedImageWarning
|
|
272
|
-
* @property {string | null} name
|
|
273
|
-
* @property {number[]} sharedContainerIds the editable container id(s) from
|
|
274
|
-
* this image's own anchor — the box the user would be editing when this
|
|
275
|
-
* image is at risk
|
|
276
|
-
*/
|
|
277
|
-
|
|
278
|
-
/**
|
|
279
|
-
* @typedef {object} ExportReport
|
|
280
|
-
* @property {import("./export-splice.js").SkippedGeometry[]} skippedGeometry
|
|
281
|
-
* geometry changes this export could NOT write, with the reason. NEVER
|
|
282
|
-
* silent: a box the user moved on screen whose new position did not reach the
|
|
283
|
-
* file has to be visible, or we repeat the exact surprise this slice fixes.
|
|
284
|
-
* @property {DegradedImageWarning[]} degradedImages ADR-0020 Karar 6's
|
|
285
|
-
* DEGRADED images (`importResult.report.skippedImages`, filtered) —
|
|
286
|
-
* surfaced again HERE, not just at import time, because export/save is the
|
|
287
|
-
* moment the risk (P6 anchor regeneration would silently drop this image)
|
|
288
|
-
* actually matters to the user. Classification itself is untouched: this
|
|
289
|
-
* reads what `importDocx` already computed, it does not recompute anything.
|
|
290
|
-
* Bytes are unaffected — `word/media/*` stays verbatim/PRESERVED either
|
|
291
|
-
* way (§3.5); this field is warning-only, like `skippedGeometry`.
|
|
292
|
-
*/
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* A .docx for `currentDoc`, produced by splicing only the touched containers'
|
|
296
|
-
* text and geometry into `importResult`'s untouched original bytes and
|
|
297
|
-
* repacking. Zero edits -> a byte-identical copy of the imported file (no part
|
|
298
|
-
* is re-compressed): the strongest form of §3.5's "zero touch = zero difference".
|
|
299
|
-
*
|
|
300
|
-
* Returns the report alongside the bytes — deliberately, so a caller cannot get
|
|
301
|
-
* the file without also receiving what did not make it into the file. Mirrors
|
|
302
|
-
* `importDocx`'s own `{doc, …, report}` shape.
|
|
303
|
-
*
|
|
304
|
-
* @param {import("./import.js").ConvertResult} importResult what `importDocx` returned (carries `doc` + `provenance`)
|
|
305
|
-
* @param {import("../../model/node.js").Doc} currentDoc the edited document (from EditorState)
|
|
306
|
-
* @param {object} [options]
|
|
307
|
-
* @param {Map<import("../../model/node.js").NodeId, Set<string>>} [options.historyDirty]
|
|
308
|
-
* the running `recordForwardDirty` map, if the caller tracks one. NOT the
|
|
309
|
-
* dirty source — `dirtyGeometry`'s value compare still decides what gets
|
|
310
|
-
* written, so a taşı-sonra-geri-al still exports byte-identical even though
|
|
311
|
-
* `historyDirty` still names the keys those steps touched. When given, it is
|
|
312
|
-
* a ONE-WAY consistency gate: every key the value compare finds dirty MUST
|
|
313
|
-
* be present here, or a layout-mutating path exists that never called
|
|
314
|
-
* `recordForwardDirty` — a real bug, thrown loudly rather than silently
|
|
315
|
-
* exported with no traceability. The reverse (a key tracked but not
|
|
316
|
-
* value-dirty) is the NORMAL taşı-sonra-geri-al case and is not an error.
|
|
317
|
-
* This gate catches a DIFFERENT regression than `verifyInverseDirty`: a
|
|
318
|
-
* forgotten wiring, not an inverse naming a key it should not.
|
|
319
|
-
* @returns {Promise<{bytes: Uint8Array, report: ExportReport}>}
|
|
320
|
-
*/
|
|
321
|
-
export async function exportDocx(importResult, currentDoc, { historyDirty } = {}) {
|
|
322
|
-
const { provenance } = importResult;
|
|
323
|
-
if (provenance.packageBytes === undefined || provenance.entries === undefined) {
|
|
324
|
-
// convertDocument's provenance is document-level only; export needs the
|
|
325
|
-
// retained package importDocx adds (piece 1). This guards a misuse and
|
|
326
|
-
// narrows the optional fields for the type checker.
|
|
327
|
-
throw new Error("exportDocx requires importDocx's provenance (packageBytes + entries)");
|
|
328
|
-
}
|
|
329
|
-
const dirty = dirtyContainers(importResult.doc, currentDoc);
|
|
330
|
-
const geometry = dirtyGeometry(importResult.doc, currentDoc);
|
|
331
|
-
|
|
332
|
-
if (historyDirty !== undefined) {
|
|
333
|
-
for (const [id, keys] of geometry) {
|
|
334
|
-
const tracked = historyDirty.get(id);
|
|
335
|
-
for (const key of keys) {
|
|
336
|
-
if (tracked === undefined || !tracked.has(key)) {
|
|
337
|
-
throw new Error(
|
|
338
|
-
`exportDocx: container ${id}'s '${key}' is dirty by value but was never tracked by ` +
|
|
339
|
-
"recordForwardDirty — a layout-mutating path exists that history tracking is not wired to",
|
|
340
|
-
);
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
const { bytes: newDocumentXml, skippedGeometry } = spliceDirty(provenance, currentDoc, {
|
|
347
|
-
textIds: dirty,
|
|
348
|
-
geometry,
|
|
349
|
-
originalDoc: importResult.doc,
|
|
350
|
-
});
|
|
351
|
-
// spliceDirty returns the ORIGINAL bytes (same reference) when nothing was
|
|
352
|
-
// written; in that case repack with NO replacement, so document.xml is copied
|
|
353
|
-
// verbatim and the whole .docx comes out byte-identical (not merely
|
|
354
|
-
// inflate-identical). Otherwise the changed document.xml is the one replacement.
|
|
355
|
-
const replacements =
|
|
356
|
-
newDocumentXml === provenance.documentXmlBytes
|
|
357
|
-
? {}
|
|
358
|
-
: { [resolveMainDocumentPart(provenance.entries).name]: newDocumentXml };
|
|
359
|
-
|
|
360
|
-
const bytes = await writePackage(provenance.packageBytes, provenance.entries, replacements);
|
|
361
|
-
const degradedImages = importResult.report.skippedImages
|
|
362
|
-
.filter((img) => img.fidelity === "DEGRADED")
|
|
363
|
-
.map((img) => ({ name: img.name, sharedContainerIds: img.sharedContainerIds }));
|
|
364
|
-
return { bytes, report: { skippedGeometry, degradedImages } };
|
|
365
|
-
}
|
|
1
|
+
// P6 piece (3): export orchestration. The whole import -> edit -> export path
|
|
2
|
+
// in one call: which containers changed (dirty), splice each one's new
|
|
3
|
+
// txbxContent into the untouched document.xml (piece 4b), then repack into a
|
|
4
|
+
// valid .docx (piece 2). This is where the FIRST real .docx comes out.
|
|
5
|
+
//
|
|
6
|
+
// LIMITS (narrow, by design): plain-text only (no rPr/pPr — piece 7); existing
|
|
7
|
+
// containers' text changes only (no new anchor).
|
|
8
|
+
//
|
|
9
|
+
// DIRTY SOURCE (piece 5 / Dilim B, 2026-08-29): content dirty is the model's
|
|
10
|
+
// own structural sharing (identity inequality); geometry dirty is a VALUE
|
|
11
|
+
// compare of the layout branch (`dirtyGeometry`). Both stay the PRODUCTION
|
|
12
|
+
// source — a taşı-sonra-geri-al must export byte-identical, which only a
|
|
13
|
+
// value compare gives for free. `recordForwardDirty`/`verifyInverseDirty`
|
|
14
|
+
// below are the HISTORY-driven half ADR-0023 originally expected to be the
|
|
15
|
+
// dirty source itself; it turned out narrower and more useful as a
|
|
16
|
+
// TRACEABILITY layer: `recordForwardDirty` remembers which keys a forward
|
|
17
|
+
// setLayout named, `verifyInverseDirty` checks an undo's inverse step never
|
|
18
|
+
// names a key no forward step named — HIST7's own guarantee
|
|
19
|
+
// ("invertSetLayout restores only the patched keys"), made observable at the
|
|
20
|
+
// one point in the codebase that rule has a real consequence. A same-set
|
|
21
|
+
// check ("history exactly equals value-dirty") was tried and REJECTED,
|
|
22
|
+
// measured to false-positive on two healthy paths: `commands/box.js`'s own
|
|
23
|
+
// full-key patch (`{x, y}` for an x-only move, so drag coalescing keeps
|
|
24
|
+
// working) and a taşı-sonra-geri-al itself (history stays populated, value
|
|
25
|
+
// compare goes empty — §3.5's own guarantee, not a bug). `exportDocx`'s
|
|
26
|
+
// optional `historyDirty` gate is the third, complementary check: it catches
|
|
27
|
+
// a layout-mutating path that was never wired to this tracker at all.
|
|
28
|
+
|
|
29
|
+
import { spliceDirty } from "./export-splice.js";
|
|
30
|
+
import { writePackage } from "./opc-write.js";
|
|
31
|
+
import { resolveMainDocumentPart } from "./zip-container.js";
|
|
32
|
+
import { containerById, LAYOUT_KEYS } from "../../model/node.js";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The layout keys the geometry splice can WRITE today.
|
|
36
|
+
*
|
|
37
|
+
* `x`/`y` live in `wp:posOffset` as plain digits and have exactly one live copy
|
|
38
|
+
* (measured: `a:off` is `x="0" y="0"` on 7,990/7,990 corpus containers).
|
|
39
|
+
* @type {ReadonlySet<string>}
|
|
40
|
+
*/
|
|
41
|
+
export const WRITABLE_LAYOUT_KEYS = new Set(["x", "y"]);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Every other layout key, with the reason it is refused. Together with
|
|
45
|
+
* `WRITABLE_LAYOUT_KEYS` this must PARTITION `LAYOUT_KEYS` — a key that is
|
|
46
|
+
* neither written nor deliberately refused would export as silent nothing,
|
|
47
|
+
* which is exactly the bug this slice exists to fix, moved one layer up. The
|
|
48
|
+
* partition is a gate in `test/io/export-geometry.test.js`.
|
|
49
|
+
* @type {Readonly<Record<string, string>>}
|
|
50
|
+
*/
|
|
51
|
+
export const REFUSED_LAYOUT_KEYS = Object.freeze({
|
|
52
|
+
cx: "size has TWO live copies in mc:Choice (wp:extent AND wps:spPr/a:xfrm/a:ext, 7990/7990) — writing one leaves the other stale; own slice",
|
|
53
|
+
cy: "same as cx",
|
|
54
|
+
z: "z is wp:anchor@relativeHeight, an ATTRIBUTE — xml-scan keeps no attribute offsets, so it needs the open-tag re-emit path; own slice",
|
|
55
|
+
page: "changing the page means MOVING the w:drawing inside the body, not editing an attribute — permanently out of this design",
|
|
56
|
+
rot: "rotation is never rendered by this editor (FIDELITY: DEGRADED); writing it would claim a fidelity we do not have",
|
|
57
|
+
flipH: "same as rot",
|
|
58
|
+
flipV: "same as rot",
|
|
59
|
+
locked: "a model-only affordance flag; it has no OOXML counterpart to write",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The `pPrOverride` keys the pPr splice can WRITE today (ADR-0030).
|
|
64
|
+
*
|
|
65
|
+
* All four live as attributes of a single `<w:ind>` element, and the corpus
|
|
66
|
+
* carries no others on it: measured 2026-08-31 across 46/46 documents, `w:ind`
|
|
67
|
+
* appears with exactly these four attribute names in 9 distinct combinations,
|
|
68
|
+
* every one a subset of this set — `w:start`/`w:end`/`w:leftChars`/
|
|
69
|
+
* `w:rightChars`/`w:firstLineChars`/`w:hangingChars` are 0/46. So these four
|
|
70
|
+
* fully cover this corpus; the emitter still carries unmodelled attributes
|
|
71
|
+
* forward, because they exist in the schema and the protection is free.
|
|
72
|
+
* @type {ReadonlySet<string>}
|
|
73
|
+
*/
|
|
74
|
+
export const WRITABLE_PPR_KEYS = new Set([
|
|
75
|
+
"ind.left",
|
|
76
|
+
"ind.right",
|
|
77
|
+
"ind.firstLine",
|
|
78
|
+
"ind.hanging",
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Every other key `readPPr` can produce, with the reason it is refused.
|
|
83
|
+
* Together with `WRITABLE_PPR_KEYS` this must PARTITION `readPPr`'s own output
|
|
84
|
+
* — the same contract, and the same reason, as
|
|
85
|
+
* `WRITABLE_LAYOUT_KEYS`/`REFUSED_LAYOUT_KEYS` above: a key that is neither
|
|
86
|
+
* written nor deliberately refused would export as silent nothing. The
|
|
87
|
+
* partition is a gate in `test/io/export-rpr.test.js` ("GATE (ADR-0030): ...
|
|
88
|
+
* PARTITION readPPr's own output"; a separate `export-ppr.test.js` was never
|
|
89
|
+
* created), driven by calling
|
|
90
|
+
* `readPPr` on a pPr carrying every field rather than by a hand-written list.
|
|
91
|
+
* @type {Readonly<Record<string, string>>}
|
|
92
|
+
*/
|
|
93
|
+
export const REFUSED_PPR_KEYS = Object.freeze({
|
|
94
|
+
jc: "not an override key at all — alignment is its own Paragraph.jc field (ADR-0029/ADR-0030 Karar 2), emitted by emitParagraphProps' own jc branch",
|
|
95
|
+
"spacing.before": "own slice; the spacing measurement is recorded in ADR-0030 but no decision was made",
|
|
96
|
+
"spacing.after": "same as spacing.before (and measured to have exactly ONE distinct corpus value, \"0\")",
|
|
97
|
+
"spacing.line": "same as spacing.before, and coupled to spacing.lineRule — the two always co-occur (4256/4256)",
|
|
98
|
+
"spacing.lineRule":
|
|
99
|
+
"the UI's 1.0/1.15/1.5 multipliers only mean anything under lineRule=auto, but 52% of corpus line-carrying paragraphs use `exact`; writing a multiplier would CHANGE the rule. Measured, not decided — ADR-0030 kapsam dışı",
|
|
100
|
+
"numPr.ilvl": "list numbering is a separate subsystem (numbering.xml), never a direct paragraph override",
|
|
101
|
+
"numPr.numId": "same as numPr.ilvl",
|
|
102
|
+
"numPr.marker":
|
|
103
|
+
"not a readPPr key at all — import.js ADDS it after the style cascade, resolved from numbering.xml; display-only, never written back",
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Container ids whose content changed between the imported document and the
|
|
108
|
+
* current one. Relies on the model's structural sharing: an edit rebuilds only
|
|
109
|
+
* the touched container (and its ancestors), leaving every other container `===`
|
|
110
|
+
* its original — so identity inequality means "touched". A container present in
|
|
111
|
+
* `currentDoc` but not `originalDoc` (a NEW box) is reported dirty too; export
|
|
112
|
+
* then rejects it (no anchor range) — new-anchor writing is a later slice.
|
|
113
|
+
*
|
|
114
|
+
* @param {import("../../model/node.js").Doc} originalDoc the imported document
|
|
115
|
+
* @param {import("../../model/node.js").Doc} currentDoc the edited document
|
|
116
|
+
* @returns {Set<import("../../model/node.js").NodeId>}
|
|
117
|
+
*/
|
|
118
|
+
export function dirtyContainers(originalDoc, currentDoc) {
|
|
119
|
+
/** @type {Set<import("../../model/node.js").NodeId>} */
|
|
120
|
+
const dirty = new Set();
|
|
121
|
+
for (const container of currentDoc.content) {
|
|
122
|
+
const original = containerById(originalDoc, container.id);
|
|
123
|
+
if (original === null || original !== container) {
|
|
124
|
+
dirty.add(container.id);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return dirty;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Which boxes' GEOMETRY changed, and which layout keys changed on each.
|
|
132
|
+
*
|
|
133
|
+
* The layout branch is compared by VALUE, key by key — the mirror of
|
|
134
|
+
* `dirtyContainers`' identity compare on the content branch, and for the same
|
|
135
|
+
* reason: `applySetLayout` rebuilds only `doc.layout`, so a move leaves every
|
|
136
|
+
* `Container` `===` and the content-side signal cannot see it at all.
|
|
137
|
+
*
|
|
138
|
+
* Value (not identity) compare on purpose, twice over:
|
|
139
|
+
* - `patchLayout` mints a NEW entry object even when a drag returns a box to
|
|
140
|
+
* where it started, so identity would report a move that did not happen;
|
|
141
|
+
* - the commands deliberately patch the FULL key set (`{x, y}` for a move even
|
|
142
|
+
* when only x changed) so `HistoryEngine`'s drag coalescing keeps working —
|
|
143
|
+
* value compare narrows that back down to the keys that really moved, which
|
|
144
|
+
* is what §3.5's dirty-KEY guarantee needs.
|
|
145
|
+
*
|
|
146
|
+
* Iterates `currentDoc.content`, never `provenance.anchorRanges`: that map also
|
|
147
|
+
* holds DECORATION ids, and decorations (like images) are render-only and must
|
|
148
|
+
* never be written (ADR-0015 / ADR-0020 K6).
|
|
149
|
+
*
|
|
150
|
+
* @param {import("../../model/node.js").Doc} originalDoc the imported document
|
|
151
|
+
* @param {import("../../model/node.js").Doc} currentDoc the edited document
|
|
152
|
+
* @returns {Map<import("../../model/node.js").NodeId, Set<string>>} id -> changed keys
|
|
153
|
+
*/
|
|
154
|
+
export function dirtyGeometry(originalDoc, currentDoc) {
|
|
155
|
+
/** @type {Map<import("../../model/node.js").NodeId, Set<string>>} */
|
|
156
|
+
const dirty = new Map();
|
|
157
|
+
for (const container of currentDoc.content) {
|
|
158
|
+
const before = originalDoc.layout[container.id];
|
|
159
|
+
const after = currentDoc.layout[container.id];
|
|
160
|
+
if (before === undefined || after === undefined || before === after) {
|
|
161
|
+
continue; // not a positioned box, brand new, or provably untouched
|
|
162
|
+
}
|
|
163
|
+
/** @type {Set<string>} */
|
|
164
|
+
const changed = new Set();
|
|
165
|
+
for (const key of LAYOUT_KEYS) {
|
|
166
|
+
if (before[key] !== after[key]) {
|
|
167
|
+
changed.add(key);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (changed.size > 0) {
|
|
171
|
+
dirty.set(container.id, changed);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return dirty;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Folds the layout keys ONE transaction's steps NAME into a running dirty map —
|
|
179
|
+
* P6 parça (5) / Dilim B's history-driven tracking.
|
|
180
|
+
*
|
|
181
|
+
* 🔴 This is NOT the production dirty source (that stays `dirtyGeometry`'s value
|
|
182
|
+
* compare — see the module doc comment on why). Its job is narrower and real:
|
|
183
|
+
* it is what makes `invertSetLayout`'s "restores only the PATCHED keys, not
|
|
184
|
+
* every key" guarantee (`step-invert.js`) have an actual consumer. Before this,
|
|
185
|
+
* that guarantee was checked only by a shape assertion inspecting a returned
|
|
186
|
+
* step's own `patch` object (`step-invert.test.js`) — HIST7's mutation record
|
|
187
|
+
* could not point at any behavior it broke, because nothing downstream ever
|
|
188
|
+
* READ which keys a step named. This function is that reader: call it on every
|
|
189
|
+
* dispatched transaction's `steps`/`docBefore` (forward, undo, AND redo — a
|
|
190
|
+
* `HistoryEngine.undo()`/`redo()` result is a real `Transaction`, walked the
|
|
191
|
+
* same way), and the accumulated set becomes:
|
|
192
|
+
* - a REAL behavioral test for HIST7 (see the mutation's own test file), and
|
|
193
|
+
* - `exportDocx`'s optional consistency gate (`historyDirty` param below):
|
|
194
|
+
* every key the value compare finds dirty must appear here too, or a
|
|
195
|
+
* layout-mutating path exists that this tracker was never wired to.
|
|
196
|
+
*
|
|
197
|
+
* Only `setLayout` steps carry layout keys; every other step type is a no-op
|
|
198
|
+
* here (mirrors `dirtyContainerIds`' own switch).
|
|
199
|
+
*
|
|
200
|
+
* @param {Map<import("../../model/node.js").NodeId, Set<string>>} dirty mutated in place
|
|
201
|
+
* @param {import("../../model/node.js").Doc} docBefore unused today (kept for
|
|
202
|
+
* symmetry with `dirtyContainerIds(doc, step)` and because a future step type
|
|
203
|
+
* may need to resolve state that only `docBefore` has)
|
|
204
|
+
* @param {import("../../model/step.js").Step[]} steps
|
|
205
|
+
* @returns {Map<import("../../model/node.js").NodeId, Set<string>>} `dirty`, for chaining
|
|
206
|
+
*/
|
|
207
|
+
export function recordForwardDirty(dirty, docBefore, steps) {
|
|
208
|
+
void docBefore;
|
|
209
|
+
for (const step of steps) {
|
|
210
|
+
if (step.type !== "setLayout") {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const keys = dirty.get(step.containerId) ?? new Set();
|
|
214
|
+
for (const key of Object.keys(step.patch)) {
|
|
215
|
+
keys.add(key);
|
|
216
|
+
}
|
|
217
|
+
dirty.set(step.containerId, keys);
|
|
218
|
+
}
|
|
219
|
+
return dirty;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Verifies an INVERSE transaction's `setLayout` steps (a `HistoryEngine.undo()`
|
|
224
|
+
* result) name ONLY keys a forward step already recorded via
|
|
225
|
+
* `recordForwardDirty` — i.e. `invertSetLayout` never invents a key no forward
|
|
226
|
+
* step ever patched.
|
|
227
|
+
*
|
|
228
|
+
* This is HIST7's guarantee made observable, and observable in a way a naive
|
|
229
|
+
* "history set equals value-dirty set" check is NOT: that check has two false-
|
|
230
|
+
* positive sources that are both completely healthy —
|
|
231
|
+
* (a) `commands/box.js` deliberately patches the FULL key set (`{x, y}` for a
|
|
232
|
+
* move even when only x changed, so `HistoryEngine`'s drag coalescing
|
|
233
|
+
* keeps working) — a value compare narrows this, but `dirty` here still
|
|
234
|
+
* legitimately carries `y` even when only `x` moved;
|
|
235
|
+
* (b) a taşı-sonra-geri-al leaves `dirty` populated (traceability) while the
|
|
236
|
+
* value compare is empty (nothing to write) — that is §3.5's own "zero
|
|
237
|
+
* touch = zero difference" guarantee, not a bug.
|
|
238
|
+
* Both would make a same-set check throw on healthy input. The check that
|
|
239
|
+
* actually isolates HIST7's own claim is narrower: an INVERSE step may only
|
|
240
|
+
* name keys its OWN forward step already named — box.js's `{x, y}` patch is
|
|
241
|
+
* still fine (both keys were forward-named), but a whole-entry inverse
|
|
242
|
+
* (`{...before}`, the HIST7 mutation) names `page`/`cx`/`cy`/`z`/… that no
|
|
243
|
+
* forward step for that container ever patched, and THAT throws.
|
|
244
|
+
*
|
|
245
|
+
* @param {Map<import("../../model/node.js").NodeId, Set<string>>} known the
|
|
246
|
+
* Map `recordForwardDirty` has been accumulating — read, never mutated here
|
|
247
|
+
* @param {import("../../model/node.js").Doc} docBefore unused today, kept for
|
|
248
|
+
* symmetry with `recordForwardDirty`/`dirtyContainerIds`
|
|
249
|
+
* @param {import("../../model/step.js").Step[]} steps an inverse transaction's steps
|
|
250
|
+
* @returns {void}
|
|
251
|
+
*/
|
|
252
|
+
export function verifyInverseDirty(known, docBefore, steps) {
|
|
253
|
+
void docBefore;
|
|
254
|
+
for (const step of steps) {
|
|
255
|
+
if (step.type !== "setLayout") {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const knownKeys = known.get(step.containerId);
|
|
259
|
+
for (const key of Object.keys(step.patch)) {
|
|
260
|
+
if (knownKeys === undefined || !knownKeys.has(key)) {
|
|
261
|
+
throw new Error(
|
|
262
|
+
`verifyInverseDirty: container ${step.containerId}'s inverse names '${key}', which no forward ` +
|
|
263
|
+
"step ever patched — invertSetLayout is restoring a key it should not (HIST7)",
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* @typedef {object} DegradedImageWarning
|
|
272
|
+
* @property {string | null} name
|
|
273
|
+
* @property {number[]} sharedContainerIds the editable container id(s) from
|
|
274
|
+
* this image's own anchor — the box the user would be editing when this
|
|
275
|
+
* image is at risk
|
|
276
|
+
*/
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* @typedef {object} ExportReport
|
|
280
|
+
* @property {import("./export-splice.js").SkippedGeometry[]} skippedGeometry
|
|
281
|
+
* geometry changes this export could NOT write, with the reason. NEVER
|
|
282
|
+
* silent: a box the user moved on screen whose new position did not reach the
|
|
283
|
+
* file has to be visible, or we repeat the exact surprise this slice fixes.
|
|
284
|
+
* @property {DegradedImageWarning[]} degradedImages ADR-0020 Karar 6's
|
|
285
|
+
* DEGRADED images (`importResult.report.skippedImages`, filtered) —
|
|
286
|
+
* surfaced again HERE, not just at import time, because export/save is the
|
|
287
|
+
* moment the risk (P6 anchor regeneration would silently drop this image)
|
|
288
|
+
* actually matters to the user. Classification itself is untouched: this
|
|
289
|
+
* reads what `importDocx` already computed, it does not recompute anything.
|
|
290
|
+
* Bytes are unaffected — `word/media/*` stays verbatim/PRESERVED either
|
|
291
|
+
* way (§3.5); this field is warning-only, like `skippedGeometry`.
|
|
292
|
+
*/
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* A .docx for `currentDoc`, produced by splicing only the touched containers'
|
|
296
|
+
* text and geometry into `importResult`'s untouched original bytes and
|
|
297
|
+
* repacking. Zero edits -> a byte-identical copy of the imported file (no part
|
|
298
|
+
* is re-compressed): the strongest form of §3.5's "zero touch = zero difference".
|
|
299
|
+
*
|
|
300
|
+
* Returns the report alongside the bytes — deliberately, so a caller cannot get
|
|
301
|
+
* the file without also receiving what did not make it into the file. Mirrors
|
|
302
|
+
* `importDocx`'s own `{doc, …, report}` shape.
|
|
303
|
+
*
|
|
304
|
+
* @param {import("./import.js").ConvertResult} importResult what `importDocx` returned (carries `doc` + `provenance`)
|
|
305
|
+
* @param {import("../../model/node.js").Doc} currentDoc the edited document (from EditorState)
|
|
306
|
+
* @param {object} [options]
|
|
307
|
+
* @param {Map<import("../../model/node.js").NodeId, Set<string>>} [options.historyDirty]
|
|
308
|
+
* the running `recordForwardDirty` map, if the caller tracks one. NOT the
|
|
309
|
+
* dirty source — `dirtyGeometry`'s value compare still decides what gets
|
|
310
|
+
* written, so a taşı-sonra-geri-al still exports byte-identical even though
|
|
311
|
+
* `historyDirty` still names the keys those steps touched. When given, it is
|
|
312
|
+
* a ONE-WAY consistency gate: every key the value compare finds dirty MUST
|
|
313
|
+
* be present here, or a layout-mutating path exists that never called
|
|
314
|
+
* `recordForwardDirty` — a real bug, thrown loudly rather than silently
|
|
315
|
+
* exported with no traceability. The reverse (a key tracked but not
|
|
316
|
+
* value-dirty) is the NORMAL taşı-sonra-geri-al case and is not an error.
|
|
317
|
+
* This gate catches a DIFFERENT regression than `verifyInverseDirty`: a
|
|
318
|
+
* forgotten wiring, not an inverse naming a key it should not.
|
|
319
|
+
* @returns {Promise<{bytes: Uint8Array, report: ExportReport}>}
|
|
320
|
+
*/
|
|
321
|
+
export async function exportDocx(importResult, currentDoc, { historyDirty } = {}) {
|
|
322
|
+
const { provenance } = importResult;
|
|
323
|
+
if (provenance.packageBytes === undefined || provenance.entries === undefined) {
|
|
324
|
+
// convertDocument's provenance is document-level only; export needs the
|
|
325
|
+
// retained package importDocx adds (piece 1). This guards a misuse and
|
|
326
|
+
// narrows the optional fields for the type checker.
|
|
327
|
+
throw new Error("exportDocx requires importDocx's provenance (packageBytes + entries)");
|
|
328
|
+
}
|
|
329
|
+
const dirty = dirtyContainers(importResult.doc, currentDoc);
|
|
330
|
+
const geometry = dirtyGeometry(importResult.doc, currentDoc);
|
|
331
|
+
|
|
332
|
+
if (historyDirty !== undefined) {
|
|
333
|
+
for (const [id, keys] of geometry) {
|
|
334
|
+
const tracked = historyDirty.get(id);
|
|
335
|
+
for (const key of keys) {
|
|
336
|
+
if (tracked === undefined || !tracked.has(key)) {
|
|
337
|
+
throw new Error(
|
|
338
|
+
`exportDocx: container ${id}'s '${key}' is dirty by value but was never tracked by ` +
|
|
339
|
+
"recordForwardDirty — a layout-mutating path exists that history tracking is not wired to",
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const { bytes: newDocumentXml, skippedGeometry } = spliceDirty(provenance, currentDoc, {
|
|
347
|
+
textIds: dirty,
|
|
348
|
+
geometry,
|
|
349
|
+
originalDoc: importResult.doc,
|
|
350
|
+
});
|
|
351
|
+
// spliceDirty returns the ORIGINAL bytes (same reference) when nothing was
|
|
352
|
+
// written; in that case repack with NO replacement, so document.xml is copied
|
|
353
|
+
// verbatim and the whole .docx comes out byte-identical (not merely
|
|
354
|
+
// inflate-identical). Otherwise the changed document.xml is the one replacement.
|
|
355
|
+
const replacements =
|
|
356
|
+
newDocumentXml === provenance.documentXmlBytes
|
|
357
|
+
? {}
|
|
358
|
+
: { [resolveMainDocumentPart(provenance.entries).name]: newDocumentXml };
|
|
359
|
+
|
|
360
|
+
const bytes = await writePackage(provenance.packageBytes, provenance.entries, replacements);
|
|
361
|
+
const degradedImages = importResult.report.skippedImages
|
|
362
|
+
.filter((img) => img.fidelity === "DEGRADED")
|
|
363
|
+
.map((img) => ({ name: img.name, sharedContainerIds: img.sharedContainerIds }));
|
|
364
|
+
return { bytes, report: { skippedGeometry, degradedImages } };
|
|
365
|
+
}
|