@ansonlai/docx-redline-js 0.4.0 → 0.5.1

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 (104) hide show
  1. package/AGENTS.md +646 -288
  2. package/ARCHITECTURE.md +215 -9
  3. package/CHANGELOG.md +319 -0
  4. package/README.md +604 -360
  5. package/adapters/config.js +45 -43
  6. package/bin/docx-redline.js +3 -0
  7. package/core/list-targeting.js +101 -110
  8. package/core/paragraph-targeting.js +501 -61
  9. package/core/paragraph-text.js +209 -0
  10. package/core/redline-validation.js +11 -5
  11. package/core/revision-cloning.js +38 -0
  12. package/core/types.js +64 -10
  13. package/core/word-xml.js +43 -15
  14. package/dist/docx-redline-js.esm.js +3145 -505
  15. package/dist/docx-redline-js.esm.js.map +4 -4
  16. package/dist/docx-redline-js.esm.min.js +88 -76
  17. package/dist/docx-redline-js.esm.min.js.map +4 -4
  18. package/docs/TESTING.md +342 -23
  19. package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
  20. package/docs/plans/2026-09-08-cross-author-revision-slicing.md +505 -0
  21. package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
  22. package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
  23. package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
  24. package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
  25. package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
  26. package/docs/schemas/document-operations.schema.json +109 -0
  27. package/docs/test-comparison-dashboard.html +4250 -7
  28. package/engine/formatting-removal.js +11 -2
  29. package/engine/oxml-engine.js +508 -336
  30. package/engine/reconstruction-mode.js +15 -14
  31. package/engine/reconstruction-writer.js +247 -142
  32. package/engine/route-selection.js +35 -0
  33. package/engine/rpr-helpers.js +334 -35
  34. package/engine/run-builders.js +239 -196
  35. package/engine/surgical-diff-application.js +407 -50
  36. package/engine/surgical-mode.js +142 -6
  37. package/engine/surgical-run-splitting.js +103 -0
  38. package/engine/surgical-spans.js +52 -1
  39. package/engine/table-cell-context.js +3 -6
  40. package/engine/table-mode.js +1 -1
  41. package/index.d.ts +234 -6
  42. package/index.js +24 -1
  43. package/node/cli.js +322 -0
  44. package/node/docx-document.js +302 -0
  45. package/node/index.d.ts +31 -0
  46. package/node/index.js +2 -0
  47. package/node/zip-archive.js +52 -0
  48. package/orchestration/list-markdown.js +10 -16
  49. package/orchestration/list-parsing.js +7 -12
  50. package/orchestration/list-structural-fallback.js +21 -10
  51. package/package.json +123 -102
  52. package/pipeline/content-analysis.js +12 -17
  53. package/pipeline/ingestion-export.js +3 -31
  54. package/pipeline/ingestion-paragraph.js +10 -5
  55. package/pipeline/list-generation.js +150 -55
  56. package/pipeline/list-markers.js +70 -3
  57. package/pipeline/serialization.js +4 -2
  58. package/pipeline/structured-content.js +160 -0
  59. package/scripts/apply_changes.mjs +27 -0
  60. package/scripts/benchmark-operation-session.mjs +137 -0
  61. package/scripts/benchmark-targeting-browser.html +74 -0
  62. package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
  63. package/scripts/benchmark-test-runner.mjs +59 -0
  64. package/scripts/build-test-dashboard.mjs +23 -0
  65. package/scripts/export-lane1-fixtures.mjs +380 -0
  66. package/scripts/export-reredline-stress-fixtures.mjs +317 -0
  67. package/scripts/export-validation-fixtures.mjs +1 -1
  68. package/scripts/extract_text.mjs +7 -0
  69. package/scripts/generate-cross-author-slicing-fixtures.ps1 +256 -0
  70. package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
  71. package/scripts/generate-test-dashboard.mjs +362 -11
  72. package/scripts/lib/word-coverage-catalogue.mjs +6 -2
  73. package/scripts/profile-route-selection.mjs +19 -0
  74. package/scripts/render-agenda-multilevel.mjs +0 -5
  75. package/scripts/render-multilevel-cases.mjs +0 -1
  76. package/scripts/run-tests.mjs +107 -35
  77. package/scripts/word-com-corpus-suite.ps1 +3 -0
  78. package/scripts/word-com-differential.ps1 +64 -4
  79. package/scripts/word-com-suite.ps1 +3 -0
  80. package/services/batch-operation-orchestrator.js +513 -0
  81. package/services/capture-engine.js +226 -0
  82. package/services/comment-builders.js +23 -6
  83. package/services/comment-engine.js +108 -47
  84. package/services/comment-locator.js +187 -82
  85. package/services/comment-replies.js +95 -0
  86. package/services/document-inspection.js +258 -0
  87. package/services/document-operation-applier.js +372 -0
  88. package/services/document-operation-contract.js +345 -0
  89. package/services/document-operation-mutations.js +1749 -0
  90. package/services/document-operation-session.js +258 -0
  91. package/services/numbering-service.js +14 -5
  92. package/services/operation-heuristics.js +173 -0
  93. package/services/operation-preflight.js +390 -0
  94. package/services/receipt-collector.js +288 -0
  95. package/services/revision-comment-management.js +77 -5
  96. package/services/revision-token.js +290 -0
  97. package/services/standalone-docx-plumbing.js +123 -8
  98. package/services/standalone-operation-runner.d.ts +296 -0
  99. package/services/standalone-operation-runner.js +10 -1455
  100. package/services/table-reconciliation.js +15 -6
  101. package/docs/VALIDATION.md +0 -183
  102. package/docs/WORD-MANUAL-REVIEW.md +0 -138
  103. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
  104. /package/docs/plans/{2026-08-30-reliability-testing-improvements.md → completed/2026-08-30-reliability-testing-improvements.md} +0 -0
