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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +108 -70
  2. package/LICENSE +21 -21
  3. package/README.md +286 -247
  4. package/dist/canvas/box-clipboard.js +238 -238
  5. package/dist/canvas/box-selection.js +80 -80
  6. package/dist/canvas/canvas-editor.js +502 -502
  7. package/dist/canvas/controller.js +717 -717
  8. package/dist/canvas/geometry.js +131 -131
  9. package/dist/canvas/gesture.d.ts +0 -1
  10. package/dist/canvas/gesture.js +289 -289
  11. package/dist/canvas/handles.js +212 -212
  12. package/dist/canvas/menu.js +82 -82
  13. package/dist/canvas/mode-controller.js +81 -81
  14. package/dist/canvas/nudge.js +108 -108
  15. package/dist/canvas/overlay.js +149 -149
  16. package/dist/canvas/snap.js +132 -132
  17. package/dist/canvas/viewport.js +112 -112
  18. package/dist/canvas/z-order.js +171 -171
  19. package/dist/commands/alignment.js +150 -150
  20. package/dist/commands/box.js +272 -272
  21. package/dist/commands/clipboard.js +93 -93
  22. package/dist/commands/graphemes.js +112 -112
  23. package/dist/commands/marks.js +153 -153
  24. package/dist/commands/paragraph-props.js +176 -176
  25. package/dist/commands/size.js +368 -368
  26. package/dist/commands/text.js +291 -291
  27. package/dist/docx.d.ts +1 -0
  28. package/dist/docx.js +15 -13
  29. package/dist/editor/editing-host.js +434 -434
  30. package/dist/element.js +41 -41
  31. package/dist/fonts/OFL-Carlito.txt +94 -0
  32. package/dist/fonts/calibri-metrics.d.ts +27 -0
  33. package/dist/fonts/calibri-metrics.js +88 -0
  34. package/dist/history/history.js +433 -433
  35. package/dist/index.js +63 -63
  36. package/dist/input/beforeinput.js +224 -224
  37. package/dist/input/clipboard.js +107 -107
  38. package/dist/input/composition.js +341 -341
  39. package/dist/input/keymap.js +54 -54
  40. package/dist/io/ooxml/custgeom.js +72 -72
  41. package/dist/io/ooxml/drawingml.js +232 -232
  42. package/dist/io/ooxml/edits.js +67 -67
  43. package/dist/io/ooxml/export-splice.js +880 -880
  44. package/dist/io/ooxml/export.js +365 -365
  45. package/dist/io/ooxml/fill.d.ts +93 -8
  46. package/dist/io/ooxml/fill.js +618 -562
  47. package/dist/io/ooxml/fit.d.ts +252 -0
  48. package/dist/io/ooxml/fit.js +598 -0
  49. package/dist/io/ooxml/import-gate.js +200 -200
  50. package/dist/io/ooxml/import.js +691 -691
  51. package/dist/io/ooxml/inflate.js +126 -126
  52. package/dist/io/ooxml/media.js +87 -87
  53. package/dist/io/ooxml/ns.d.ts +2 -0
  54. package/dist/io/ooxml/ns.js +26 -21
  55. package/dist/io/ooxml/numbering.js +290 -290
  56. package/dist/io/ooxml/opc-write.js +171 -171
  57. package/dist/io/ooxml/provenance.js +93 -93
  58. package/dist/io/ooxml/recipe.js +708 -708
  59. package/dist/io/ooxml/relationships.js +59 -59
  60. package/dist/io/ooxml/run-fonts.d.ts +89 -0
  61. package/dist/io/ooxml/run-fonts.js +378 -0
  62. package/dist/io/ooxml/slots.d.ts +4 -0
  63. package/dist/io/ooxml/slots.js +582 -581
  64. package/dist/io/ooxml/styles-resolve.js +257 -257
  65. package/dist/io/ooxml/units.js +43 -43
  66. package/dist/io/ooxml/value.js +120 -120
  67. package/dist/io/ooxml/xml-scan.js +616 -616
  68. package/dist/io/ooxml/zip-container.js +228 -228
  69. package/dist/io/ooxml/zip-reject.js +106 -106
  70. package/dist/measure/placement.js +83 -83
  71. package/dist/measure/report.js +75 -75
  72. package/dist/model/ids.js +70 -70
  73. package/dist/model/normalize.js +146 -146
  74. package/dist/model/position.js +283 -283
  75. package/dist/model/transaction.js +209 -209
  76. package/dist/platform/capabilities.js +25 -25
  77. package/dist/react/index.js +21 -21
  78. package/dist/selection/controller.js +315 -315
  79. package/dist/selection/dom-map.js +334 -334
  80. package/dist/state/editor-state.js +109 -109
  81. package/dist/styles.css +66 -66
  82. package/package.json +94 -93
@@ -1,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
+ }