@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,257 +1,257 @@
1
- // styles.xml -> docDefaults / style chain / effective-value resolver. Ported
2
- // from scripts/measure-style-inheritance.py's load_styles()/style_chain()/
3
- // resolve()/same_state() — the algorithm, not the aggregate-measurement
4
- // machinery (new_acc/measure_document), which belongs only to that
5
- // standalone script. ADR-0017: "Çözümleyici sıfırdan yazılmayacak."
6
- //
7
- // Resolution order (docDefaults -> style chain root-first, derived wins ->
8
- // direct), ported unchanged.
9
-
10
- import { getAttr, findChild, findChildren } from "./xml-scan.js";
11
- import { W_NS } from "./ns.js";
12
- import { rejectCharacterStyle } from "./import-gate.js";
13
-
14
- const OFF_VALUES = new Set(["0", "false", "none"]);
15
-
16
- /**
17
- * A `w:pPr` element -> flat {field: value}. Absent field = not set here.
18
- * @param {import("./xml-scan.js").XmlElement | undefined} pPrEl
19
- * @returns {Record<string, string>}
20
- */
21
- export function readPPr(pPrEl) {
22
- /** @type {Record<string, string>} */
23
- const out = {};
24
- if (!pPrEl) return out;
25
- const jc = findChild(pPrEl, W_NS, "jc");
26
- if (jc) {
27
- const val = getAttr(jc, W_NS, "val");
28
- if (val !== undefined) out.jc = val;
29
- }
30
- const ind = findChild(pPrEl, W_NS, "ind");
31
- if (ind) {
32
- for (const a of ["left", "right", "firstLine", "hanging"]) {
33
- const v = getAttr(ind, W_NS, a);
34
- if (v !== undefined) out[`ind.${a}`] = v;
35
- }
36
- }
37
- const sp = findChild(pPrEl, W_NS, "spacing");
38
- if (sp) {
39
- for (const a of ["before", "after", "line", "lineRule"]) {
40
- const v = getAttr(sp, W_NS, a);
41
- if (v !== undefined) out[`spacing.${a}`] = v;
42
- }
43
- }
44
- // Raw numId/ilvl only -- resolving them to a display marker needs
45
- // numbering.xml, which this function has no access to (it is called for
46
- // BOTH a style's own pPr and a paragraph's direct pPr, same as jc/ind/
47
- // spacing above, so direct numPr correctly overrides a style's numPr
48
- // through the existing resolve() cascade). import.js's internPPrRef adds
49
- // the resolved `numPr.marker` field after this cascade runs.
50
- const numPr = findChild(pPrEl, W_NS, "numPr");
51
- if (numPr) {
52
- const ilvl = findChild(numPr, W_NS, "ilvl");
53
- if (ilvl) {
54
- const v = getAttr(ilvl, W_NS, "val");
55
- if (v !== undefined) out["numPr.ilvl"] = v;
56
- }
57
- const numId = findChild(numPr, W_NS, "numId");
58
- if (numId) {
59
- const v = getAttr(numId, W_NS, "val");
60
- if (v !== undefined) out["numPr.numId"] = v;
61
- }
62
- }
63
- return out;
64
- }
65
-
66
- /**
67
- * A `w:rPr` element -> flat {field: value}. `font` collapses w:rFonts'
68
- * ascii/hAnsi/cs in that order; a theme-only rFonts yields the sentinel
69
- * "(tema)" — theme1.xml is deliberately not opened (ADR-0017 DOĞRULANMALI
70
- * #6), and pretending the field is unset would hide that.
71
- * A `w:rStyle` child (a reference to a character style) is rejected rather
72
- * than silently ignored — ADR-0020 Karar 4: 0/81.973 real corpus runs carry
73
- * one, so a run that does is corpus-external and this resolver has no
74
- * mechanism to fold the referenced style's properties in.
75
- * `color` is DISPLAY-only (render, not the `Marks`/toggle path — 2026-08-31
76
- * decision, ADR-0026): a 6-hex-digit `w:val` is read straight through; a
77
- * `w:themeColor` reference or `w:val="auto"` is left unset rather than
78
- * guessed — measured 211 real corpus `w:color` elements, 0 carry either
79
- * (theme1.xml is deliberately not opened, same reason as the `rFonts` theme
80
- * sentinel above), so this is not a real-corpus gap today, only a documented
81
- * refusal to invent a value.
82
- * `vertAlign` is DISPLAY-only, same class as `color` (2026-09-01): only the
83
- * three ST_VerticalAlignRun enum values (`superscript`/`subscript`/
84
- * `baseline`) are read straight through; anything else is left unset rather
85
- * than guessed — measured 0/576 real corpus `w:vertAlign` elements carry a
86
- * fourth value.
87
- * @param {import("./xml-scan.js").XmlElement | undefined} rPrEl
88
- * @returns {Record<string, string>}
89
- */
90
- export function readRPr(rPrEl) {
91
- /** @type {Record<string, string>} */
92
- const out = {};
93
- if (!rPrEl) return out;
94
- if (findChild(rPrEl, W_NS, "rStyle")) {
95
- rejectCharacterStyle(`w:rPr at byte ${rPrEl.start}`);
96
- }
97
- const sz = findChild(rPrEl, W_NS, "sz");
98
- if (sz) {
99
- const val = getAttr(sz, W_NS, "val");
100
- if (val) out.sz = val;
101
- }
102
- const rf = findChild(rPrEl, W_NS, "rFonts");
103
- if (rf) {
104
- let name;
105
- for (const a of ["ascii", "hAnsi", "cs"]) {
106
- const v = getAttr(rf, W_NS, a);
107
- if (v) {
108
- name = v;
109
- break;
110
- }
111
- }
112
- if (name === undefined) {
113
- for (const a of ["asciiTheme", "hAnsiTheme", "cstheme"]) {
114
- if (getAttr(rf, W_NS, a)) {
115
- name = "(tema)";
116
- break;
117
- }
118
- }
119
- }
120
- if (name !== undefined) out.font = name;
121
- }
122
- const color = findChild(rPrEl, W_NS, "color");
123
- if (color) {
124
- const val = getAttr(color, W_NS, "val");
125
- // Only a genuine 6-hex-digit value — "auto" and a themeColor reference
126
- // (both absent from this val, or this val itself non-hex) are left
127
- // unset rather than guessed, see this function's own doc comment above.
128
- if (val !== undefined && /^[0-9A-Fa-f]{6}$/.test(val)) {
129
- out.color = val;
130
- }
131
- }
132
- const vertAlign = findChild(rPrEl, W_NS, "vertAlign");
133
- if (vertAlign) {
134
- const val = getAttr(vertAlign, W_NS, "val");
135
- if (val === "superscript" || val === "subscript" || val === "baseline") {
136
- out.vertAlign = val;
137
- }
138
- }
139
- for (const tag of ["b", "i", "u"]) {
140
- const el = findChild(rPrEl, W_NS, tag);
141
- if (el) {
142
- const val = getAttr(el, W_NS, "val");
143
- out[tag] = val !== undefined && OFF_VALUES.has(val) ? "off" : "on";
144
- }
145
- }
146
- return out;
147
- }
148
-
149
- /**
150
- * Compares two resolved values, treating an absent toggle and an explicit
151
- * "off" as the SAME state — `w:u w:val="none"` is not a deviation from "no
152
- * underline", it IS no underline spelled out (measured: 542/622 corpus
153
- * w:u are val="none"). Ported from same_state(), caught by that script's own
154
- * self-test after a first version that missed this.
155
- * @param {string | undefined} a
156
- * @param {string | undefined} b
157
- * @returns {boolean}
158
- */
159
- export function sameState(a, b) {
160
- /** @param {string | undefined} v */
161
- const isOff = (v) => v === undefined || v === "off";
162
- if (isOff(a) && isOff(b)) return true;
163
- return a === b;
164
- }
165
-
166
- /**
167
- * @typedef {object} StyleEntry
168
- * @property {string | null} basedOn
169
- * @property {Record<string, string>} pPr
170
- * @property {Record<string, string>} rPr
171
- */
172
-
173
- /**
174
- * @typedef {object} LoadedStyles
175
- * @property {{pPr: Record<string,string>, rPr: Record<string,string>}} docDefaults
176
- * @property {Record<string, StyleEntry>} styles paragraph styles only, keyed by styleId
177
- * @property {string | null} defaultParagraphStyle
178
- */
179
-
180
- /**
181
- * docDefaults + every PARAGRAPH style, keyed by styleId. Character/table/
182
- * numbering styles are read (for `total`/`custom` bookkeeping parity with
183
- * the measurement script) but not stored — `rStyle` (character styles) is
184
- * 0/81,973 in the corpus (ADR-0017), so this scope matches what is actually
185
- * used.
186
- * @param {import("./xml-scan.js").XmlElement} stylesRoot
187
- * @returns {LoadedStyles}
188
- */
189
- export function loadStyles(stylesRoot) {
190
- const dd = findChild(stylesRoot, W_NS, "docDefaults");
191
- const pPrDefault = dd && findChild(dd, W_NS, "pPrDefault");
192
- const rPrDefault = dd && findChild(dd, W_NS, "rPrDefault");
193
- const docPPr = readPPr(pPrDefault && findChild(pPrDefault, W_NS, "pPr"));
194
- const docRPr = readRPr(rPrDefault && findChild(rPrDefault, W_NS, "rPr"));
195
-
196
- /** @type {Record<string, StyleEntry>} */
197
- const styles = {};
198
- let defaultPara = null;
199
- for (const st of findChildren(stylesRoot, W_NS, "style")) {
200
- if (getAttr(st, W_NS, "type") !== "paragraph") continue;
201
- const sid = getAttr(st, W_NS, "styleId");
202
- if (sid === undefined) continue;
203
- const bo = findChild(st, W_NS, "basedOn");
204
- styles[sid] = {
205
- basedOn: bo ? (getAttr(bo, W_NS, "val") ?? null) : null,
206
- pPr: readPPr(findChild(st, W_NS, "pPr")),
207
- rPr: readRPr(findChild(st, W_NS, "rPr")),
208
- };
209
- if (getAttr(st, W_NS, "default") === "1") defaultPara = sid;
210
- }
211
- return { docDefaults: { pPr: docPPr, rPr: docRPr }, styles, defaultParagraphStyle: defaultPara };
212
- }
213
-
214
- /**
215
- * Root-first chain: [Normal, BodyText]. Cycle-safe.
216
- * @param {Record<string, StyleEntry>} styles
217
- * @param {string | null | undefined} styleId
218
- * @returns {string[]}
219
- */
220
- export function styleChain(styles, styleId) {
221
- /** @type {string[]} */
222
- const chain = [];
223
- const seen = new Set();
224
- let cur = styleId;
225
- while (cur && styles[cur] && !seen.has(cur)) {
226
- seen.add(cur);
227
- chain.push(cur);
228
- cur = styles[cur].basedOn;
229
- }
230
- chain.reverse();
231
- return chain;
232
- }
233
-
234
- /**
235
- * @typedef {[string, "docDefaults" | "style" | "direct"]} ResolvedValue
236
- */
237
-
238
- /**
239
- * Effective value + WHERE it came from, per field. Order: docDefaults ->
240
- * style chain root-first (derived wins) -> direct.
241
- * @param {Record<string,string>} defaults
242
- * @param {Record<string, StyleEntry>} styles
243
- * @param {string | null | undefined} styleId
244
- * @param {Record<string,string>} direct
245
- * @param {"pPr" | "rPr"} which
246
- * @returns {Record<string, ResolvedValue>}
247
- */
248
- export function resolve(defaults, styles, styleId, direct, which) {
249
- /** @type {Record<string, ResolvedValue>} */
250
- const eff = {};
251
- for (const [f, v] of Object.entries(defaults)) eff[f] = [v, "docDefaults"];
252
- for (const sid of styleChain(styles, styleId)) {
253
- for (const [f, v] of Object.entries(styles[sid][which])) eff[f] = [v, "style"];
254
- }
255
- for (const [f, v] of Object.entries(direct)) eff[f] = [v, "direct"];
256
- return eff;
257
- }
1
+ // styles.xml -> docDefaults / style chain / effective-value resolver. Ported
2
+ // from scripts/measure-style-inheritance.py's load_styles()/style_chain()/
3
+ // resolve()/same_state() — the algorithm, not the aggregate-measurement
4
+ // machinery (new_acc/measure_document), which belongs only to that
5
+ // standalone script. ADR-0017: "Çözümleyici sıfırdan yazılmayacak."
6
+ //
7
+ // Resolution order (docDefaults -> style chain root-first, derived wins ->
8
+ // direct), ported unchanged.
9
+
10
+ import { getAttr, findChild, findChildren } from "./xml-scan.js";
11
+ import { W_NS } from "./ns.js";
12
+ import { rejectCharacterStyle } from "./import-gate.js";
13
+
14
+ const OFF_VALUES = new Set(["0", "false", "none"]);
15
+
16
+ /**
17
+ * A `w:pPr` element -> flat {field: value}. Absent field = not set here.
18
+ * @param {import("./xml-scan.js").XmlElement | undefined} pPrEl
19
+ * @returns {Record<string, string>}
20
+ */
21
+ export function readPPr(pPrEl) {
22
+ /** @type {Record<string, string>} */
23
+ const out = {};
24
+ if (!pPrEl) return out;
25
+ const jc = findChild(pPrEl, W_NS, "jc");
26
+ if (jc) {
27
+ const val = getAttr(jc, W_NS, "val");
28
+ if (val !== undefined) out.jc = val;
29
+ }
30
+ const ind = findChild(pPrEl, W_NS, "ind");
31
+ if (ind) {
32
+ for (const a of ["left", "right", "firstLine", "hanging"]) {
33
+ const v = getAttr(ind, W_NS, a);
34
+ if (v !== undefined) out[`ind.${a}`] = v;
35
+ }
36
+ }
37
+ const sp = findChild(pPrEl, W_NS, "spacing");
38
+ if (sp) {
39
+ for (const a of ["before", "after", "line", "lineRule"]) {
40
+ const v = getAttr(sp, W_NS, a);
41
+ if (v !== undefined) out[`spacing.${a}`] = v;
42
+ }
43
+ }
44
+ // Raw numId/ilvl only -- resolving them to a display marker needs
45
+ // numbering.xml, which this function has no access to (it is called for
46
+ // BOTH a style's own pPr and a paragraph's direct pPr, same as jc/ind/
47
+ // spacing above, so direct numPr correctly overrides a style's numPr
48
+ // through the existing resolve() cascade). import.js's internPPrRef adds
49
+ // the resolved `numPr.marker` field after this cascade runs.
50
+ const numPr = findChild(pPrEl, W_NS, "numPr");
51
+ if (numPr) {
52
+ const ilvl = findChild(numPr, W_NS, "ilvl");
53
+ if (ilvl) {
54
+ const v = getAttr(ilvl, W_NS, "val");
55
+ if (v !== undefined) out["numPr.ilvl"] = v;
56
+ }
57
+ const numId = findChild(numPr, W_NS, "numId");
58
+ if (numId) {
59
+ const v = getAttr(numId, W_NS, "val");
60
+ if (v !== undefined) out["numPr.numId"] = v;
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /**
67
+ * A `w:rPr` element -> flat {field: value}. `font` collapses w:rFonts'
68
+ * ascii/hAnsi/cs in that order; a theme-only rFonts yields the sentinel
69
+ * "(tema)" — theme1.xml is deliberately not opened (ADR-0017 DOĞRULANMALI
70
+ * #6), and pretending the field is unset would hide that.
71
+ * A `w:rStyle` child (a reference to a character style) is rejected rather
72
+ * than silently ignored — ADR-0020 Karar 4: 0/81.973 real corpus runs carry
73
+ * one, so a run that does is corpus-external and this resolver has no
74
+ * mechanism to fold the referenced style's properties in.
75
+ * `color` is DISPLAY-only (render, not the `Marks`/toggle path — 2026-08-31
76
+ * decision, ADR-0026): a 6-hex-digit `w:val` is read straight through; a
77
+ * `w:themeColor` reference or `w:val="auto"` is left unset rather than
78
+ * guessed — measured 211 real corpus `w:color` elements, 0 carry either
79
+ * (theme1.xml is deliberately not opened, same reason as the `rFonts` theme
80
+ * sentinel above), so this is not a real-corpus gap today, only a documented
81
+ * refusal to invent a value.
82
+ * `vertAlign` is DISPLAY-only, same class as `color` (2026-09-01): only the
83
+ * three ST_VerticalAlignRun enum values (`superscript`/`subscript`/
84
+ * `baseline`) are read straight through; anything else is left unset rather
85
+ * than guessed — measured 0/576 real corpus `w:vertAlign` elements carry a
86
+ * fourth value.
87
+ * @param {import("./xml-scan.js").XmlElement | undefined} rPrEl
88
+ * @returns {Record<string, string>}
89
+ */
90
+ export function readRPr(rPrEl) {
91
+ /** @type {Record<string, string>} */
92
+ const out = {};
93
+ if (!rPrEl) return out;
94
+ if (findChild(rPrEl, W_NS, "rStyle")) {
95
+ rejectCharacterStyle(`w:rPr at byte ${rPrEl.start}`);
96
+ }
97
+ const sz = findChild(rPrEl, W_NS, "sz");
98
+ if (sz) {
99
+ const val = getAttr(sz, W_NS, "val");
100
+ if (val) out.sz = val;
101
+ }
102
+ const rf = findChild(rPrEl, W_NS, "rFonts");
103
+ if (rf) {
104
+ let name;
105
+ for (const a of ["ascii", "hAnsi", "cs"]) {
106
+ const v = getAttr(rf, W_NS, a);
107
+ if (v) {
108
+ name = v;
109
+ break;
110
+ }
111
+ }
112
+ if (name === undefined) {
113
+ for (const a of ["asciiTheme", "hAnsiTheme", "cstheme"]) {
114
+ if (getAttr(rf, W_NS, a)) {
115
+ name = "(tema)";
116
+ break;
117
+ }
118
+ }
119
+ }
120
+ if (name !== undefined) out.font = name;
121
+ }
122
+ const color = findChild(rPrEl, W_NS, "color");
123
+ if (color) {
124
+ const val = getAttr(color, W_NS, "val");
125
+ // Only a genuine 6-hex-digit value — "auto" and a themeColor reference
126
+ // (both absent from this val, or this val itself non-hex) are left
127
+ // unset rather than guessed, see this function's own doc comment above.
128
+ if (val !== undefined && /^[0-9A-Fa-f]{6}$/.test(val)) {
129
+ out.color = val;
130
+ }
131
+ }
132
+ const vertAlign = findChild(rPrEl, W_NS, "vertAlign");
133
+ if (vertAlign) {
134
+ const val = getAttr(vertAlign, W_NS, "val");
135
+ if (val === "superscript" || val === "subscript" || val === "baseline") {
136
+ out.vertAlign = val;
137
+ }
138
+ }
139
+ for (const tag of ["b", "i", "u"]) {
140
+ const el = findChild(rPrEl, W_NS, tag);
141
+ if (el) {
142
+ const val = getAttr(el, W_NS, "val");
143
+ out[tag] = val !== undefined && OFF_VALUES.has(val) ? "off" : "on";
144
+ }
145
+ }
146
+ return out;
147
+ }
148
+
149
+ /**
150
+ * Compares two resolved values, treating an absent toggle and an explicit
151
+ * "off" as the SAME state — `w:u w:val="none"` is not a deviation from "no
152
+ * underline", it IS no underline spelled out (measured: 542/622 corpus
153
+ * w:u are val="none"). Ported from same_state(), caught by that script's own
154
+ * self-test after a first version that missed this.
155
+ * @param {string | undefined} a
156
+ * @param {string | undefined} b
157
+ * @returns {boolean}
158
+ */
159
+ export function sameState(a, b) {
160
+ /** @param {string | undefined} v */
161
+ const isOff = (v) => v === undefined || v === "off";
162
+ if (isOff(a) && isOff(b)) return true;
163
+ return a === b;
164
+ }
165
+
166
+ /**
167
+ * @typedef {object} StyleEntry
168
+ * @property {string | null} basedOn
169
+ * @property {Record<string, string>} pPr
170
+ * @property {Record<string, string>} rPr
171
+ */
172
+
173
+ /**
174
+ * @typedef {object} LoadedStyles
175
+ * @property {{pPr: Record<string,string>, rPr: Record<string,string>}} docDefaults
176
+ * @property {Record<string, StyleEntry>} styles paragraph styles only, keyed by styleId
177
+ * @property {string | null} defaultParagraphStyle
178
+ */
179
+
180
+ /**
181
+ * docDefaults + every PARAGRAPH style, keyed by styleId. Character/table/
182
+ * numbering styles are read (for `total`/`custom` bookkeeping parity with
183
+ * the measurement script) but not stored — `rStyle` (character styles) is
184
+ * 0/81,973 in the corpus (ADR-0017), so this scope matches what is actually
185
+ * used.
186
+ * @param {import("./xml-scan.js").XmlElement} stylesRoot
187
+ * @returns {LoadedStyles}
188
+ */
189
+ export function loadStyles(stylesRoot) {
190
+ const dd = findChild(stylesRoot, W_NS, "docDefaults");
191
+ const pPrDefault = dd && findChild(dd, W_NS, "pPrDefault");
192
+ const rPrDefault = dd && findChild(dd, W_NS, "rPrDefault");
193
+ const docPPr = readPPr(pPrDefault && findChild(pPrDefault, W_NS, "pPr"));
194
+ const docRPr = readRPr(rPrDefault && findChild(rPrDefault, W_NS, "rPr"));
195
+
196
+ /** @type {Record<string, StyleEntry>} */
197
+ const styles = {};
198
+ let defaultPara = null;
199
+ for (const st of findChildren(stylesRoot, W_NS, "style")) {
200
+ if (getAttr(st, W_NS, "type") !== "paragraph") continue;
201
+ const sid = getAttr(st, W_NS, "styleId");
202
+ if (sid === undefined) continue;
203
+ const bo = findChild(st, W_NS, "basedOn");
204
+ styles[sid] = {
205
+ basedOn: bo ? (getAttr(bo, W_NS, "val") ?? null) : null,
206
+ pPr: readPPr(findChild(st, W_NS, "pPr")),
207
+ rPr: readRPr(findChild(st, W_NS, "rPr")),
208
+ };
209
+ if (getAttr(st, W_NS, "default") === "1") defaultPara = sid;
210
+ }
211
+ return { docDefaults: { pPr: docPPr, rPr: docRPr }, styles, defaultParagraphStyle: defaultPara };
212
+ }
213
+
214
+ /**
215
+ * Root-first chain: [Normal, BodyText]. Cycle-safe.
216
+ * @param {Record<string, StyleEntry>} styles
217
+ * @param {string | null | undefined} styleId
218
+ * @returns {string[]}
219
+ */
220
+ export function styleChain(styles, styleId) {
221
+ /** @type {string[]} */
222
+ const chain = [];
223
+ const seen = new Set();
224
+ let cur = styleId;
225
+ while (cur && styles[cur] && !seen.has(cur)) {
226
+ seen.add(cur);
227
+ chain.push(cur);
228
+ cur = styles[cur].basedOn;
229
+ }
230
+ chain.reverse();
231
+ return chain;
232
+ }
233
+
234
+ /**
235
+ * @typedef {[string, "docDefaults" | "style" | "direct"]} ResolvedValue
236
+ */
237
+
238
+ /**
239
+ * Effective value + WHERE it came from, per field. Order: docDefaults ->
240
+ * style chain root-first (derived wins) -> direct.
241
+ * @param {Record<string,string>} defaults
242
+ * @param {Record<string, StyleEntry>} styles
243
+ * @param {string | null | undefined} styleId
244
+ * @param {Record<string,string>} direct
245
+ * @param {"pPr" | "rPr"} which
246
+ * @returns {Record<string, ResolvedValue>}
247
+ */
248
+ export function resolve(defaults, styles, styleId, direct, which) {
249
+ /** @type {Record<string, ResolvedValue>} */
250
+ const eff = {};
251
+ for (const [f, v] of Object.entries(defaults)) eff[f] = [v, "docDefaults"];
252
+ for (const sid of styleChain(styles, styleId)) {
253
+ for (const [f, v] of Object.entries(styles[sid][which])) eff[f] = [v, "style"];
254
+ }
255
+ for (const [f, v] of Object.entries(direct)) eff[f] = [v, "direct"];
256
+ return eff;
257
+ }
@@ -1,43 +1,43 @@
1
- // EMU <-> pt, in ONE place. OOXML measures geometry in EMU (English Metric
2
- // Units); the model measures it in points. Import needs one direction, the
3
- // geometry export (P6) needs the other, and a second copy of the constant in a
4
- // second file is exactly how the two directions drift apart.
5
-
6
- export const EMU_PER_PT = 12700;
7
-
8
- /**
9
- * `round(value / 12700, 2)` — measured 2026-08-27: across all 7,297 unique EMU
10
- * values actually present in the 46-document corpus (posOffset, extent, off,
11
- * ext), Python's round() (round-half-to-even) and this expression never
12
- * disagree. A bit-exact port of Python's round() was NOT written — there was
13
- * nothing, measured, for it to fix.
14
- * @param {number} emu
15
- * @returns {number}
16
- */
17
- export function emuToPt(emu) {
18
- return Math.round((emu / EMU_PER_PT) * 100) / 100;
19
- }
20
-
21
- /**
22
- * pt -> EMU, the geometry export's write direction.
23
- *
24
- * 🔴 NOT a lossless inverse of `emuToPt`, and callers must not treat it as one:
25
- * `emuToPt` rounds to 2 decimals (~0.005 pt ~ 64 EMU), so a value that made a
26
- * round trip can differ from the original integer. Measured 2026-08-29 on the
27
- * corpus: 791 of 7,990 `cx` values and 482 `cy` values do NOT survive
28
- * `emuToPt -> ptToEmu` (AA01's first box: 6674484 -> 525.55 pt -> 6674485).
29
- * That is why the splice writes ONLY the coordinates the user actually changed
30
- * and carries every untouched one through as its original bytes.
31
- *
32
- * Throws on a non-finite input rather than emitting it: `Math.round(NaN)` is
33
- * `NaN` and `String(NaN)` is `"NaN"`, which would reach document.xml and make
34
- * Word refuse the file.
35
- * @param {number} pt
36
- * @returns {number} integer EMU
37
- */
38
- export function ptToEmu(pt) {
39
- if (!Number.isFinite(pt)) {
40
- throw new TypeError(`ptToEmu: non-finite pt value (${pt}) — refusing to write it to document.xml`);
41
- }
42
- return Math.round(pt * EMU_PER_PT);
43
- }
1
+ // EMU <-> pt, in ONE place. OOXML measures geometry in EMU (English Metric
2
+ // Units); the model measures it in points. Import needs one direction, the
3
+ // geometry export (P6) needs the other, and a second copy of the constant in a
4
+ // second file is exactly how the two directions drift apart.
5
+
6
+ export const EMU_PER_PT = 12700;
7
+
8
+ /**
9
+ * `round(value / 12700, 2)` — measured 2026-08-27: across all 7,297 unique EMU
10
+ * values actually present in the 46-document corpus (posOffset, extent, off,
11
+ * ext), Python's round() (round-half-to-even) and this expression never
12
+ * disagree. A bit-exact port of Python's round() was NOT written — there was
13
+ * nothing, measured, for it to fix.
14
+ * @param {number} emu
15
+ * @returns {number}
16
+ */
17
+ export function emuToPt(emu) {
18
+ return Math.round((emu / EMU_PER_PT) * 100) / 100;
19
+ }
20
+
21
+ /**
22
+ * pt -> EMU, the geometry export's write direction.
23
+ *
24
+ * 🔴 NOT a lossless inverse of `emuToPt`, and callers must not treat it as one:
25
+ * `emuToPt` rounds to 2 decimals (~0.005 pt ~ 64 EMU), so a value that made a
26
+ * round trip can differ from the original integer. Measured 2026-08-29 on the
27
+ * corpus: 791 of 7,990 `cx` values and 482 `cy` values do NOT survive
28
+ * `emuToPt -> ptToEmu` (AA01's first box: 6674484 -> 525.55 pt -> 6674485).
29
+ * That is why the splice writes ONLY the coordinates the user actually changed
30
+ * and carries every untouched one through as its original bytes.
31
+ *
32
+ * Throws on a non-finite input rather than emitting it: `Math.round(NaN)` is
33
+ * `NaN` and `String(NaN)` is `"NaN"`, which would reach document.xml and make
34
+ * Word refuse the file.
35
+ * @param {number} pt
36
+ * @returns {number} integer EMU
37
+ */
38
+ export function ptToEmu(pt) {
39
+ if (!Number.isFinite(pt)) {
40
+ throw new TypeError(`ptToEmu: non-finite pt value (${pt}) — refusing to write it to document.xml`);
41
+ }
42
+ return Math.round(pt * EMU_PER_PT);
43
+ }