@sametalmdr/word-editor 0.2.0-alpha → 0.3.0-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +108 -70
- package/LICENSE +21 -21
- package/README.md +286 -247
- package/dist/canvas/box-clipboard.js +238 -238
- package/dist/canvas/box-selection.js +80 -80
- package/dist/canvas/canvas-editor.js +502 -502
- package/dist/canvas/controller.js +717 -717
- package/dist/canvas/geometry.js +131 -131
- package/dist/canvas/gesture.d.ts +0 -1
- package/dist/canvas/gesture.js +289 -289
- package/dist/canvas/handles.js +212 -212
- package/dist/canvas/menu.js +82 -82
- package/dist/canvas/mode-controller.js +81 -81
- package/dist/canvas/nudge.js +108 -108
- package/dist/canvas/overlay.js +149 -149
- package/dist/canvas/snap.js +132 -132
- package/dist/canvas/viewport.js +112 -112
- package/dist/canvas/z-order.js +171 -171
- package/dist/commands/alignment.js +150 -150
- package/dist/commands/box.js +272 -272
- package/dist/commands/clipboard.js +93 -93
- package/dist/commands/graphemes.js +112 -112
- package/dist/commands/marks.js +153 -153
- package/dist/commands/paragraph-props.js +176 -176
- package/dist/commands/size.js +368 -368
- package/dist/commands/text.js +291 -291
- package/dist/docx.d.ts +1 -0
- package/dist/docx.js +15 -13
- package/dist/editor/editing-host.js +434 -434
- package/dist/element.js +41 -41
- package/dist/fonts/OFL-Carlito.txt +94 -0
- package/dist/fonts/calibri-metrics.d.ts +27 -0
- package/dist/fonts/calibri-metrics.js +88 -0
- package/dist/history/history.js +433 -433
- package/dist/index.js +63 -63
- package/dist/input/beforeinput.js +224 -224
- package/dist/input/clipboard.js +107 -107
- package/dist/input/composition.js +341 -341
- package/dist/input/keymap.js +54 -54
- package/dist/io/ooxml/custgeom.js +72 -72
- package/dist/io/ooxml/drawingml.js +232 -232
- package/dist/io/ooxml/edits.js +67 -67
- package/dist/io/ooxml/export-splice.js +880 -880
- package/dist/io/ooxml/export.js +365 -365
- package/dist/io/ooxml/fill.d.ts +93 -8
- package/dist/io/ooxml/fill.js +618 -562
- package/dist/io/ooxml/fit.d.ts +252 -0
- package/dist/io/ooxml/fit.js +598 -0
- package/dist/io/ooxml/import-gate.js +200 -200
- package/dist/io/ooxml/import.js +691 -691
- package/dist/io/ooxml/inflate.js +126 -126
- package/dist/io/ooxml/media.js +87 -87
- package/dist/io/ooxml/ns.d.ts +2 -0
- package/dist/io/ooxml/ns.js +26 -21
- package/dist/io/ooxml/numbering.js +290 -290
- package/dist/io/ooxml/opc-write.js +171 -171
- package/dist/io/ooxml/provenance.js +93 -93
- package/dist/io/ooxml/recipe.js +708 -708
- package/dist/io/ooxml/relationships.js +59 -59
- package/dist/io/ooxml/run-fonts.d.ts +89 -0
- package/dist/io/ooxml/run-fonts.js +378 -0
- package/dist/io/ooxml/slots.d.ts +4 -0
- package/dist/io/ooxml/slots.js +582 -581
- package/dist/io/ooxml/styles-resolve.js +257 -257
- package/dist/io/ooxml/units.js +43 -43
- package/dist/io/ooxml/value.js +120 -120
- package/dist/io/ooxml/xml-scan.js +616 -616
- package/dist/io/ooxml/zip-container.js +228 -228
- package/dist/io/ooxml/zip-reject.js +106 -106
- package/dist/measure/placement.js +83 -83
- package/dist/measure/report.js +75 -75
- package/dist/model/ids.js +70 -70
- package/dist/model/normalize.js +146 -146
- package/dist/model/position.js +283 -283
- package/dist/model/transaction.js +209 -209
- package/dist/platform/capabilities.js +25 -25
- package/dist/react/index.js +21 -21
- package/dist/selection/controller.js +315 -315
- package/dist/selection/dom-map.js +334 -334
- package/dist/state/editor-state.js +109 -109
- package/dist/styles.css +66 -66
- package/package.json +94 -93
|
@@ -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
|
+
}
|