@ansonlai/docx-redline-js 0.1.0

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 (64) hide show
  1. package/AGENTS.md +176 -0
  2. package/ARCHITECTURE.md +121 -0
  3. package/LICENSE +21 -0
  4. package/README.md +177 -0
  5. package/adapters/config.js +43 -0
  6. package/adapters/logger.js +89 -0
  7. package/adapters/xml-adapter.js +74 -0
  8. package/core/list-targeting.js +398 -0
  9. package/core/ooxml-identifiers.js +15 -0
  10. package/core/paragraph-offset-policy.js +50 -0
  11. package/core/paragraph-targeting.js +501 -0
  12. package/core/table-targeting.js +233 -0
  13. package/core/types.js +204 -0
  14. package/core/xml-query.js +99 -0
  15. package/dist/docx-redline-js.esm.js +8801 -0
  16. package/dist/docx-redline-js.esm.js.map +7 -0
  17. package/dist/docx-redline-js.esm.min.js +195 -0
  18. package/dist/docx-redline-js.esm.min.js.map +7 -0
  19. package/engine/format-application.js +358 -0
  20. package/engine/format-extraction.js +232 -0
  21. package/engine/format-paragraph-targeting.js +208 -0
  22. package/engine/format-span-application.js +178 -0
  23. package/engine/formatting-removal.js +330 -0
  24. package/engine/oxml-engine.js +279 -0
  25. package/engine/reconstruction-mapper.js +270 -0
  26. package/engine/reconstruction-mode.js +38 -0
  27. package/engine/reconstruction-writer.js +276 -0
  28. package/engine/rpr-helpers.js +194 -0
  29. package/engine/run-builders.js +235 -0
  30. package/engine/surgical-mode.js +520 -0
  31. package/engine/table-cell-context.js +151 -0
  32. package/engine/table-mode.js +172 -0
  33. package/index.js +308 -0
  34. package/orchestration/list-markdown.js +141 -0
  35. package/orchestration/list-parsing.js +73 -0
  36. package/orchestration/list-structural-fallback.js +530 -0
  37. package/orchestration/redline-operation-converter.js +141 -0
  38. package/orchestration/route-plan.js +160 -0
  39. package/package.json +76 -0
  40. package/pipeline/content-analysis.js +107 -0
  41. package/pipeline/diff-engine.js +204 -0
  42. package/pipeline/ingestion-export.js +255 -0
  43. package/pipeline/ingestion-paragraph.js +351 -0
  44. package/pipeline/ingestion-table.js +169 -0
  45. package/pipeline/ingestion-xml.js +39 -0
  46. package/pipeline/ingestion.js +8 -0
  47. package/pipeline/list-generation.js +280 -0
  48. package/pipeline/list-markers.js +77 -0
  49. package/pipeline/markdown-processor.js +160 -0
  50. package/pipeline/patching.js +408 -0
  51. package/pipeline/pipeline.js +326 -0
  52. package/pipeline/serialization.js +395 -0
  53. package/services/browser-demo-prompt-context.js +345 -0
  54. package/services/comment-builders.js +60 -0
  55. package/services/comment-engine.js +248 -0
  56. package/services/comment-locator.js +197 -0
  57. package/services/comment-package.js +113 -0
  58. package/services/numbering-helpers.js +416 -0
  59. package/services/numbering-service.js +290 -0
  60. package/services/package-builder.js +147 -0
  61. package/services/standalone-docx-plumbing.js +443 -0
  62. package/services/standalone-operation-runner.js +1169 -0
  63. package/services/table-reconciliation.js +344 -0
  64. package/standalone.js +5 -0
