@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,290 +1,290 @@
1
- // word/numbering.xml -> a resolved marker per numbered paragraph OCCURRENCE.
2
- // Import-time only: import.js's internPPrRef calls createNumberingCounter's
3
- // next() once per numPr paragraph, in document order, and bakes the result
4
- // into that paragraph's own pPr props — the renderer never needs this module
5
- // or numbering.xml itself.
6
- //
7
- // SCOPE (measured 2026-08-30, 46/46 real corpus): 30/46 documents carry
8
- // word/numbering.xml; 4,266 w:lvl definitions, 4,239 bullet / 27 ordinal
9
- // (14 decimal, 10 lowerLetter, 2 lowerRoman, 1 upperLetter). w:lvlOverride:
10
- // 0/474 w:num entries -- not implemented, nothing in this corpus needs it.
11
- // w:lvlRestart: 0/474 -- not implemented; the DEFAULT OOXML restart rule
12
- // (a level's count resets whenever a SHALLOWER level of the SAME numId is
13
- // encountered) is implemented instead, unconditionally, matching the corpus.
14
- //
15
- // REBUILD NOTE (2026-08-31): v1 of this module returned a STATIC per-level
16
- // placeholder for ordinal formats ("1.", "a.", ...) -- deliberately, per that
17
- // turn's own explicit scope exclusion ("real list counting is NOT written").
18
- // Measured against real Word ground truth (user screenshot): consecutive
19
- // paragraphs at the same (numId, ilvl) all showed the identical letter/roman
20
- // numeral instead of incrementing, and a list continued numbering across a
21
- // container/page boundary in Word but not here. Root cause was NOT a wrong
22
- // counter key or a container-scoped reset -- there was no counter variable
23
- // anywhere in this module or in import.js's internPPrRef; resolveMarker was a
24
- // pure function of (numFmt, lvlText) alone, with no paragraph-occurrence
25
- // input at all. This version adds one.
26
- //
27
- // One thing this module still deliberately does NOT do (ADR-0006-class line:
28
- // pagination/reflow logic is the browser's job, not ours):
29
- // - Wingdings/Symbol GLYPH SUBSTITUTION for bullets. A bullet level whose
30
- // w:lvlText is a Private Use Area codepoint (measured: 387/4,239 bullet
31
- // levels, e.g. U+F076) means the true glyph lives in a font this importer
32
- // never loads/reads (ADR-0020 DOĞRULANMALI #6, theme1.xml is deliberately
33
- // not opened). Falls back to a default bullet character, flagged
34
- // unresolved -- unrelated to counting, unchanged from v1.
35
-
36
- import { findChild, findChildren, getAttr } from "./xml-scan.js";
37
- import { W_NS } from "./ns.js";
38
-
39
- export const DEFAULT_BULLET = "•";
40
-
41
- const PUA_START = 0xe000;
42
- const PUA_END = 0xf8ff;
43
-
44
- /**
45
- * Word's lowerLetter/upperLetter sequencing: the letter itself cycles a..z
46
- * (NOT spreadsheet-column base-26 positional notation), and past z the
47
- * letter REPEATS -- 27 is "aa" meaning "a" twice, not "aa" as a two-digit
48
- * base-26 number. n is 1-based.
49
- * @param {number} n
50
- * @returns {string}
51
- */
52
- function lowerLetterOf(n) {
53
- const letter = String.fromCharCode(97 + ((n - 1) % 26));
54
- const repeat = Math.floor((n - 1) / 26) + 1;
55
- return letter.repeat(repeat);
56
- }
57
-
58
- /** @type {ReadonlyArray<[number, string]>} */
59
- const ROMAN_TABLE = Object.freeze([
60
- [1000, "m"], [900, "cm"], [500, "d"], [400, "cd"], [100, "c"], [90, "xc"],
61
- [50, "l"], [40, "xl"], [10, "x"], [9, "ix"], [5, "v"], [4, "iv"], [1, "i"],
62
- ]);
63
-
64
- /**
65
- * @param {number} n 1-based, positive
66
- * @returns {string}
67
- */
68
- function lowerRomanOf(n) {
69
- let out = "";
70
- let remaining = n;
71
- for (const [value, symbol] of ROMAN_TABLE) {
72
- while (remaining >= value) {
73
- out += symbol;
74
- remaining -= value;
75
- }
76
- }
77
- return out;
78
- }
79
-
80
- /**
81
- * @param {string} numFmt
82
- * @param {number} n 1-based
83
- * @returns {string | null} null for a numFmt this module does not format
84
- */
85
- function formatOrdinal(numFmt, n) {
86
- switch (numFmt) {
87
- case "decimal":
88
- return String(n);
89
- case "lowerLetter":
90
- return lowerLetterOf(n);
91
- case "upperLetter":
92
- return lowerLetterOf(n).toUpperCase();
93
- case "lowerRoman":
94
- return lowerRomanOf(n);
95
- case "upperRoman":
96
- return lowerRomanOf(n).toUpperCase();
97
- default:
98
- return null;
99
- }
100
- }
101
-
102
- /**
103
- * @typedef {
104
- * | {kind: "bullet", bulletMarker: string, bulletResolved: boolean}
105
- * | {kind: "ordinal", numFmt: string, lvlText: string, start: number}
106
- * | {kind: "unresolved"}
107
- * } LevelDef
108
- */
109
-
110
- /**
111
- * @param {string | null} numFmt
112
- * @param {string | null} lvlText
113
- * @returns {{bulletMarker: string, bulletResolved: boolean} | null} null if
114
- * numFmt is not "bullet" (an ordinal level, resolved per-occurrence instead)
115
- */
116
- function resolveBullet(numFmt, lvlText) {
117
- if (numFmt !== "bullet") return null;
118
- if (lvlText !== null && lvlText.length > 0) {
119
- const cp = lvlText.codePointAt(0);
120
- if (cp !== undefined && (cp < PUA_START || cp > PUA_END)) {
121
- return { bulletMarker: lvlText, bulletResolved: true };
122
- }
123
- }
124
- // Missing lvlText, or a PUA codepoint this importer cannot map to a real
125
- // glyph without loading the level's font (not implemented, see header).
126
- return { bulletMarker: DEFAULT_BULLET, bulletResolved: false };
127
- }
128
-
129
- /**
130
- * `word/numbering.xml`'s root -> `Map<numId, Map<ilvl, LevelDef>>`. Every
131
- * numId/ilvl pair the document *declares* is captured once here; counting
132
- * itself happens later, per paragraph occurrence, via `createNumberingCounter`.
133
- * @param {import("./xml-scan.js").XmlElement} numberingRoot
134
- * @returns {Map<string, Map<string, LevelDef>>}
135
- */
136
- export function parseNumbering(numberingRoot) {
137
- /** @type {Map<string, string>} numId -> abstractNumId */
138
- const numToAbstract = new Map();
139
- for (const numEl of findChildren(numberingRoot, W_NS, "num")) {
140
- const numId = getAttr(numEl, W_NS, "numId");
141
- // w:lvlOverride (whose own children can include w:startOverride, a
142
- // per-INSTANCE start value that would silently outrank the abstract
143
- // level's own w:start below) -- measured 0/474 real corpus w:num
144
- // entries (and, independently, 0/30 word/numbering.xml-carrying
145
- // documents for w:startOverride specifically, 2026-08-31). Not
146
- // implemented: rather than silently using the abstract default and
147
- // producing a WRONG start count for a corpus-external document that
148
- // does carry one, this throws loudly the moment it is seen.
149
- if (findChild(numEl, W_NS, "lvlOverride") !== undefined) {
150
- throw new Error(
151
- `numbering.js: w:num[numId=${numId ?? "?"}] carries a w:lvlOverride ` +
152
- "(possibly w:startOverride) -- not implemented (measured 0/474 real corpus " +
153
- "w:num entries carry one); the counter would silently use the abstract " +
154
- "level's own w:start instead of this instance's override, producing a wrong count",
155
- );
156
- }
157
- const abstractIdEl = findChild(numEl, W_NS, "abstractNumId");
158
- const abstractId = abstractIdEl ? getAttr(abstractIdEl, W_NS, "val") : undefined;
159
- if (numId !== undefined && abstractId !== undefined) {
160
- numToAbstract.set(numId, abstractId);
161
- }
162
- }
163
-
164
- /** @type {Map<string, Map<string, LevelDef>>} abstractNumId -> ilvl -> LevelDef */
165
- const abstractLevels = new Map();
166
- for (const absEl of findChildren(numberingRoot, W_NS, "abstractNum")) {
167
- const absId = getAttr(absEl, W_NS, "abstractNumId");
168
- if (absId === undefined) continue;
169
- /** @type {Map<string, LevelDef>} */
170
- const levels = new Map();
171
- for (const lvlEl of findChildren(absEl, W_NS, "lvl")) {
172
- const ilvl = getAttr(lvlEl, W_NS, "ilvl");
173
- if (ilvl === undefined) continue;
174
- const numFmtEl = findChild(lvlEl, W_NS, "numFmt");
175
- const numFmt = numFmtEl ? (getAttr(numFmtEl, W_NS, "val") ?? null) : null;
176
- const lvlTextEl = findChild(lvlEl, W_NS, "lvlText");
177
- const lvlText = lvlTextEl ? (getAttr(lvlTextEl, W_NS, "val") ?? null) : null;
178
-
179
- const bullet = resolveBullet(numFmt, lvlText);
180
- if (bullet !== null) {
181
- levels.set(ilvl, { kind: "bullet", ...bullet });
182
- continue;
183
- }
184
- if (numFmt !== null && formatOrdinal(numFmt, 1) !== null) {
185
- const startEl = findChild(lvlEl, W_NS, "start");
186
- const startVal = startEl ? Number.parseInt(getAttr(startEl, W_NS, "val") ?? "1", 10) : 1;
187
- levels.set(ilvl, {
188
- kind: "ordinal",
189
- numFmt,
190
- lvlText: lvlText !== null && lvlText.length > 0 ? lvlText : "%1.",
191
- start: Number.isFinite(startVal) ? startVal : 1,
192
- });
193
- continue;
194
- }
195
- // An unrecognized numFmt (none observed in the corpus -- e.g.
196
- // chineseCounting, ordinalText). Do not guess a glyph or a counter for it.
197
- levels.set(ilvl, { kind: "unresolved" });
198
- }
199
- abstractLevels.set(absId, levels);
200
- }
201
-
202
- /** @type {Map<string, Map<string, LevelDef>>} */
203
- const markersByNumId = new Map();
204
- for (const [numId, absId] of numToAbstract) {
205
- const levels = abstractLevels.get(absId);
206
- if (levels !== undefined) {
207
- markersByNumId.set(numId, levels);
208
- }
209
- }
210
- return markersByNumId;
211
- }
212
-
213
- /**
214
- * A running counter over (numId, ilvl), advanced once per paragraph
215
- * OCCURRENCE by calling `next` in DOCUMENT ORDER -- the only order this
216
- * makes sense in, since "the next item in this list" is a document-order
217
- * concept, not a per-container or per-page one (measured against real Word:
218
- * a list continues across a container/page boundary). Scope is therefore the
219
- * WHOLE document: one counter instance is created per `convertDocument` call
220
- * (import.js) and threaded through every anchor/container/paragraph that
221
- * call processes, in the single sequential pass `collectAnchors` + the
222
- * anchor loop already walk the document in.
223
- *
224
- * Multi-level restart (OOXML default, w:lvlRestart not implemented -- 0/474
225
- * real corpus w:num entries carry it): encountering ANY level of a numId
226
- * resets every DEEPER level's counter for that SAME numId, so a fresh "a."
227
- * at ilvl 0 restarts a sub-list's "i., ii., ..." at ilvl 1 back to "i.".
228
- * Encountering ilvl 1 again with NO intervening ilvl 0 (e.g. across a
229
- * container boundary) does NOT reset it -- it continues.
230
- *
231
- * @param {Map<string, Map<string, import("./numbering.js").LevelDef>>} numDefs
232
- */
233
- export function createNumberingCounter(numDefs) {
234
- /** @type {Map<string, Map<string, number>>} numId -> ilvl -> current count */
235
- const counts = new Map();
236
-
237
- /**
238
- * @param {string} numId
239
- * @param {string} ilvl
240
- * @returns {{marker: string, resolved: boolean} | undefined} undefined if
241
- * this (numId, ilvl) has no numbering.xml definition at all -- the
242
- * caller (import.js) falls back to DEFAULT_BULLET, this module never guesses
243
- */
244
- function next(numId, ilvl) {
245
- const level = numDefs.get(numId)?.get(ilvl);
246
- if (level === undefined || level.kind === "unresolved") {
247
- return undefined;
248
- }
249
- if (level.kind === "bullet") {
250
- // Bullets are not counted, but encountering ANY level (bullet or
251
- // ordinal) still restarts deeper ordinal levels of the same numId --
252
- // the restart rule is about level hierarchy, not format.
253
- resetDeeperLevels(numId, ilvl);
254
- return { marker: level.bulletMarker, resolved: level.bulletResolved };
255
- }
256
-
257
- let byIlvl = counts.get(numId);
258
- if (byIlvl === undefined) {
259
- byIlvl = new Map();
260
- counts.set(numId, byIlvl);
261
- }
262
- const current = byIlvl.get(ilvl);
263
- const n = current === undefined ? level.start : current + 1;
264
- byIlvl.set(ilvl, n);
265
- resetDeeperLevels(numId, ilvl);
266
-
267
- const formatted = formatOrdinal(level.numFmt, n);
268
- // formatOrdinal cannot fail here: parseNumbering only assigns kind:
269
- // "ordinal" when formatOrdinal(numFmt, 1) already returned non-null.
270
- const marker = level.lvlText.replace(/%\d/g, formatted ?? "");
271
- return { marker, resolved: true };
272
- }
273
-
274
- /**
275
- * @param {string} numId
276
- * @param {string} ilvl
277
- */
278
- function resetDeeperLevels(numId, ilvl) {
279
- const byIlvl = counts.get(numId);
280
- if (byIlvl === undefined) return;
281
- const thisDepth = Number.parseInt(ilvl, 10);
282
- for (const trackedIlvl of [...byIlvl.keys()]) {
283
- if (Number.parseInt(trackedIlvl, 10) > thisDepth) {
284
- byIlvl.delete(trackedIlvl);
285
- }
286
- }
287
- }
288
-
289
- return { next };
290
- }
1
+ // word/numbering.xml -> a resolved marker per numbered paragraph OCCURRENCE.
2
+ // Import-time only: import.js's internPPrRef calls createNumberingCounter's
3
+ // next() once per numPr paragraph, in document order, and bakes the result
4
+ // into that paragraph's own pPr props — the renderer never needs this module
5
+ // or numbering.xml itself.
6
+ //
7
+ // SCOPE (measured 2026-08-30, 46/46 real corpus): 30/46 documents carry
8
+ // word/numbering.xml; 4,266 w:lvl definitions, 4,239 bullet / 27 ordinal
9
+ // (14 decimal, 10 lowerLetter, 2 lowerRoman, 1 upperLetter). w:lvlOverride:
10
+ // 0/474 w:num entries -- not implemented, nothing in this corpus needs it.
11
+ // w:lvlRestart: 0/474 -- not implemented; the DEFAULT OOXML restart rule
12
+ // (a level's count resets whenever a SHALLOWER level of the SAME numId is
13
+ // encountered) is implemented instead, unconditionally, matching the corpus.
14
+ //
15
+ // REBUILD NOTE (2026-08-31): v1 of this module returned a STATIC per-level
16
+ // placeholder for ordinal formats ("1.", "a.", ...) -- deliberately, per that
17
+ // turn's own explicit scope exclusion ("real list counting is NOT written").
18
+ // Measured against real Word ground truth (user screenshot): consecutive
19
+ // paragraphs at the same (numId, ilvl) all showed the identical letter/roman
20
+ // numeral instead of incrementing, and a list continued numbering across a
21
+ // container/page boundary in Word but not here. Root cause was NOT a wrong
22
+ // counter key or a container-scoped reset -- there was no counter variable
23
+ // anywhere in this module or in import.js's internPPrRef; resolveMarker was a
24
+ // pure function of (numFmt, lvlText) alone, with no paragraph-occurrence
25
+ // input at all. This version adds one.
26
+ //
27
+ // One thing this module still deliberately does NOT do (ADR-0006-class line:
28
+ // pagination/reflow logic is the browser's job, not ours):
29
+ // - Wingdings/Symbol GLYPH SUBSTITUTION for bullets. A bullet level whose
30
+ // w:lvlText is a Private Use Area codepoint (measured: 387/4,239 bullet
31
+ // levels, e.g. U+F076) means the true glyph lives in a font this importer
32
+ // never loads/reads (ADR-0020 DOĞRULANMALI #6, theme1.xml is deliberately
33
+ // not opened). Falls back to a default bullet character, flagged
34
+ // unresolved -- unrelated to counting, unchanged from v1.
35
+
36
+ import { findChild, findChildren, getAttr } from "./xml-scan.js";
37
+ import { W_NS } from "./ns.js";
38
+
39
+ export const DEFAULT_BULLET = "•";
40
+
41
+ const PUA_START = 0xe000;
42
+ const PUA_END = 0xf8ff;
43
+
44
+ /**
45
+ * Word's lowerLetter/upperLetter sequencing: the letter itself cycles a..z
46
+ * (NOT spreadsheet-column base-26 positional notation), and past z the
47
+ * letter REPEATS -- 27 is "aa" meaning "a" twice, not "aa" as a two-digit
48
+ * base-26 number. n is 1-based.
49
+ * @param {number} n
50
+ * @returns {string}
51
+ */
52
+ function lowerLetterOf(n) {
53
+ const letter = String.fromCharCode(97 + ((n - 1) % 26));
54
+ const repeat = Math.floor((n - 1) / 26) + 1;
55
+ return letter.repeat(repeat);
56
+ }
57
+
58
+ /** @type {ReadonlyArray<[number, string]>} */
59
+ const ROMAN_TABLE = Object.freeze([
60
+ [1000, "m"], [900, "cm"], [500, "d"], [400, "cd"], [100, "c"], [90, "xc"],
61
+ [50, "l"], [40, "xl"], [10, "x"], [9, "ix"], [5, "v"], [4, "iv"], [1, "i"],
62
+ ]);
63
+
64
+ /**
65
+ * @param {number} n 1-based, positive
66
+ * @returns {string}
67
+ */
68
+ function lowerRomanOf(n) {
69
+ let out = "";
70
+ let remaining = n;
71
+ for (const [value, symbol] of ROMAN_TABLE) {
72
+ while (remaining >= value) {
73
+ out += symbol;
74
+ remaining -= value;
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+
80
+ /**
81
+ * @param {string} numFmt
82
+ * @param {number} n 1-based
83
+ * @returns {string | null} null for a numFmt this module does not format
84
+ */
85
+ function formatOrdinal(numFmt, n) {
86
+ switch (numFmt) {
87
+ case "decimal":
88
+ return String(n);
89
+ case "lowerLetter":
90
+ return lowerLetterOf(n);
91
+ case "upperLetter":
92
+ return lowerLetterOf(n).toUpperCase();
93
+ case "lowerRoman":
94
+ return lowerRomanOf(n);
95
+ case "upperRoman":
96
+ return lowerRomanOf(n).toUpperCase();
97
+ default:
98
+ return null;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * @typedef {
104
+ * | {kind: "bullet", bulletMarker: string, bulletResolved: boolean}
105
+ * | {kind: "ordinal", numFmt: string, lvlText: string, start: number}
106
+ * | {kind: "unresolved"}
107
+ * } LevelDef
108
+ */
109
+
110
+ /**
111
+ * @param {string | null} numFmt
112
+ * @param {string | null} lvlText
113
+ * @returns {{bulletMarker: string, bulletResolved: boolean} | null} null if
114
+ * numFmt is not "bullet" (an ordinal level, resolved per-occurrence instead)
115
+ */
116
+ function resolveBullet(numFmt, lvlText) {
117
+ if (numFmt !== "bullet") return null;
118
+ if (lvlText !== null && lvlText.length > 0) {
119
+ const cp = lvlText.codePointAt(0);
120
+ if (cp !== undefined && (cp < PUA_START || cp > PUA_END)) {
121
+ return { bulletMarker: lvlText, bulletResolved: true };
122
+ }
123
+ }
124
+ // Missing lvlText, or a PUA codepoint this importer cannot map to a real
125
+ // glyph without loading the level's font (not implemented, see header).
126
+ return { bulletMarker: DEFAULT_BULLET, bulletResolved: false };
127
+ }
128
+
129
+ /**
130
+ * `word/numbering.xml`'s root -> `Map<numId, Map<ilvl, LevelDef>>`. Every
131
+ * numId/ilvl pair the document *declares* is captured once here; counting
132
+ * itself happens later, per paragraph occurrence, via `createNumberingCounter`.
133
+ * @param {import("./xml-scan.js").XmlElement} numberingRoot
134
+ * @returns {Map<string, Map<string, LevelDef>>}
135
+ */
136
+ export function parseNumbering(numberingRoot) {
137
+ /** @type {Map<string, string>} numId -> abstractNumId */
138
+ const numToAbstract = new Map();
139
+ for (const numEl of findChildren(numberingRoot, W_NS, "num")) {
140
+ const numId = getAttr(numEl, W_NS, "numId");
141
+ // w:lvlOverride (whose own children can include w:startOverride, a
142
+ // per-INSTANCE start value that would silently outrank the abstract
143
+ // level's own w:start below) -- measured 0/474 real corpus w:num
144
+ // entries (and, independently, 0/30 word/numbering.xml-carrying
145
+ // documents for w:startOverride specifically, 2026-08-31). Not
146
+ // implemented: rather than silently using the abstract default and
147
+ // producing a WRONG start count for a corpus-external document that
148
+ // does carry one, this throws loudly the moment it is seen.
149
+ if (findChild(numEl, W_NS, "lvlOverride") !== undefined) {
150
+ throw new Error(
151
+ `numbering.js: w:num[numId=${numId ?? "?"}] carries a w:lvlOverride ` +
152
+ "(possibly w:startOverride) -- not implemented (measured 0/474 real corpus " +
153
+ "w:num entries carry one); the counter would silently use the abstract " +
154
+ "level's own w:start instead of this instance's override, producing a wrong count",
155
+ );
156
+ }
157
+ const abstractIdEl = findChild(numEl, W_NS, "abstractNumId");
158
+ const abstractId = abstractIdEl ? getAttr(abstractIdEl, W_NS, "val") : undefined;
159
+ if (numId !== undefined && abstractId !== undefined) {
160
+ numToAbstract.set(numId, abstractId);
161
+ }
162
+ }
163
+
164
+ /** @type {Map<string, Map<string, LevelDef>>} abstractNumId -> ilvl -> LevelDef */
165
+ const abstractLevels = new Map();
166
+ for (const absEl of findChildren(numberingRoot, W_NS, "abstractNum")) {
167
+ const absId = getAttr(absEl, W_NS, "abstractNumId");
168
+ if (absId === undefined) continue;
169
+ /** @type {Map<string, LevelDef>} */
170
+ const levels = new Map();
171
+ for (const lvlEl of findChildren(absEl, W_NS, "lvl")) {
172
+ const ilvl = getAttr(lvlEl, W_NS, "ilvl");
173
+ if (ilvl === undefined) continue;
174
+ const numFmtEl = findChild(lvlEl, W_NS, "numFmt");
175
+ const numFmt = numFmtEl ? (getAttr(numFmtEl, W_NS, "val") ?? null) : null;
176
+ const lvlTextEl = findChild(lvlEl, W_NS, "lvlText");
177
+ const lvlText = lvlTextEl ? (getAttr(lvlTextEl, W_NS, "val") ?? null) : null;
178
+
179
+ const bullet = resolveBullet(numFmt, lvlText);
180
+ if (bullet !== null) {
181
+ levels.set(ilvl, { kind: "bullet", ...bullet });
182
+ continue;
183
+ }
184
+ if (numFmt !== null && formatOrdinal(numFmt, 1) !== null) {
185
+ const startEl = findChild(lvlEl, W_NS, "start");
186
+ const startVal = startEl ? Number.parseInt(getAttr(startEl, W_NS, "val") ?? "1", 10) : 1;
187
+ levels.set(ilvl, {
188
+ kind: "ordinal",
189
+ numFmt,
190
+ lvlText: lvlText !== null && lvlText.length > 0 ? lvlText : "%1.",
191
+ start: Number.isFinite(startVal) ? startVal : 1,
192
+ });
193
+ continue;
194
+ }
195
+ // An unrecognized numFmt (none observed in the corpus -- e.g.
196
+ // chineseCounting, ordinalText). Do not guess a glyph or a counter for it.
197
+ levels.set(ilvl, { kind: "unresolved" });
198
+ }
199
+ abstractLevels.set(absId, levels);
200
+ }
201
+
202
+ /** @type {Map<string, Map<string, LevelDef>>} */
203
+ const markersByNumId = new Map();
204
+ for (const [numId, absId] of numToAbstract) {
205
+ const levels = abstractLevels.get(absId);
206
+ if (levels !== undefined) {
207
+ markersByNumId.set(numId, levels);
208
+ }
209
+ }
210
+ return markersByNumId;
211
+ }
212
+
213
+ /**
214
+ * A running counter over (numId, ilvl), advanced once per paragraph
215
+ * OCCURRENCE by calling `next` in DOCUMENT ORDER -- the only order this
216
+ * makes sense in, since "the next item in this list" is a document-order
217
+ * concept, not a per-container or per-page one (measured against real Word:
218
+ * a list continues across a container/page boundary). Scope is therefore the
219
+ * WHOLE document: one counter instance is created per `convertDocument` call
220
+ * (import.js) and threaded through every anchor/container/paragraph that
221
+ * call processes, in the single sequential pass `collectAnchors` + the
222
+ * anchor loop already walk the document in.
223
+ *
224
+ * Multi-level restart (OOXML default, w:lvlRestart not implemented -- 0/474
225
+ * real corpus w:num entries carry it): encountering ANY level of a numId
226
+ * resets every DEEPER level's counter for that SAME numId, so a fresh "a."
227
+ * at ilvl 0 restarts a sub-list's "i., ii., ..." at ilvl 1 back to "i.".
228
+ * Encountering ilvl 1 again with NO intervening ilvl 0 (e.g. across a
229
+ * container boundary) does NOT reset it -- it continues.
230
+ *
231
+ * @param {Map<string, Map<string, import("./numbering.js").LevelDef>>} numDefs
232
+ */
233
+ export function createNumberingCounter(numDefs) {
234
+ /** @type {Map<string, Map<string, number>>} numId -> ilvl -> current count */
235
+ const counts = new Map();
236
+
237
+ /**
238
+ * @param {string} numId
239
+ * @param {string} ilvl
240
+ * @returns {{marker: string, resolved: boolean} | undefined} undefined if
241
+ * this (numId, ilvl) has no numbering.xml definition at all -- the
242
+ * caller (import.js) falls back to DEFAULT_BULLET, this module never guesses
243
+ */
244
+ function next(numId, ilvl) {
245
+ const level = numDefs.get(numId)?.get(ilvl);
246
+ if (level === undefined || level.kind === "unresolved") {
247
+ return undefined;
248
+ }
249
+ if (level.kind === "bullet") {
250
+ // Bullets are not counted, but encountering ANY level (bullet or
251
+ // ordinal) still restarts deeper ordinal levels of the same numId --
252
+ // the restart rule is about level hierarchy, not format.
253
+ resetDeeperLevels(numId, ilvl);
254
+ return { marker: level.bulletMarker, resolved: level.bulletResolved };
255
+ }
256
+
257
+ let byIlvl = counts.get(numId);
258
+ if (byIlvl === undefined) {
259
+ byIlvl = new Map();
260
+ counts.set(numId, byIlvl);
261
+ }
262
+ const current = byIlvl.get(ilvl);
263
+ const n = current === undefined ? level.start : current + 1;
264
+ byIlvl.set(ilvl, n);
265
+ resetDeeperLevels(numId, ilvl);
266
+
267
+ const formatted = formatOrdinal(level.numFmt, n);
268
+ // formatOrdinal cannot fail here: parseNumbering only assigns kind:
269
+ // "ordinal" when formatOrdinal(numFmt, 1) already returned non-null.
270
+ const marker = level.lvlText.replace(/%\d/g, formatted ?? "");
271
+ return { marker, resolved: true };
272
+ }
273
+
274
+ /**
275
+ * @param {string} numId
276
+ * @param {string} ilvl
277
+ */
278
+ function resetDeeperLevels(numId, ilvl) {
279
+ const byIlvl = counts.get(numId);
280
+ if (byIlvl === undefined) return;
281
+ const thisDepth = Number.parseInt(ilvl, 10);
282
+ for (const trackedIlvl of [...byIlvl.keys()]) {
283
+ if (Number.parseInt(trackedIlvl, 10) > thisDepth) {
284
+ byIlvl.delete(trackedIlvl);
285
+ }
286
+ }
287
+ }
288
+
289
+ return { next };
290
+ }