package/node/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { DocxDocument, openDocx, computePackageRevisionToken } from './docx-document.js';
2
+ export { executeCli, runCli } from './cli.js';
@@ -0,0 +1,52 @@
1
+ import { deflateRawSync, inflateRawSync } from 'node:zlib';
2
+
3
+ const CRC_TABLE = (() => { const table = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; table[n] = c >>> 0; } return table; })();
4
+ function crc32(buffer) { let crc = 0xffffffff; for (const byte of buffer) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); return (crc ^ 0xffffffff) >>> 0; }
5
+
6
+ export function unzipDocx(input) {
7
+ const source = Buffer.from(input);
8
+ let eocd = -1;
9
+ for (let offset = source.length - 22; offset >= Math.max(0, source.length - 0xffff - 22); offset--) if (source.readUInt32LE(offset) === 0x06054b50) { eocd = offset; break; }
10
+ if (eocd < 0) throw new Error('Invalid DOCX: ZIP end record not found.');
11
+ const count = source.readUInt16LE(eocd + 10); const centralOffset = source.readUInt32LE(eocd + 16);
12
+ if (count === 0xffff || centralOffset === 0xffffffff) throw new Error('Unsupported DOCX: ZIP64 archives are not supported.');
13
+ const entries = new Map(); let offset = centralOffset;
14
+ for (let index = 0; index < count; index++) {
15
+ if (source.readUInt32LE(offset) !== 0x02014b50) throw new Error('Invalid DOCX: central directory is corrupt.');
16
+ const flags = source.readUInt16LE(offset + 8); const method = source.readUInt16LE(offset + 10);
17
+ if (flags & 1) throw new Error('Unsupported DOCX: encrypted ZIP entries are not supported.');
18
+ const compressedSize = source.readUInt32LE(offset + 20); const nameLength = source.readUInt16LE(offset + 28);
19
+ const extraLength = source.readUInt16LE(offset + 30); const commentLength = source.readUInt16LE(offset + 32); const localOffset = source.readUInt32LE(offset + 42);
20
+ const name = source.toString('utf8', offset + 46, offset + 46 + nameLength);
21
+ if (source.readUInt32LE(localOffset) !== 0x04034b50) throw new Error('Invalid DOCX: local entry is corrupt.');
22
+ const localNameLength = source.readUInt16LE(localOffset + 26); const localExtraLength = source.readUInt16LE(localOffset + 28);
23
+ const start = localOffset + 30 + localNameLength + localExtraLength; const compressed = source.subarray(start, start + compressedSize);
24
+ if (method !== 0 && method !== 8) throw new Error(`Unsupported DOCX compression method: ${method}.`);
25
+ entries.set(name, method === 8 ? inflateRawSync(compressed) : Buffer.from(compressed));
26
+ offset += 46 + nameLength + extraLength + commentLength;
27
+ }
28
+ return entries;
29
+ }
30
+
31
+ export function zipDocx(entries) {
32
+ const local = []; const central = []; let offset = 0;
33
+ for (const [name, raw] of entries) {
34
+ const nameBytes = Buffer.from(name); const data = Buffer.from(raw); const compressed = deflateRawSync(data, { level: 9 });
35
+ const method = compressed.length < data.length ? 8 : 0; const payload = method === 8 ? compressed : data; const crc = crc32(data);
36
+ const lh = Buffer.alloc(30); lh.writeUInt32LE(0x04034b50); lh.writeUInt16LE(20,4); lh.writeUInt16LE(method,8); lh.writeUInt16LE(0x5c21,12); lh.writeUInt32LE(crc,14); lh.writeUInt32LE(payload.length,18); lh.writeUInt32LE(data.length,22); lh.writeUInt16LE(nameBytes.length,26);
37
+ local.push(lh, nameBytes, payload);
38
+ const ch = Buffer.alloc(46); ch.writeUInt32LE(0x02014b50); ch.writeUInt16LE(20,4); ch.writeUInt16LE(20,6); ch.writeUInt16LE(method,10); ch.writeUInt16LE(0x5c21,14); ch.writeUInt32LE(crc,16); ch.writeUInt32LE(payload.length,20); ch.writeUInt32LE(data.length,24); ch.writeUInt16LE(nameBytes.length,28); ch.writeUInt32LE(offset,42);
39
+ central.push(ch, nameBytes); offset += lh.length + nameBytes.length + payload.length;
40
+ }
41
+ const directory = Buffer.concat(central); const end = Buffer.alloc(22); end.writeUInt32LE(0x06054b50); end.writeUInt16LE(entries.size,8); end.writeUInt16LE(entries.size,10); end.writeUInt32LE(directory.length,12); end.writeUInt32LE(offset,16);
42
+ return Buffer.concat([...local, directory, end]);
43
+ }
44
+
45
+ export class MemoryZip {
46
+ constructor(entries) { this.entries = entries; }
47
+ file(path, value) {
48
+ if (value !== undefined) { this.entries.set(path, Buffer.isBuffer(value) ? Buffer.from(value) : Buffer.from(String(value))); return this; }
49
+ const data = this.entries.get(path); if (!data) return null;
50
+ return { async: async type => type === 'string' ? data.toString('utf8') : Buffer.from(data) };
51
+ }
52
+ }
@@ -2,23 +2,19 @@
2
2
  * Shared list markdown construction/parsing helpers for command adapters.