@@ -0,0 +1,416 @@
1
+ import { parseOoxml, serializeOoxml } from '../engine/oxml-engine.js';
2
+ import { WORD_MAIN_NS } from '../core/paragraph-targeting.js';
3
+
4
+ function parseIntegerAttribute(element, names) {
5
+ if (!element || !Array.isArray(names)) return null;
6
+ for (const name of names) {
7
+ const raw = element.getAttribute(name);
8
+ if (raw == null || raw === '') continue;
9
+ const parsed = Number.parseInt(String(raw), 10);
10
+ if (Number.isFinite(parsed)) return parsed;
11
+ }
12
+ return null;
13
+ }
14
+
15
+ function nextAvailableId(startId, occupiedIds, maxPreferred = null) {
16
+ let candidate = Number.isInteger(startId) && startId > 0 ? startId : 1;
17
+ const occupied = occupiedIds instanceof Set ? occupiedIds : new Set();
18
+
19
+ while (occupied.has(candidate)) {
20
+ candidate += 1;
21
+ }
22
+
23
+ if (Number.isInteger(maxPreferred) && maxPreferred > 0 && candidate > maxPreferred) {
24
+ for (let probe = 1; probe <= maxPreferred; probe += 1) {
25
+ if (!occupied.has(probe)) return probe;
26
+ }
27
+ }
28
+
29
+ return candidate;
30
+ }
31
+
32
+ /**
33
+ * Builds a dynamic numbering-id state from existing numbering XML.
34
+ *
35
+ * This avoids hardcoded ID floors and keeps IDs deterministic relative to the
36
+ * current document numbering definitions.
37
+ *
38
+ * @param {string} numberingXml - Existing `word/numbering.xml` content
39
+ * @param {{
40
+ * minId?: number,
41
+ * maxPreferred?: number
42
+ * }} [options={}] - Optional ID preferences
43
+ * @returns {{
44
+ * nextNumId: number,
45
+ * nextAbstractNumId: number,
46
+ * usedNumIds: Set<number>,
47
+ * usedAbstractNumIds: Set<number>,
48
+ * minId: number,
49
+ * maxPreferred: number
50
+ * }}
51
+ */
52
+ export function createDynamicNumberingIdState(numberingXml, options = {}) {
53
+ const minId = Number.isInteger(options?.minId) && options.minId > 0 ? options.minId : 1;
54
+ const maxPreferred = Number.isInteger(options?.maxPreferred) && options.maxPreferred >= minId
55
+ ? options.maxPreferred
56
+ : 32767;
57
+
58
+ const usedNumIds = new Set();
59
+ const usedAbstractNumIds = new Set();
60
+
61
+ if (String(numberingXml || '').trim()) {
62
+ try {
63
+ const numberingDoc = parseOoxml(numberingXml);
64
+ const abstractNums = Array.from(numberingDoc.getElementsByTagNameNS('*', 'abstractNum'));
65
+ const nums = Array.from(numberingDoc.getElementsByTagNameNS('*', 'num'));
66
+
67
+ for (const abstractNum of abstractNums) {
68
+ const id = parseIntegerAttribute(abstractNum, ['w:abstractNumId', 'abstractNumId']);
69
+ if (id != null) usedAbstractNumIds.add(id);
70
+ }
71
+ for (const num of nums) {
72
+ const id = parseIntegerAttribute(num, ['w:numId', 'numId']);
73
+ if (id != null) usedNumIds.add(id);
74
+ }
75
+ } catch {
76
+ // Ignore malformed numbering XML and fall back to empty sets.
77
+ }
78
+ }
79
+
80
+ const maxUsedNumId = usedNumIds.size > 0 ? Math.max(...usedNumIds) : 0;
81
+ const maxUsedAbstractNumId = usedAbstractNumIds.size > 0 ? Math.max(...usedAbstractNumIds) : 0;
82
+ const baseNumId = Math.max(minId, maxUsedNumId + 1);
83
+ const baseAbstractNumId = Math.max(minId, maxUsedAbstractNumId + 1);
84
+
85
+ return {
86
+ nextNumId: nextAvailableId(baseNumId, usedNumIds, maxPreferred),
87
+ nextAbstractNumId: nextAvailableId(baseAbstractNumId, usedAbstractNumIds, maxPreferred),
88
+ usedNumIds,
89
+ usedAbstractNumIds,
90
+ minId,
91
+ maxPreferred
92
+ };
93
+ }
94
+
95
+ function normalizeNumberingIdState(state) {
96
+ if (!state || typeof state !== 'object') return null;
97
+
98
+ if (!(state.usedNumIds instanceof Set)) state.usedNumIds = new Set();
99
+ if (!(state.usedAbstractNumIds instanceof Set)) state.usedAbstractNumIds = new Set();
100
+
101
+ if (!Number.isInteger(state.minId) || state.minId < 1) {
102
+ state.minId = 1;
103
+ }
104
+ if (!Number.isInteger(state.maxPreferred) || state.maxPreferred < state.minId) {
105
+ state.maxPreferred = 32767;
106
+ }
107
+
108
+ if (!Number.isInteger(state.nextNumId) || state.nextNumId < state.minId) {
109
+ state.nextNumId = state.minId;
110
+ }
111
+ if (!Number.isInteger(state.nextAbstractNumId) || state.nextAbstractNumId < state.minId) {
112
+ state.nextAbstractNumId = state.minId;
113
+ }
114
+
115
+ state.nextNumId = nextAvailableId(state.nextNumId, state.usedNumIds, state.maxPreferred);
116
+ state.nextAbstractNumId = nextAvailableId(state.nextAbstractNumId, state.usedAbstractNumIds, state.maxPreferred);
117
+ return state;
118
+ }
119
+
120
+ /**
121
+ * Reserves the next available ID from a mutable numbering-id state.
122
+ *
123
+ * @param {ReturnType<typeof createDynamicNumberingIdState>} state
124
+ * @param {'num'|'abstract'} [kind='num']
125
+ * @returns {number|null}
126
+ */
127
+ export function reserveNextNumberingId(state, kind = 'num') {
128
+ const normalized = normalizeNumberingIdState(state);
129
+ if (!normalized) return null;
130
+
131
+ const useAbstract = kind === 'abstract';
132
+ const id = useAbstract ? normalized.nextAbstractNumId : normalized.nextNumId;
133
+ if (!Number.isInteger(id) || id < 1) return null;
134
+
135
+ if (useAbstract) {
136
+ normalized.usedAbstractNumIds.add(id);
137
+ normalized.nextAbstractNumId = nextAvailableId(id + 1, normalized.usedAbstractNumIds, normalized.maxPreferred);
138
+ } else {
139
+ normalized.usedNumIds.add(id);
140
+ normalized.nextNumId = nextAvailableId(id + 1, normalized.usedNumIds, normalized.maxPreferred);
141
+ }
142
+
143
+ return id;
144
+ }
145
+
146
+ /**
147
+ * Reserves the next available numbering IDs on a mutable numbering-id state.
148
+ *
149
+ * @param {ReturnType<typeof createDynamicNumberingIdState>} state
150
+ * @returns {{ numId: number, abstractNumId: number } | null}
151
+ */
152
+ export function reserveNextNumberingIdPair(state) {
153
+ const numId = reserveNextNumberingId(state, 'num');
154
+ const abstractNumId = reserveNextNumberingId(state, 'abstract');
155
+ if (numId == null || abstractNumId == null) return null;
156
+
157
+ return { numId, abstractNumId };
158
+ }
159
+
160
+ function hasXmlParseError(doc) {
161
+ if (!doc || !doc.documentElement) return true;
162
+ if (doc.documentElement.localName === 'parsererror') return true;
163
+ return doc.getElementsByTagName('parsererror').length > 0;
164
+ }
165
+
166
+ function isDirectWordChild(node, localName) {
167
+ return !!(
168
+ node &&
169
+ node.nodeType === 1 &&
170
+ node.namespaceURI === WORD_MAIN_NS &&
171
+ node.localName === localName
172
+ );
173
+ }
174
+
175
+ function insertNumberingNodeInSchemaOrder(root, node, kind) {
176
+ if (!root || !node) return;
177
+ const directChildren = Array.from(root.childNodes || []).filter(
178
+ child => child && child.nodeType === 1 && child.namespaceURI === WORD_MAIN_NS
179
+ );
180
+
181
+ let anchor = null;
182
+ if (kind === 'abstract') {
183
+ anchor = directChildren.find(
184
+ child => child.localName === 'num' || child.localName === 'numIdMacAtCleanup'
185
+ ) || null;
186
+ } else {
187
+ anchor = directChildren.find(child => child.localName === 'numIdMacAtCleanup') || null;
188
+ }
189
+
190
+ if (anchor) root.insertBefore(node, anchor);
191
+ else root.appendChild(node);
192
+ }
193
+
194
+ function getAttributeFirst(element, names) {
195
+ for (const name of names || []) {
196
+ const value = element?.getAttribute?.(name);
197
+ if (value != null && value !== '') return value;
198
+ }
199
+ return null;
200
+ }
201
+
202
+ function getElementId(element, names) {
203
+ const raw = getAttributeFirst(element, names);
204
+ const parsed = Number.parseInt(String(raw || ''), 10);
205
+ return Number.isFinite(parsed) ? parsed : null;
206
+ }
207
+
208
+ function setElementId(element, preferredName, idValue) {
209
+ element?.setAttribute?.(preferredName, String(idValue));
210
+ }
211
+
212
+ function setElementVal(element, value) {
213
+ element?.setAttribute?.('w:val', String(value));
214
+ }
215
+
216
+ /**
217
+ * Overwrites all paragraph-level `w:numId` references in a node collection.
218
+ *
219
+ * @param {Node[]|null|undefined} paragraphNodes
220
+ * @param {string|number|null|undefined} targetNumId
221
+ */
222
+ export function overwriteParagraphNumIds(paragraphNodes, targetNumId) {
223
+ if (!Array.isArray(paragraphNodes) || targetNumId == null) return;
224
+ for (const node of paragraphNodes) {
225
+ const numIdNodes = Array.from(node?.getElementsByTagNameNS?.('*', 'numId') || []);
226
+ for (const numIdNode of numIdNodes) {
227
+ setElementVal(numIdNode, targetNumId);
228
+ }
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Extracts the first `w:numId` value from a node collection.
234
+ *
235
+ * @param {Node[]|null|undefined} paragraphNodes
236
+ * @returns {string|null}
237
+ */
238
+ export function extractFirstParagraphNumId(paragraphNodes) {
239
+ for (const node of paragraphNodes || []) {
240
+ const numIdNodes = Array.from(node?.getElementsByTagNameNS?.('*', 'numId') || []);
241
+ for (const numIdNode of numIdNodes) {
242
+ const numId = getElementId(numIdNode, ['w:val', 'val']);
243
+ if (numId != null) return String(numId);
244
+ }
245
+ }
246
+ return null;
247
+ }
248
+
249
+ /**
250
+ * Builds multilevel decimal numbering XML for explicit-start header conversion.
251
+ *
252
+ * @param {string|number} numId
253
+ * @param {string|number} abstractNumId
254
+ * @param {number} startAt
255
+ * @returns {string}
256
+ */
257
+ export function buildExplicitDecimalMultilevelNumberingXml(numId, abstractNumId, startAt) {
258
+ const safeNumId = String(numId);
259
+ const safeAbstractNumId = String(abstractNumId);
260
+ const safeStartAt = Number.isInteger(startAt) && startAt > 0 ? startAt : 1;
261
+ const levelsXml = Array.from({ length: 9 }, (_, level) => {
262
+ const lvlText = Array.from({ length: level + 1 }, (_, i) => `%${i + 1}`).join('.') + '.';
263
+ const left = 720 * (level + 1);
264
+ return `
265
+ <w:lvl w:ilvl="${level}">
266
+ <w:start w:val="1"/>
267
+ <w:numFmt w:val="decimal"/>
268
+ <w:lvlText w:val="${lvlText}"/>
269
+ <w:lvlJc w:val="left"/>
270
+ <w:pPr><w:ind w:left="${left}" w:hanging="360"/></w:pPr>
271
+ </w:lvl>`;
272
+ }).join('');
273
+ return `
274
+ <w:numbering xmlns:w="${WORD_MAIN_NS}">
275
+ <w:abstractNum w:abstractNumId="${safeAbstractNumId}">
276
+ <w:multiLevelType w:val="multilevel"/>
277
+ ${levelsXml}
278
+ </w:abstractNum>
279
+ <w:num w:numId="${safeNumId}">
280
+ <w:abstractNumId w:val="${safeAbstractNumId}"/>
281
+ <w:lvlOverride w:ilvl="0">
282
+ <w:startOverride w:val="${safeStartAt}"/>
283
+ </w:lvlOverride>
284
+ </w:num>
285
+ </w:numbering>`.trim();
286
+ }
287
+
288
+ /**
289
+ * Remaps incoming numbering payload IDs to document-safe IDs, and updates the
290
+ * provided replacement nodes to reference the remapped `w:numId` values.
291
+ *
292
+ * @param {string} numberingXml
293
+ * @param {Node[]} replacementNodes
294
+ * @param {ReturnType<typeof createDynamicNumberingIdState>} numberingIdState
295
+ * @returns {{ numberingXml: string, replacementNodes: Node[] }}
296
+ */
297
+ export function remapNumberingPayloadForDocument(numberingXml, replacementNodes, numberingIdState) {
298
+ const numberingDoc = parseOoxml(numberingXml);
299
+ if (hasXmlParseError(numberingDoc)) {
300
+ return {
301
+ numberingXml: String(numberingXml || ''),
302
+ replacementNodes: Array.isArray(replacementNodes)
303
+ ? replacementNodes.map(node => node?.cloneNode ? node.cloneNode(true) : node)
304
+ : []
305
+ };
306
+ }
307
+
308
+ const abstractNumMap = new Map();
309
+ const numIdMap = new Map();
310
+
311
+ const abstractNums = Array.from(numberingDoc.getElementsByTagNameNS('*', 'abstractNum'));
312
+ for (const abstractNum of abstractNums) {
313
+ const oldId = getElementId(abstractNum, ['w:abstractNumId', 'abstractNumId']);
314
+ if (oldId == null) continue;
315
+ const newId = reserveNextNumberingId(numberingIdState, 'abstract');
316
+ if (newId == null) continue;
317
+ abstractNumMap.set(oldId, newId);
318
+ setElementId(abstractNum, 'w:abstractNumId', newId);
319
+ }
320
+
321
+ const nums = Array.from(numberingDoc.getElementsByTagNameNS('*', 'num'));
322
+ for (const num of nums) {
323
+ const oldNumId = getElementId(num, ['w:numId', 'numId']);
324
+ if (oldNumId == null) continue;
325
+ const newNumId = reserveNextNumberingId(numberingIdState, 'num');
326
+ if (newNumId == null) continue;
327
+ numIdMap.set(oldNumId, newNumId);
328
+ setElementId(num, 'w:numId', newNumId);
329
+
330
+ const abstractNumIdNode = Array.from(num.getElementsByTagNameNS('*', 'abstractNumId'))[0] || null;
331
+ if (abstractNumIdNode) {
332
+ const oldAbsRef = getElementId(abstractNumIdNode, ['w:val', 'val']);
333
+ if (oldAbsRef != null && abstractNumMap.has(oldAbsRef)) {
334
+ setElementVal(abstractNumIdNode, abstractNumMap.get(oldAbsRef));
335
+ }
336
+ }
337
+ }
338
+
339
+ const clonedNodes = Array.isArray(replacementNodes)
340
+ ? replacementNodes.map(node => node?.cloneNode ? node.cloneNode(true) : node)
341
+ : [];
342
+ for (const node of clonedNodes) {
343
+ const numIdNodes = Array.from(node?.getElementsByTagNameNS?.('*', 'numId') || []);
344
+ for (const numIdNode of numIdNodes) {
345
+ const oldNumRef = getElementId(numIdNode, ['w:val', 'val']);
346
+ if (oldNumRef != null && numIdMap.has(oldNumRef)) {
347
+ setElementVal(numIdNode, numIdMap.get(oldNumRef));
348
+ }
349
+ }
350
+ }
351
+
352
+ return {
353
+ numberingXml: serializeOoxml(numberingDoc),
354
+ replacementNodes: clonedNodes
355
+ };
356
+ }
357
+
358
+ /**
359
+ * Merges incoming numbering definitions into an existing numbering part while
360
+ * preserving schema child order (`abstractNum*` before `num*`).
361
+ *
362
+ * @param {string} existingNumberingXml
363
+ * @param {string} incomingNumberingXml
364
+ * @returns {string}
365
+ */
366
+ export function mergeNumberingXmlBySchemaOrder(existingNumberingXml, incomingNumberingXml) {
367
+ const existingText = String(existingNumberingXml || '');
368
+ const incomingText = String(incomingNumberingXml || '');
369
+ if (!incomingText.trim()) return existingText;
370
+ if (!existingText.trim()) return incomingText;
371
+
372
+ try {
373
+ const existingDoc = parseOoxml(existingText);
374
+ const incomingDoc = parseOoxml(incomingText);
375
+ if (hasXmlParseError(existingDoc) || hasXmlParseError(incomingDoc)) {
376
+ return existingText;
377
+ }
378
+
379
+ const existingRoot = existingDoc.documentElement;
380
+ const incomingRoot = incomingDoc.documentElement;
381
+ if (!existingRoot || !incomingRoot) return existingText;
382
+
383
+ const existingAbstractIds = new Set(
384
+ Array.from(existingRoot.childNodes || [])
385
+ .filter(node => isDirectWordChild(node, 'abstractNum'))
386
+ .map(node => parseIntegerAttribute(node, ['w:abstractNumId', 'abstractNumId']))
387
+ .filter(id => id != null)
388
+ );
389
+ const existingNumIds = new Set(
390
+ Array.from(existingRoot.childNodes || [])
391
+ .filter(node => isDirectWordChild(node, 'num'))
392
+ .map(node => parseIntegerAttribute(node, ['w:numId', 'numId']))
393
+ .filter(id => id != null)
394
+ );
395
+
396
+ for (const incomingNode of Array.from(incomingRoot.childNodes || [])) {
397
+ if (!isDirectWordChild(incomingNode, 'abstractNum')) continue;
398
+ const incomingId = parseIntegerAttribute(incomingNode, ['w:abstractNumId', 'abstractNumId']);
399
+ if (incomingId == null || existingAbstractIds.has(incomingId)) continue;
400
+ insertNumberingNodeInSchemaOrder(existingRoot, existingDoc.importNode(incomingNode, true), 'abstract');
401
+ existingAbstractIds.add(incomingId);
402
+ }
403
+
404
+ for (const incomingNode of Array.from(incomingRoot.childNodes || [])) {
405
+ if (!isDirectWordChild(incomingNode, 'num')) continue;
406
+ const incomingId = parseIntegerAttribute(incomingNode, ['w:numId', 'numId']);
407
+ if (incomingId == null || existingNumIds.has(incomingId)) continue;
408
+ insertNumberingNodeInSchemaOrder(existingRoot, existingDoc.importNode(incomingNode, true), 'num');
409
+ existingNumIds.add(incomingId);
410
+ }
411
+
412
+ return serializeOoxml(existingDoc);
413
+ } catch {
414
+ return existingText;
415
+ }
416
+ }