3
3
  */
4
4
 
5
+ import {
6
+ matchListMarker,
7
+ stripListMarker
8
+ } from '../pipeline/list-markers.js';
9
+
10
+ export { inferNumberingStyleFromMarker } from '../pipeline/list-markers.js';
11
+
5
12
  /**
6
13
  * Infers numbering style from a list marker.
7
14
  *
8
15
  * @param {string} marker - Marker text
9
16
  * @returns {'decimal'|'lowerAlpha'|'upperAlpha'|'lowerRoman'|'upperRoman'}
10
17
  */
11
- export function inferNumberingStyleFromMarker(marker) {
12
- const m = (marker || '').trim();
13
- if (!m) return 'decimal';
14
- if (/^\d+(?:\.\d+)*\.?$/.test(m) || /^\(\d+\)$/.test(m)) return 'decimal';
15
- if (/^[ivxlcdm]+\.$/.test(m)) return 'lowerRoman';
16
- if (/^[IVXLCDM]{2,}\.$/.test(m)) return 'upperRoman';
17
- if (/^[a-z]\.$/.test(m)) return 'lowerAlpha';
18
- if (/^[A-Z]\.$/.test(m)) return 'upperAlpha';
19
- return 'decimal';
20
- }
21
-
22
18
  /**
23
19
  * Builds list markdown from normalized item+level input.
24
20
  *
@@ -60,8 +56,6 @@ export function buildListMarkdown(itemsWithLevels, listType, numberingStyle) {
60
56
  */
61
57
  export function normalizeListItemsWithLevels(rawItems, options = {}) {
62
58
  const indentSpaces = Math.max(1, Number(options.indentSpaces) || 4);
63
- const markersRegex = /^((?:\d+(?:\.\d+)*\.?|\((?:\d+|[a-zA-Z]|[ivxlcIVXLC]+)\)|[a-zA-Z]\.|\d+\.|[ivxlcIVXLC]+\.|[-*•])\s*)/;
64
-
65
59
  return (rawItems || []).map((rawItem) => {
66
60
  const item = String(rawItem ?? '');
67
61
  const indentMatch = item.match(/^(\s*)/);
@@ -70,10 +64,10 @@ export function normalizeListItemsWithLevels(rawItems, options = {}) {
70
64
 
71
65
  let stripped = item.trim();
72
66
  let removedMarker = null;
73
- const markerMatch = stripped.match(markersRegex);
67
+ const markerMatch = matchListMarker(stripped, { allowZeroSpaceAfterMarker: true });
74
68
  if (markerMatch) {
75
- removedMarker = markerMatch[1].trim() || null;
76
- stripped = stripped.replace(markersRegex, '');
69
+ removedMarker = markerMatch[2].trim() || null;
70
+ stripped = stripListMarker(stripped, { allowZeroSpaceAfterMarker: true });
77
71
  }
78
72
 
79
73
  return {
@@ -4,7 +4,7 @@
4
4
  * Keeps command-layer list parsing aligned with reconciliation marker logic.
5
5
  */
6
6
 
7
- import { matchListMarker, stripListMarker } from '../pipeline/list-markers.js';
7
+ import { parseListItem } from '../pipeline/list-markers.js';
8
8
 
9
9
  /**
10
10
  * Parses markdown list-like content into structured items.
@@ -26,19 +26,14 @@ export function parseMarkdownListContent(content) {
26
26
  for (const line of lines) {
27
27
  if (!line.trim()) continue;
28
28
 
29
- const markerMatch = matchListMarker(line, { allowZeroSpaceAfterMarker: false });
30
- if (markerMatch) {
31
- const indent = markerMatch[1] || '';
32
- const marker = markerMatch[2].trim();
33
- const text = stripListMarker(line, { allowZeroSpaceAfterMarker: false }).trim();
34
- const level = Math.floor(indent.length / 2);
35
- const isBullet = /^[-*+\u2022]$/.test(marker);
29
+ const parsed = parseListItem(line, { allowZeroSpaceAfterMarker: false, indentSpaces: 2 });
30
+ if (parsed) {
36
31
 
37
32
  items.push({
38
- type: isBullet ? 'bullet' : 'numbered',
39
- level,
40
- text,
41
- marker
33
+ type: parsed.markerType,
34
+ level: parsed.level,
35
+ text: parsed.text.trim(),
36
+ marker: parsed.marker
42
37
  });
43
38
  continue;
44
39
  }
@@ -11,7 +11,8 @@ import {
11
11
  normalizeWhitespaceForTargeting
12
12
  } from '../core/paragraph-targeting.js';
13
13
  import { getParagraphListInfo } from '../core/list-targeting.js';
14
- import { ReconciliationPipeline } from '../pipeline/pipeline.js';
14
+ import { executeListGeneration } from '../pipeline/list-generation.js';
15
+ import { NumberingService } from '../services/numbering-service.js';
15
16
  import { preprocessMarkdown } from '../pipeline/markdown-processor.js';
16
17
  import { parseMarkdownListContent, hasListItems } from './list-parsing.js';
17
18
  import { inferNumberingStyleFromMarker } from './list-markdown.js';
@@ -433,7 +434,8 @@ export function buildSingleLineListStructuralFallbackPlan(options = {}) {
433
434
  * @param {{
434
435
  * author?: string,
435
436
  * generateRedlines?: boolean,
436
- * pipeline?: ReconciliationPipeline,
437
+ * revisionIdAllocator?: import('../core/types.js').RevisionIdAllocator|null,
438
+ * pipeline?: import('../pipeline/pipeline.js').ReconciliationPipeline,
437
439
  * setAbstractStartOverride?: boolean
438
440
  * }} [options={}] - Execution options
439
441
  * @returns {Promise<{
@@ -461,14 +463,23 @@ export async function executeSingleLineListStructuralFallback(plan, options = {}
461
463
 
462
464
  const author = options.author || 'AI';
463
465
  const generateRedlines = options.generateRedlines ?? true;
464
- const pipeline = options.pipeline || new ReconciliationPipeline({ author, generateRedlines });
465
-
466
- const result = await pipeline.executeListGeneration(
467
- plan.listInput,
468
- null,
469
- null,
470
- String(plan.originalText || '')
471
- );
466
+ const result = options.pipeline
467
+ ? await options.pipeline.executeListGeneration(
468
+ plan.listInput,
469
+ null,
470
+ null,
471
+ String(plan.originalText || '')
472
+ )
473
+ : await executeListGeneration({
474
+ cleanText: plan.listInput,
475
+ numberingContext: null,
476
+ originalRunModel: [],
477
+ originalText: String(plan.originalText || ''),
478
+ generateRedlines,
479
+ author,
480
+ revisionIdAllocator: options.revisionIdAllocator || null,
481
+ numberingService: new NumberingService()
482
+ });
472
483
 
473
484
  const rawOxml = result?.oxml || result?.ooxml || '';
474
485
  const oxml = trimTrailingBlankParagraph(rawOxml);
package/package.json CHANGED
@@ -1,102 +1,123 @@
1
- {
2
- "name": "@ansonlai/docx-redline-js",
3
- "version": "0.4.0",
4
- "description": "Host-independent OOXML reconciliation engine for .docx manipulation with track changes",
5
- "license": "MIT",
6
- "type": "module",
7
- "main": "./index.js",
8
- "module": "./index.js",
9
- "types": "./index.d.ts",
10
- "exports": {
11
- ".": {
12
- "types": "./index.d.ts",
13
- "import": "./index.js",
14
- "default": "./index.js"
15
- },
16
- "./standalone-runner": "./services/standalone-operation-runner.js",
17
- "./redline-operation-converter": "./orchestration/redline-operation-converter.js",
18
- "./adapters/*": "./adapters/*",
19
- "./core/*": "./core/*",
20
- "./engine/*": "./engine/*",
21
- "./pipeline/*": "./pipeline/*",
22
- "./services/*": "./services/*",
23
- "./orchestration/*": "./orchestration/*"
24
- },
25
- "files": [
26
- "adapters/",
27
- "core/",
28
- "engine/",
29
- "pipeline/",
30
- "services/",
31
- "orchestration/",
32
- "scripts/",
33
- "docs/",
34
- "index.js",
35
- "index.d.ts",
36
- "dist/",
37
- "ARCHITECTURE.md",
38
- "AGENTS.md",
39
- "README.md",
40
- "LICENSE"
41
- ],
42
- "dependencies": {
43
- "diff-match-patch": "^1.0.5"
44
- },
45
- "peerDependencies": {
46
- "@xmldom/xmldom": ">=0.8.0"
47
- },
48
- "peerDependenciesMeta": {
49
- "@xmldom/xmldom": {
50
- "optional": true
51
- }
52
- },
53
- "devDependencies": {
54
- "@xmldom/xmldom": "^0.9.0",
55
- "c8": "^12.0.0",
56
- "docx-preview": "^0.4.0",
57
- "esbuild": "^0.28.1",
58
- "eslint": "^10.9.1",
59
- "jszip": "^3.10.1",
60
- "typescript": "^7.0.2"
61
- },
62
- "scripts": {
63
- "build": "node scripts/build.mjs",
64
- "check:types": "tsc -p tsconfig.types.json && node scripts/check-types.mjs",
65
- "lint": "eslint index.js adapters core engine pipeline services orchestration scripts tests",
66
- "test:coverage": "c8 --reporter=text --reporter=json --reporter=json-summary --reports-dir=coverage node scripts/run-tests.mjs",
67
- "coverage:gaps": "node scripts/report-coverage-gaps.mjs",
68
- "smoke:word": "powershell -File scripts/word-com-smoke.ps1",
69
- "smoke:word:diff": "powershell -File scripts/word-com-differential.ps1",
70
- "test:word": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/word-com-suite.ps1",
71
- "test:word:visual": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/word-com-visual-suite.ps1",
72
- "test:corpus:word": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/word-com-corpus-suite.ps1",
73
- "test:corpus:word:visual": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/word-com-corpus-visual-suite.ps1",
74
- "test:visual:inspect": "node scripts/inspect-visual-evidence.mjs",
75
- "test:visual:sample": "node scripts/sample-multimodal-visual-check.mjs",
76
- "corpus:fetch:superdoc": "node scripts/fetch-superdoc-corpus.mjs",
77
- "report:word:coverage": "node scripts/report-word-coverage.mjs",
78
- "report:test:dashboard": "node scripts/build-test-dashboard.mjs",
79
- "review:word:prepare": "node scripts/prepare-word-review.mjs",
80
- "test": "node scripts/run-tests.mjs",
81
- "test:isolation": "node tests/no_word_api_index_check.mjs && node tests/core_dependency_graph_check.mjs",
82
- "prepublishOnly": "npm run test:isolation && npm run build"
83
- },
84
- "keywords": [
85
- "docx",
86
- "ooxml",
87
- "reconciliation",
88
- "track-changes",
89
- "redlines",
90
- "word",
91
- "office",
92
- "document",
93
- "xml"
94
- ],
95
- "repository": {
96
- "type": "git",
97
- "url": "https://github.com/AnsonLai/docx-redline-js.git"
98
- },
99
- "engines": {
100
- "node": ">=20.0.0"
101
- }
102
- }
1
+ {
2
+ "name": "@ansonlai/docx-redline-js",
3
+ "version": "0.5.1",
4
+ "description": "Host-independent OOXML reconciliation engine for .docx manipulation with track changes",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./index.js",
8
+ "module": "./index.js",
9
+ "types": "./index.d.ts",
10
+ "bin": {
11
+ "docx-redline": "./bin/docx-redline.js"
12
+ },
13
+ "exports": {
14
+ ".": {
15
+ "types": "./index.d.ts",
16
+ "import": "./index.js",
17
+ "default": "./index.js"
18
+ },
19
+ "./standalone-runner": {
20
+ "types": "./services/standalone-operation-runner.d.ts",
21
+ "import": "./services/standalone-operation-runner.js",
22
+ "default": "./services/standalone-operation-runner.js"
23
+ },
24
+ "./node": {
25
+ "types": "./node/index.d.ts",
26
+ "import": "./node/index.js",
27
+ "default": "./node/index.js"
28
+ },
29
+ "./redline-operation-converter": "./orchestration/redline-operation-converter.js",
30
+ "./adapters/*": "./adapters/*",
31
+ "./core/*": "./core/*",
32
+ "./engine/*": "./engine/*",
33
+ "./pipeline/*": "./pipeline/*",
34
+ "./services/*": "./services/*",
35
+ "./orchestration/*": "./orchestration/*"
36
+ },
37
+ "files": [
38
+ "adapters/",
39
+ "core/",
40
+ "engine/",
41
+ "pipeline/",
42
+ "services/",
43
+ "node/",
44
+ "bin/",
45
+ "orchestration/",
46
+ "scripts/",
47
+ "docs/",
48
+ "index.js",
49
+ "index.d.ts",
50
+ "dist/",
51
+ "ARCHITECTURE.md",
52
+ "AGENTS.md",
53
+ "CHANGELOG.md",
54
+ "README.md",
55
+ "LICENSE"
56
+ ],
57
+ "dependencies": {
58
+ "diff-match-patch": "^1.0.5"
59
+ },
60
+ "peerDependencies": {
61
+ "@xmldom/xmldom": ">=0.8.0"
62
+ },
63
+ "peerDependenciesMeta": {
64
+ "@xmldom/xmldom": {
65
+ "optional": true
66
+ }
67
+ },
68
+ "devDependencies": {
69
+ "@xmldom/xmldom": "^0.9.0",
70
+ "c8": "^12.0.0",
71
+ "docx-preview": "^0.4.0",
72
+ "esbuild": "^0.28.1",
73
+ "eslint": "^10.9.1",
74
+ "jszip": "^3.10.1",
75
+ "typescript": "^7.0.2"
76
+ },
77
+ "scripts": {
78
+ "build": "node scripts/build.mjs",
79
+ "check:types": "tsc -p tsconfig.types.json && node scripts/check-types.mjs",
80
+ "lint": "eslint index.js adapters core engine pipeline services orchestration scripts tests",
81
+ "test:coverage": "c8 --reporter=text --reporter=json --reporter=json-summary --reports-dir=coverage node scripts/run-tests.mjs",
82
+ "coverage:gaps": "node scripts/report-coverage-gaps.mjs",
83
+ "benchmark:session": "node scripts/benchmark-operation-session.mjs",
84
+ "benchmark:targeting": "node scripts/benchmark-targeting-hot-paths.mjs",
85
+ "benchmark:tests": "node scripts/benchmark-test-runner.mjs",
86
+ "profile:routes": "node scripts/profile-route-selection.mjs",
87
+ "smoke:word": "powershell -File scripts/word-com-smoke.ps1",
88
+ "smoke:word:diff": "powershell -File scripts/word-com-differential.ps1",
89
+ "test:word": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/word-com-suite.ps1",
90
+ "test:word:visual": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/word-com-visual-suite.ps1",
91
+ "test:corpus:word": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/word-com-corpus-suite.ps1",
92
+ "test:corpus:word:visual": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/word-com-corpus-visual-suite.ps1",
93
+ "test:visual:inspect": "node scripts/inspect-visual-evidence.mjs",
94
+ "test:visual:sample": "node scripts/sample-multimodal-visual-check.mjs",
95
+ "fixtures:reredline": "node scripts/export-reredline-stress-fixtures.mjs",
96
+ "corpus:fetch:superdoc": "node scripts/fetch-superdoc-corpus.mjs",
97
+ "report:word:coverage": "node scripts/report-word-coverage.mjs",
98
+ "report:test:dashboard": "node scripts/build-test-dashboard.mjs",
99
+ "review:word:prepare": "node scripts/prepare-word-review.mjs",
100
+ "docx-redline": "node bin/docx-redline.js",
101
+ "test": "node scripts/run-tests.mjs",
102
+ "test:isolation": "node tests/no_word_api_index_check.mjs && node tests/core_dependency_graph_check.mjs",
103
+ "prepublishOnly": "npm run test:isolation && npm run build"
104
+ },
105
+ "keywords": [
106
+ "docx",
107
+ "ooxml",
108
+ "reconciliation",
109
+ "track-changes",
110
+ "redlines",
111
+ "word",
112
+ "office",
113
+ "document",
114
+ "xml"
115
+ ],
116
+ "repository": {
117
+ "type": "git",
118
+ "url": "https://github.com/AnsonLai/docx-redline-js.git"
119
+ },
120
+ "engines": {
121
+ "node": ">=20.0.0"
122
+ }
123
+ }
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { ContentType } from '../core/types.js';
6
- import { matchListMarker, stripListMarker } from './list-markers.js';
6
+ import { parseListItem } from './list-markers.js';
7
7
 
8
8
  /**
9
9
  * Parses table from markdown-style table text.
@@ -60,22 +60,17 @@ export function parseListItems(text) {
60
60
  const items = [];
61
61
 
62
62
  lines.forEach(line => {
63
- const markerMatch = matchListMarker(line, { allowZeroSpaceAfterMarker: true });
64
- if (!markerMatch) return;
65
-
66
- const marker = markerMatch[2].trim();
67
- const indent = (line.match(/^(\s*)/)?.[1].length) || 0;
68
- const listType = /^[-*+•]/.test(marker) ? 'bullet' : 'numbered';
69
- const outlineDepth = (marker.match(/\./g) || []).length;
70
- const level = outlineDepth > 1 ? Math.min(8, outlineDepth - 1) : Math.min(8, Math.floor(indent / 2));
71
-
72
- items.push({
73
- line,
74
- text: stripListMarker(line, { allowZeroSpaceAfterMarker: true }),
75
- marker,
76
- indent,
77
- level,
78
- listType
63
+ const parsed = parseListItem(line, { allowZeroSpaceAfterMarker: true, indentSpaces: 2 });
64
+ if (!parsed) return;
65
+ const level = parsed.outlineLevel ?? parsed.level;
66
+
67
+ items.push({
68
+ line,
69
+ text: parsed.text,
70
+ marker: parsed.marker,
71
+ indent: parsed.indent,
72
+ level,
73
+ listType: parsed.listType
79
74
  });
80
75
  });
81
76
 
@@ -11,6 +11,7 @@
11
11
  import { parseOoxmlSafe } from '../adapters/xml-adapter.js';
12
12
  import { NS_W } from '../core/types.js';
13
13
  import { getXmlParseError } from '../core/xml-query.js';
14
+ import { isNodeVisibleInRevisionView, readCanonicalRunText } from '../core/paragraph-text.js';
14
15
 
15
16
  function hasParserError(doc) {
16
17
  if (!doc || !doc.documentElement) return true;
@@ -54,34 +55,6 @@ function getWordAttribute(element, names) {
54
55
  return '';
55
56
  }
56
57
 
57
- function hasWordAncestorWithin(node, localName, boundary) {
58
- let cursor = node?.parentNode || null;
59
- while (cursor && cursor !== boundary) {
60
- if (cursor.nodeType === 1 && cursor.namespaceURI === NS_W && cursor.localName === localName) {
61
- return true;
62
- }
63
- cursor = cursor.parentNode;
64
- }
65
- return false;
66
- }
67
-
68
- function readRunText(run) {
69
- let text = '';
70
- for (const child of Array.from(run?.childNodes || [])) {
71
- if (!child || child.nodeType !== 1 || child.namespaceURI !== NS_W) continue;
72
- if (child.localName === 't') {
73
- text += child.textContent || '';
74
- } else if (child.localName === 'tab') {
75
- text += '\t';
76
- } else if (child.localName === 'br' || child.localName === 'cr') {
77
- text += '\n';
78
- } else if (child.localName === 'noBreakHyphen') {
79
- text += '\u2011';
80
- }
81
- }
82
- return text;
83
- }
84
-
85
58
  function getRunFormatting(run) {
86
59
  const rPr = getDirectWordChild(run, 'rPr');
87
60
  if (!rPr) return { bold: false, italic: false };
@@ -99,9 +72,8 @@ function collectParagraphSegments(paragraph) {
99
72
  const segments = [];
100
73
  const runs = Array.from(paragraph?.getElementsByTagNameNS?.(NS_W, 'r') || []);
101
74
  for (const run of runs) {
102
- if (hasWordAncestorWithin(run, 'del', paragraph)) continue;
103
- if (hasWordAncestorWithin(run, 'moveFrom', paragraph)) continue;
104
- const text = readRunText(run);
75
+ if (!isNodeVisibleInRevisionView(run, paragraph, 'accepted')) continue;
76
+ const text = readCanonicalRunText(run, { boundary: paragraph, revisionView: 'accepted' });
105
77
  if (!text) continue;
106
78
  segments.push({
107
79
  text,
@@ -91,8 +91,11 @@ export function detectNumberingContext(pElement) {
91
91
  const ilvlEl = getFirstElementByTagNS(numPr, NS_W, 'ilvl');
92
92
  if (!numIdEl) return null;
93
93
 
94
- const numId = numIdEl.getAttribute('w:val');
95
- const type = numId === '1' ? 'bullet' : (numId === '2' ? 'numbered' : 'unknown');
94
+ const numId = numIdEl.getAttribute('w:val');
95
+ // In WordprocessingML, numId="0" explicitly removes numbering inherited
96
+ // from a paragraph style. It is not a reusable list definition.
97
+ if (!/^\d+$/.test(numId) || Number.parseInt(numId, 10) === 0) return null;
98
+ const type = numId === '1' ? 'bullet' : (numId === '2' ? 'numbered' : 'unknown');
96
99
 
97
100
  return {
98
101
  numId,
@@ -329,9 +332,11 @@ function processRun(runElement, startOffset) {
329
332
  text += '\n';
330
333
  } else if (nodeName.endsWith(':tab') || nodeName === 'tab') {
331
334
  text += '\t';
332
- } else if (nodeName.endsWith(':noBreakHyphen') || nodeName === 'noBreakHyphen') {
333
- text += '\u2011';
334
- }
335
+ } else if (nodeName.endsWith(':noBreakHyphen') || nodeName === 'noBreakHyphen') {
336
+ text += '\u2011';
337
+ } else if (nodeName.endsWith(':softHyphen') || nodeName === 'softHyphen') {
338
+ text += '\u00ad';
339
+ }
335
340
  }
336
341
 
337
342
  if (!text) return null;