@ansonlai/docx-redline-js 0.8.4 → 0.10.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 (72) hide show
  1. package/ARCHITECTURE.md +679 -460
  2. package/CHANGELOG.md +208 -1
  3. package/README.md +152 -16
  4. package/core/current-properties.js +136 -0
  5. package/core/paragraph-text.js +211 -209
  6. package/core/types.js +9 -3
  7. package/core/word-revision-semantics.js +256 -0
  8. package/core/word-xml.js +160 -118
  9. package/dist/docx-redline-js.esm.js +4338 -796
  10. package/dist/docx-redline-js.esm.js.map +4 -4
  11. package/dist/docx-redline-js.esm.min.js +111 -108
  12. package/dist/docx-redline-js.esm.min.js.map +4 -4
  13. package/dist/docx-redline.bundle.cjs +4463 -921
  14. package/dist/docx-redline.bundle.cjs.map +4 -4
  15. package/dist/docx-redline.bundle.js +4463 -921
  16. package/dist/docx-redline.bundle.js.map +4 -4
  17. package/docs/AGENT_FAST_START.md +2 -0
  18. package/docs/AGENT_KNOWLEDGE_BASE.md +49 -7
  19. package/docs/SKILL_AUTHORING.md +169 -150
  20. package/docs/TESTING.md +1146 -1059
  21. package/docs/schemas/document-operations.schema.json +55 -2
  22. package/document/docx-document.js +1022 -1010
  23. package/engine/oxml-engine.js +21 -22
  24. package/engine/paragraph-mark-revisions.js +292 -0
  25. package/engine/reconstruction-mapper.js +312 -311
  26. package/engine/reconstruction-writer.js +67 -10
  27. package/engine/rpr-helpers.js +42 -0
  28. package/engine/run-builders.js +0 -73
  29. package/engine/surgical-diff-application.js +604 -605
  30. package/engine/table-mode.js +63 -63
  31. package/index.d.ts +53 -2
  32. package/index.js +13 -2
  33. package/node/cli-help.js +1 -0
  34. package/node/cli.js +21 -3
  35. package/orchestration/list-structural-fallback.js +67 -9
  36. package/package.json +1 -1
  37. package/pipeline/diff-engine.js +130 -1
  38. package/pipeline/ingestion-export.js +227 -243
  39. package/pipeline/ingestion-paragraph.js +93 -98
  40. package/pipeline/list-generation.js +415 -419
  41. package/pipeline/markdown-processor.js +5 -2
  42. package/pipeline/pipeline.js +206 -204
  43. package/scripts/export-validation-fixtures.mjs +19 -6
  44. package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
  45. package/scripts/lib/word-coverage-catalogue.mjs +7 -2
  46. package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
  47. package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
  48. package/scripts/word-com-differential.ps1 +443 -335
  49. package/scripts/word-com-visual-suite.ps1 +2 -2
  50. package/scripts/word-revision-semantics-matrix.ps1 +108 -0
  51. package/services/batch-operation-orchestrator.js +23 -3
  52. package/services/capture-engine.js +228 -226
  53. package/services/comment-replies.js +1 -1
  54. package/services/document-operation-applier.js +214 -0
  55. package/services/document-operation-contract.js +76 -0
  56. package/services/document-operation-mutations.js +688 -51
  57. package/services/error-recovery.js +17 -0
  58. package/services/inspection-comparison.js +82 -0
  59. package/services/list-conversion-diagnostics.js +296 -0
  60. package/services/list-format-change.js +586 -0
  61. package/services/list-format-mutation.js +416 -0
  62. package/services/list-format.js +305 -0
  63. package/services/numbering-allocation.js +642 -0
  64. package/services/numbering-helpers.js +354 -524
  65. package/services/operation-batch-compiler.js +183 -11
  66. package/services/operation-preflight.js +43 -33
  67. package/services/receipt-collector.js +288 -288
  68. package/services/revision-comment-management.js +38 -5
  69. package/services/revision-fidelity-verifier.js +484 -0
  70. package/services/standalone-docx-plumbing.js +627 -631
  71. package/services/standalone-operation-runner.d.ts +182 -5
  72. package/services/table-reconciliation.js +59 -7
@@ -2,30 +2,30 @@
2
2
  * Table-specific reconciliation and transformation flows.
3
3
  */
4
4
 
5
- import {
6
- NS_W,
7
- createRevisionMetadata,
8
- getRevisionIdAllocatorForDocument,
9
- setRevisionIdAllocatorForDocument
10
- } from '../core/types.js';
11
- import {
12
- getElementsByTagNS,
13
- getElementsByTagNSOrTag,
14
- getFirstElementByTagNS,
15
- getFirstElementByTagNSOrTag,
16
- getXmlParseError
17
- } from '../core/xml-query.js';
18
- import { parseOoxmlSafe } from '../adapters/xml-adapter.js';
19
- import { log, error } from '../adapters/logger.js';
20
- import { diffTablesWithVirtualGrid, serializeVirtualGridToOoxml, generateTableOoxml } from '../services/table-reconciliation.js';
21
- import { parseTable } from '../pipeline/pipeline.js';
22
- import { ingestTableToVirtualGrid } from '../pipeline/ingestion.js';
23
- import { createWordElement, withOoxmlSourceType } from '../core/word-xml.js';
24
- import { markParagraphMarkDeleted } from './run-builders.js';
25
-
26
- function noChanges(serializer, xmlDoc) {
27
- return withOoxmlSourceType({ oxml: serializer.serializeToString(xmlDoc), hasChanges: false });
28
- }
5
+ import {
6
+ NS_W,
7
+ createRevisionMetadata,
8
+ getRevisionIdAllocatorForDocument,
9
+ setRevisionIdAllocatorForDocument
10
+ } from '../core/types.js';
11
+ import {
12
+ getElementsByTagNS,
13
+ getElementsByTagNSOrTag,
14
+ getFirstElementByTagNS,
15
+ getFirstElementByTagNSOrTag,
16
+ getXmlParseError
17
+ } from '../core/xml-query.js';
18
+ import { parseOoxmlSafe } from '../adapters/xml-adapter.js';
19
+ import { log, error } from '../adapters/logger.js';
20
+ import { diffTablesWithVirtualGrid, serializeVirtualGridToOoxml, generateTableOoxml } from '../services/table-reconciliation.js';
21
+ import { parseTable } from '../pipeline/pipeline.js';
22
+ import { ingestTableToVirtualGrid } from '../pipeline/ingestion.js';
23
+ import { createWordElement, withOoxmlSourceType } from '../core/word-xml.js';
24
+ import { markParagraphDeleted } from './paragraph-mark-revisions.js';
25
+
26
+ function noChanges(serializer, xmlDoc) {
27
+ return withOoxmlSourceType({ oxml: serializer.serializeToString(xmlDoc), hasChanges: false });
28
+ }
29
29
 
30
30
  /**
31
31
  * Applies structural reconciliation to tables using Virtual Grid.
@@ -38,8 +38,8 @@ function noChanges(serializer, xmlDoc) {
38
38
  * @param {boolean} [generateRedlines=true] - Track change toggle
39
39
  * @returns {{ oxml: string, hasChanges: boolean }}
40
40
  */
41
- export function applyTableReconciliation(xmlDoc, modifiedText, serializer, parser, author, generateRedlines = true) {
42
- const tableNodes = getElementsByTagNSOrTag(xmlDoc, NS_W, 'tbl');
41
+ export function applyTableReconciliation(xmlDoc, modifiedText, serializer, parser, author, generateRedlines = true) {
42
+ const tableNodes = getElementsByTagNSOrTag(xmlDoc, NS_W, 'tbl');
43
43
  const newTableData = parseTable(modifiedText);
44
44
  const hasNewContent = newTableData.rows.length > 0 || newTableData.headers.length > 0;
45
45
 
@@ -55,15 +55,15 @@ export function applyTableReconciliation(xmlDoc, modifiedText, serializer, parse
55
55
  return noChanges(serializer, xmlDoc);
56
56
  }
57
57
 
58
- const options = {
59
- generateRedlines,
60
- author,
61
- revisionIdAllocator: getRevisionIdAllocatorForDocument(xmlDoc)
62
- };
58
+ const options = {
59
+ generateRedlines,
60
+ author,
61
+ revisionIdAllocator: getRevisionIdAllocatorForDocument(xmlDoc)
62
+ };
63
63
  const reconciledOxml = serializeVirtualGridToOoxml(oldGrid, operations, options);
64
64
  const wrappedOxml = `<root xmlns:w="${NS_W}">${reconciledOxml}</root>`;
65
- const reconciledDoc = parseOoxmlSafe(wrappedOxml, 'application/xml').doc;
66
- if (!reconciledDoc) return noChanges(serializer, xmlDoc);
65
+ const reconciledDoc = parseOoxmlSafe(wrappedOxml, 'application/xml').doc;
66
+ if (!reconciledDoc) return noChanges(serializer, xmlDoc);
67
67
 
68
68
  const parseError = getXmlParseError(reconciledDoc);
69
69
  if (parseError) {
@@ -71,7 +71,7 @@ export function applyTableReconciliation(xmlDoc, modifiedText, serializer, parse
71
71
  return noChanges(serializer, xmlDoc);
72
72
  }
73
73
 
74
- const newTableNode = getFirstElementByTagNSOrTag(reconciledDoc, NS_W, 'tbl');
74
+ const newTableNode = getFirstElementByTagNSOrTag(reconciledDoc, NS_W, 'tbl');
75
75
  if (!newTableNode) {
76
76
  error('[OxmlEngine] No table found in reconciled OOXML');
77
77
  return noChanges(serializer, xmlDoc);
@@ -80,7 +80,7 @@ export function applyTableReconciliation(xmlDoc, modifiedText, serializer, parse
80
80
  const importedTable = xmlDoc.importNode(newTableNode, true);
81
81
  targetTable.parentNode.replaceChild(importedTable, targetTable);
82
82
 
83
- return withOoxmlSourceType({ oxml: serializer.serializeToString(xmlDoc), hasChanges: true });
83
+ return withOoxmlSourceType({ oxml: serializer.serializeToString(xmlDoc), hasChanges: true });
84
84
  }
85
85
 
86
86
  /**
@@ -94,17 +94,17 @@ export function applyTableReconciliation(xmlDoc, modifiedText, serializer, parse
94
94
  * @param {boolean} generateRedlines - Track change toggle
95
95
  * @returns {{ oxml: string, hasChanges: boolean }}
96
96
  */
97
- export function applyTextToTableTransformation(xmlDoc, modifiedText, serializer, parser, author, generateRedlines) {
98
- const revisionIdAllocator = getRevisionIdAllocatorForDocument(xmlDoc);
97
+ export function applyTextToTableTransformation(xmlDoc, modifiedText, serializer, parser, author, generateRedlines) {
98
+ const revisionIdAllocator = getRevisionIdAllocatorForDocument(xmlDoc);
99
99
  const tableData = parseTable(modifiedText);
100
100
  if (!tableData || (tableData.rows.length === 0 && tableData.headers.length === 0)) {
101
101
  log('[OxmlEngine] Failed to parse table data from Markdown');
102
102
  return noChanges(serializer, xmlDoc);
103
103
  }
104
104
 
105
- const tableOoxml = generateTableOoxml(tableData, { generateRedlines, author, revisionIdAllocator });
106
- const tableDoc = parseOoxmlSafe(`<root xmlns:w="${NS_W}">${tableOoxml}</root>`, 'application/xml').doc;
107
- if (!tableDoc) return noChanges(serializer, xmlDoc);
105
+ const tableOoxml = generateTableOoxml(tableData, { generateRedlines, author, revisionIdAllocator, trackAsBlock: true });
106
+ const tableDoc = parseOoxmlSafe(`<root xmlns:w="${NS_W}">${tableOoxml}</root>`, 'application/xml').doc;
107
+ if (!tableDoc) return noChanges(serializer, xmlDoc);
108
108
 
109
109
  const tableParseError = getXmlParseError(tableDoc);
110
110
  if (tableParseError) {
@@ -132,16 +132,16 @@ export function applyTextToTableTransformation(xmlDoc, modifiedText, serializer,
132
132
  let parent = firstParagraph.parentNode;
133
133
 
134
134
  if (parent && parent.nodeType === 9) {
135
- const wrappedDoc = parseOoxmlSafe(
136
- `<w:document xmlns:w="${NS_W}"><w:body/></w:document>`,
137
- 'application/xml'
138
- ).doc;
139
- if (!wrappedDoc) return noChanges(serializer, workingDoc);
135
+ const wrappedDoc = parseOoxmlSafe(
136
+ `<w:document xmlns:w="${NS_W}"><w:body/></w:document>`,
137
+ 'application/xml'
138
+ ).doc;
139
+ if (!wrappedDoc) return noChanges(serializer, workingDoc);
140
140
  const wrappedBody = getFirstElementByTagNS(wrappedDoc, NS_W, 'body');
141
141
  paragraphs.forEach(p => wrappedBody.appendChild(wrappedDoc.importNode(p, true)));
142
142
 
143
- workingDoc = wrappedDoc;
144
- setRevisionIdAllocatorForDocument(workingDoc, revisionIdAllocator);
143
+ workingDoc = wrappedDoc;
144
+ setRevisionIdAllocatorForDocument(workingDoc, revisionIdAllocator);
145
145
  paragraphs = getElementsByTagNS(workingDoc, NS_W, 'p');
146
146
  firstParagraph = paragraphs[0];
147
147
  parent = firstParagraph.parentNode;
@@ -149,26 +149,26 @@ export function applyTextToTableTransformation(xmlDoc, modifiedText, serializer,
149
149
 
150
150
  const importedTable = workingDoc.importNode(newTableElement, true);
151
151
 
152
- if (generateRedlines) {
153
- paragraphs.forEach(p => {
154
- markParagraphMarkDeleted(workingDoc, p, author);
155
- const runs = getElementsByTagNS(p, NS_W, 'r');
156
- runs.forEach(run => {
152
+ if (generateRedlines) {
153
+ paragraphs.forEach(p => {
154
+ markParagraphDeleted(p, { author, revisionIdAllocator: workingDoc });
155
+ const runs = getElementsByTagNS(p, NS_W, 'r');
156
+ runs.forEach(run => {
157
157
  const textNodes = getElementsByTagNS(run, NS_W, 't');
158
158
  textNodes.forEach(t => {
159
159
  const text = t.textContent || '';
160
160
  if (text.trim()) {
161
- const delText = createWordElement(workingDoc, 'w:delText');
162
- delText.textContent = text;
163
- t.parentNode.replaceChild(delText, t);
161
+ const delText = createWordElement(workingDoc, 'w:delText');
162
+ delText.textContent = text;
163
+ t.parentNode.replaceChild(delText, t);
164
164
  }
165
165
  });
166
-
167
- const del = createWordElement(workingDoc, 'w:del');
168
- const metadata = createRevisionMetadata(author, workingDoc, 'del');
169
- del.setAttribute('w:id', String(metadata.id));
170
- del.setAttribute('w:author', metadata.author);
171
- del.setAttribute('w:date', metadata.date);
166
+
167
+ const del = createWordElement(workingDoc, 'w:del');
168
+ const metadata = createRevisionMetadata(author, workingDoc, 'del');
169
+ del.setAttribute('w:id', String(metadata.id));
170
+ del.setAttribute('w:author', metadata.author);
171
+ del.setAttribute('w:date', metadata.date);
172
172
  run.parentNode.insertBefore(del, run);
173
173
  del.appendChild(run);
174
174
  });
@@ -183,5 +183,5 @@ export function applyTextToTableTransformation(xmlDoc, modifiedText, serializer,
183
183
  }
184
184
 
185
185
  log('[OxmlEngine] Text-to-table transformation complete');
186
- return withOoxmlSourceType({ oxml: serializer.serializeToString(workingDoc), hasChanges: true });
187
- }
186
+ return withOoxmlSourceType({ oxml: serializer.serializeToString(workingDoc), hasChanges: true });
187
+ }
package/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export type OoxmlSourceType = 'package' | 'document' | 'fragment';
2
2
  export type RedlineStatus = 'ok' | 'no-op' | 'partial' | 'error';
3
- export type ExistingRevisionsPolicy = 'merge-same-author' | 'slice-cross-author' | 'reject-input' | 'accept-all-first' | 'accept-all-first-keep-normalized';
3
+ export type ExistingRevisionsPolicy = 'auto' | 'merge-same-author' | 'slice-cross-author' | 'reject-input' | 'accept-all-first' | 'accept-all-first-keep-normalized';
4
4
  export type RevisionView = 'accepted' | 'rejected';
5
5
 
6
6
  export interface RevisionTextSegment {
@@ -30,6 +30,16 @@ export type {
30
30
  DocumentOperationBatchResult,
31
31
  HighlightDocumentOperation,
32
32
  InsertionAffinity,
33
+ ListConversionDiagnostic,
34
+ ListFormatDocumentOperation,
35
+ ListFormatRequest,
36
+ ListFormatConvertPlainRequest,
37
+ ListFormatChangeFormatRequest,
38
+ ListFormatJoinRequest,
39
+ ListFormatResult,
40
+ ListFormatConvertPlainResult,
41
+ ListFormatRebindResult,
42
+ ListNumberFormat,
33
43
  LocalizedReplacement,
34
44
  LocalizedReplacementChange,
35
45
  OperationConflict,
@@ -52,6 +62,13 @@ export interface RedlineError {
52
62
  | 'DIFF_TOKEN_LIMIT'
53
63
  | 'UNSUPPORTED_REVISION_VIEW_MUTATION'
54
64
  | 'UNSUPPORTED_INSERTION_AFFINITY'
65
+ | 'UNSUPPORTED_LIST_CONVERSION'
66
+ | 'UNSUPPORTED_LIST_FORMAT_CHANGE'
67
+ | 'LIST_MARKER_NOT_FOUND'
68
+ | 'LIST_MARKER_AMBIGUOUS'
69
+ | 'LIST_MARKER_UNSUPPORTED'
70
+ | 'LIST_FORMAT_TARGET_IS_LIST'
71
+ | 'LIST_FORMAT_STYLE_NUMBERED'
55
72
  | 'UNSAFE_PARAGRAPH_BOUNDARY'
56
73
  | 'FOREIGN_PARAGRAPH_MARK_DELETION'
57
74
  | 'RESTORATION_STATE_REQUIRED'
@@ -80,6 +97,11 @@ export interface RedlineError {
80
97
  recoveryVersion?: number;
81
98
  category?: 'request_fixable' | 'target_refresh_required' | 'candidate_selection_required' | 'policy_choice_required' | 'user_authorization_required' | 'source_conflict' | 'library_or_builder_failure' | 'manual_document_resolution' | string;
82
99
  context?: Record<string, unknown>;
100
+ /** List-conversion errors: the literal marker, the requested list kind/format and the kept source definition. */
101
+ listMarker?: string;
102
+ requestedList?: { kind: 'bullet' | 'numbered'; format: string };
103
+ sourceList?: { numId: string; level: number; kind: 'bullet' | 'numbered'; format: string };
104
+ targetExcerpt?: string;
83
105
  recovery?: {
84
106
  action: string;
85
107
  sameArgumentsSafe: boolean;
@@ -87,6 +109,8 @@ export interface RedlineError {
87
109
  requiresUserAuthorization: boolean;
88
110
  field?: string;
89
111
  recommendedValue?: unknown;
112
+ allowedValue?: unknown;
113
+ hint?: string;
90
114
  };
91
115
  expectedRevision?: RevisionToken;
92
116
  currentRevision?: RevisionToken;
@@ -348,6 +372,11 @@ export function inspectDocumentParts(parts: { documentXml: string; commentsXml?:
348
372
  export function buildRevisionTokenFraming(options: { scope: string; entries?: unknown }): { framing: Uint8Array; scope: string; version: number; coveredParts: string[] };
349
373
  export function computeRevisionToken(options: { scope: string; entries?: unknown; digestFn?: (bytes: Uint8Array) => Promise<string> }): Promise<RevisionToken>;
350
374
  export function computeRevisionTokenSync(options: { scope: string; entries?: unknown; digestFn: (bytes: Uint8Array) => string }): RevisionToken;
375
+ /** Compare two inspections by visible text, table position, list label, review segments and comments (ids, dates and run boundaries ignored). */
376
+ export function compareDocumentInspections(expected: { paragraphs: unknown[]; comments?: unknown[] }, actual: { paragraphs: unknown[]; comments?: unknown[] }): { matches: boolean; differences: Array<{ path: string; expected: unknown; actual: unknown }> };
377
+ export function inspectionReviewShape(inspection: { paragraphs: unknown[]; comments?: unknown[] }): { paragraphs: unknown[]; comments: string[] };
378
+ /** Resolve `existingRevisions: 'auto'` for one target node; other policies are returned unchanged. */
379
+ export function resolveExistingRevisionsPolicy(policy: ExistingRevisionsPolicy | null | undefined, node: unknown, author: string): ExistingRevisionsPolicy | null | undefined;
351
380
  export function computeDocumentPartsRevisionToken(parts: unknown, options?: { digestFn?: (bytes: Uint8Array) => Promise<string> }): Promise<RevisionToken>;
352
381
  export function validateRevisionToken(token: unknown): { valid: boolean; error?: { code: string; message: string } };
353
382
  export function areRevisionTokensEqual(a: string, b: string): boolean;
@@ -437,7 +466,29 @@ export function overwriteParagraphNumIds(oxml: string, numId: string | number):
437
466
  export function extractFirstParagraphNumId(oxml: string): string | null;
438
467
  export function buildExplicitDecimalMultilevelNumberingXml(options?: Record<string, unknown>): string;
439
468
  export function remapNumberingPayloadForDocument(numberingXml: string, state: unknown): unknown;
440
- export function mergeNumberingXmlBySchemaOrder(baseXml: string, incomingXml: string): string;
469
+ export interface NumberingIdConflict { kind: 'abstractNum' | 'num'; id: number }
470
+ export interface NumberingMergeOptions { onConflict?: (conflict: NumberingIdConflict) => void }
471
+ export interface NumberingIntegrityIssue { code: string; severity: 'error'; message: string; numId?: number; abstractNumId?: number; ilvl?: number }
472
+ export function mergeNumberingXmlBySchemaOrder(baseXml: string, incomingXml: string, options?: NumberingMergeOptions): string;
473
+ export function mergeNumberingParts(existingXml: string | null | undefined, incomingParts: string | Array<string | null | undefined> | null | undefined, options?: NumberingMergeOptions): string;
474
+ export function allocateNumberingDefinition(params: {
475
+ numberingIdState: unknown;
476
+ sourceNumberingXml?: string | null;
477
+ sourceNumId?: number | string | null;
478
+ levels?: Array<{ ilvl: number; numFmt?: string; lvlText?: string; start?: number }>;
479
+ startOverrides?: Record<number | string, number>;
480
+ }): { numId: number; abstractNumId: number; numberingXml: string } | null;
481
+ export function allocateNumberingForReplacement(params: {
482
+ numberingXml: string | null;
483
+ replacementNodes: unknown[];
484
+ sourceParagraphs?: unknown[];
485
+ numberingIdState?: unknown;
486
+ allowContinueSource?: boolean;
487
+ }): { policy: 'continue-source' | 'allocate' | 'passthrough'; numberingXml: string | null; replacementNodes: unknown[]; numId?: number };
488
+ export function applyStartOverridesToNum(numElement: unknown, startOverrides: Record<number | string, number>): void;
489
+ export function copyLevelGeometry(sourceLvl: unknown, targetLvl: unknown): void;
490
+ export function canonicalNumberingDefinition(numbering: unknown, numId: string | number): string | null;
491
+ export function validateNumberingIntegrity(documentXml: unknown, numberingXml?: unknown): NumberingIntegrityIssue[];
441
492
 
442
493
  export function buildListMarkdown(items: unknown[], options?: Record<string, unknown>): string;
443
494
  export function inferNumberingStyleFromMarker(marker: string): string;
package/index.js CHANGED
@@ -260,9 +260,18 @@ export {
260
260
  overwriteParagraphNumIds,
261
261
  extractFirstParagraphNumId,
262
262
  buildExplicitDecimalMultilevelNumberingXml,
263
- remapNumberingPayloadForDocument,
264
- mergeNumberingXmlBySchemaOrder
263
+ remapNumberingPayloadForDocument
265
264
  } from './services/numbering-helpers.js';
265
+ export {
266
+ allocateNumberingDefinition,
267
+ allocateNumberingForReplacement,
268
+ applyStartOverridesToNum,
269
+ canonicalNumberingDefinition,
270
+ copyLevelGeometry,
271
+ mergeNumberingParts,
272
+ mergeNumberingXmlBySchemaOrder,
273
+ validateNumberingIntegrity
274
+ } from './services/numbering-allocation.js';
266
275
 
267
276
  // Pipeline components
268
277
  export { ReconciliationPipeline } from './pipeline/pipeline.js';
@@ -275,6 +284,8 @@ export {
275
284
  } from './pipeline/ingestion-export.js';
276
285
  export { extractCanonicalParagraphText, readCanonicalRunText, isNodeVisibleInRevisionView, extractParagraphRevisionSegments } from './core/paragraph-text.js';
277
286
  export { inspectDocumentParts } from './services/document-inspection.js';
287
+ export { compareDocumentInspections, inspectionReviewShape } from './services/inspection-comparison.js';
288
+ export { resolveExistingRevisionsPolicy } from './core/word-xml.js';
278
289
  export {
279
290
  buildRevisionTokenFraming,
280
291
  computeRevisionToken,
package/node/cli-help.js CHANGED
@@ -144,6 +144,7 @@ export const CLI_COMMAND_HELP = Object.freeze({
144
144
  option('textToComment', '--text-to-comment <text>', 'Anchor an inline comment to an exact subspan.'),
145
145
  option('targetRef', '--target-ref <N>', 'Disambiguate an inline target with a 1-based machine index.'),
146
146
  option('existingRevisions', '--existing-revisions <policy>', 'Select revision handling; cross-author slicing must be deliberate.'),
147
+ option('revisionFidelity', '--revision-fidelity <enforce|warn|off>', 'Reject All verification of each tracked operation (default enforce: refuse with REVISION_FIDELITY_VIOLATION); the agent profile rejects off.'),
147
148
  option('atomic', '--atomic[=true|false]', 'Choose all-or-nothing or progressive batch execution.'),
148
149
  option('generateRedlines', '--generate-redlines[=true|false]', 'Control tracked-change generation.'),
149
150
  option('noRedlines', '--no-redlines', 'Apply clean text without tracked-change markup.'),
package/node/cli.js CHANGED
@@ -7,11 +7,12 @@ import { validateRedlineOoxml } from '../core/redline-validation.js';
7
7
  import { configureLogger } from '../adapters/logger.js';
8
8
  import { isExistingRevisionsPolicy } from '../services/document-operation-contract.js';
9
9
  import { normalizeErrorWithRecovery } from '../services/error-recovery.js';
10
+ import { REVISION_FIDELITY_MODES, resolveRevisionFidelityMode } from '../services/revision-fidelity-verifier.js';
10
11
  import { localizedReplacementOffsets } from '../services/localized-replacement-compiler.js';
11
12
  import { buildCliHelp, CLI_COMMANDS, commandOptionKeys } from './cli-help.js';
12
13
 
13
14
  const suffixes = { apply: 'redlined', accept: 'accepted', reject: 'rejected', 'delete-comments': 'comments-removed' };
14
- const CLI_CONTRACT_VERSION = 8;
15
+ const CLI_CONTRACT_VERSION = 9;
15
16
  const DEFAULT_INSPECTION_LIMIT = 20;
16
17
  const INSPECTION_SOFT_BYTE_LIMIT = 48 * 1024;
17
18
  const CLI_CAPABILITIES = [
@@ -39,7 +40,13 @@ const CLI_CAPABILITIES = [
39
40
  'comment-resolve-operation-v1',
40
41
  'delete-comments-by-id-v1',
41
42
  'header-footer-parts-v1',
42
- 'format-text-occurrence-v1'
43
+ 'format-text-occurrence-v1',
44
+ 'revision-fidelity-v1',
45
+ 'list-conversion-diagnostics-v1',
46
+ 'paragraph-numbering-binding-v1',
47
+ 'list-format-v1',
48
+ 'list-format-change-v1',
49
+ 'existing-revisions-auto-v1'
43
50
  ];
44
51
  const commandOptions = Object.fromEntries(CLI_COMMANDS.map(command => [command, new Set(commandOptionKeys(command))]));
45
52
 
@@ -597,7 +604,9 @@ function compactError(error) {
597
604
  'code', 'stage', 'mismatchOffset', 'expectedExcerpt', 'actualExcerpt',
598
605
  'expectedCodePoint', 'actualCodePoint', 'ownerAuthor', 'commentIds',
599
606
  'recoveryVersion', 'category', 'field', 'captureRef', 'operationIndexes',
600
- 'consumedByOperation', 'expectedScope', 'actualScope'
607
+ 'consumedByOperation', 'expectedScope', 'actualScope',
608
+ 'invariants', 'violations', 'verificationMode', 'baselineView',
609
+ 'listMarker', 'requestedList', 'sourceList', 'targetExcerpt'
601
610
  ];
602
611
  const compact = {};
603
612
  for (const field of fields) {
@@ -823,6 +832,12 @@ export async function executeCli(argv, io = process) {
823
832
  if (profile && profile !== 'agent') {
824
833
  return cliError('INVALID_PROFILE', `Unknown execution profile: "${profile}". Supported profiles: agent.`);
825
834
  }
835
+ if (flags.revisionFidelity != null && !REVISION_FIDELITY_MODES.includes(String(flags.revisionFidelity))) {
836
+ return cliError('INVALID_OPERATION', `Unsupported revision-fidelity mode: "${String(flags.revisionFidelity)}". Use enforce, warn or off.`);
837
+ }
838
+ if (profile === 'agent' && String(flags.revisionFidelity) === 'off') {
839
+ return cliError('INVALID_PROFILE', 'The agent profile requires Reject All verification; --revision-fidelity off is only for diagnosis outside the agent profile.');
840
+ }
826
841
  let inspectOptions = null;
827
842
  if (command === 'inspect' || command === 'extract') {
828
843
  try { inspectOptions = inspectionOptions(flags); }
@@ -925,6 +940,7 @@ export async function executeCli(argv, io = process) {
925
940
  validate: true,
926
941
  generateRedlines,
927
942
  existingRevisions: flags.existingRevisions || 'merge-same-author',
943
+ verifyRevisionFidelity: resolveRevisionFidelityMode(flags.revisionFidelity == null ? undefined : String(flags.revisionFidelity)),
928
944
  requireComplete
929
945
  };
930
946
  const result = await document.applyOperations(operations, {
@@ -932,8 +948,10 @@ export async function executeCli(argv, io = process) {
932
948
  atomic,
933
949
  validate: true,
934
950
  strictTargets: true,
951
+ strictListConversion: agentProfile || atomic,
935
952
  generateRedlines,
936
953
  ...(flags.existingRevisions ? { existingRevisions: flags.existingRevisions } : {}),
954
+ verifyRevisionFidelity: effectiveOptions.verifyRevisionFidelity,
937
955
  ...(expectedRevision ? { expectedRevision } : {})
938
956
  });
939
957
  const localizedVerificationFailed = (result.results || []).some(item => item?.change && (
@@ -5,7 +5,9 @@
5
5
 
6
6
  import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
7
7
  import { getXmlParseError } from '../core/xml-query.js';
8
- import { NS_W } from '../core/types.js';
8
+ import { NS_W, createRevisionMetadata } from '../core/types.js';
9
+ import { clonePropertiesWithoutRevisionHistory } from '../core/revision-cloning.js';
10
+ import { getCurrentNumIdElements } from '../core/current-properties.js';
9
11
  import { createWordElement } from '../core/word-xml.js';
10
12
  import {
11
13
  getDocumentParagraphNodes,
@@ -62,12 +64,47 @@ export function stripSingleLineListMarkerPrefix(text) {
62
64
  return String(candidate.contentText || '').trim();
63
65
  }
64
66
 
65
- function parseMarkerStart(marker, numberingStyle) {
66
- if (numberingStyle !== 'decimal') return null;
67
- const match = String(marker || '').trim().match(/^(\d+)\.?$/);
68
- if (!match) return null;
69
- const value = Number.parseInt(match[1], 10);
70
- return Number.isFinite(value) && value > 0 ? value : null;
67
+ const ROMAN_VALUES = { i: 1, v: 5, x: 10, l: 50, c: 100, d: 500, m: 1000 };
68
+
69
+ function parseRomanNumeral(text) {
70
+ const letters = String(text || '').toLowerCase();
71
+ if (!/^[ivxlcdm]+$/.test(letters)) return null;
72
+ let total = 0;
73
+ for (let i = 0; i < letters.length; i++) {
74
+ const value = ROMAN_VALUES[letters[i]];
75
+ const next = ROMAN_VALUES[letters[i + 1]] || 0;
76
+ total += value < next ? -value : value;
77
+ }
78
+ return total;
79
+ }
80
+
81
+ /**
82
+ * Derives the numeric start a literal marker implies: `7.` -> 7, `C.` -> 3, `iv.` -> 4.
83
+ * Decimal markers keep every positive value (including 1). Letter and roman markers report only
84
+ * values above 1, because the default start (1) needs no override and keeps the shared-list behavior.
85
+ *
86
+ * @param {string} marker
87
+ * @param {string} numberingStyle
88
+ * @returns {number|null}
89
+ */
90
+ export function parseMarkerStart(marker, numberingStyle) {
91
+ const text = String(marker || '').trim();
92
+ let value = null;
93
+ if (numberingStyle === 'decimal') {
94
+ const match = text.match(/^(\d+)\.?$/);
95
+ value = match ? Number.parseInt(match[1], 10) : null;
96
+ } else if (numberingStyle === 'lowerRoman' || numberingStyle === 'upperRoman') {
97
+ const match = text.match(/^([A-Za-z]+)[.)]?$/);
98
+ value = match ? parseRomanNumeral(match[1]) : null;
99
+ } else if (numberingStyle === 'lowerAlpha' || numberingStyle === 'upperAlpha') {
100
+ const match = text.match(/^([A-Za-z])[.)]?$/);
101
+ value = match ? match[1].toLowerCase().charCodeAt(0) - 96 : null;
102
+ } else {
103
+ return null;
104
+ }
105
+ if (!Number.isFinite(value) || value < 1) return null;
106
+ if (numberingStyle !== 'decimal' && value === 1) return null;
107
+ return value;
71
108
  }
72
109
 
73
110
  /**
@@ -199,7 +236,8 @@ function getDirectWordChild(element, localName) {
199
236
  * numId: string|number,
200
237
  * ilvl?: number,
201
238
  * clearParagraphPropertyChanges?: boolean,
202
- * removeListPropertyNode?: boolean
239
+ * removeListPropertyNode?: boolean,
240
+ * trackPropertyChange?: { author?: string, revisionIdAllocator?: object|null }|null
203
241
  * }} options - Binding options
204
242
  * @returns {number} Number of paragraph nodes updated
205
243
  */
@@ -209,6 +247,7 @@ export function enforceListBindingOnParagraphNodes(nodes, options = {}) {
209
247
  const ilvl = Number.isInteger(options?.ilvl) ? Math.max(0, options.ilvl) : 0;
210
248
  const clearParagraphPropertyChanges = options?.clearParagraphPropertyChanges !== false;
211
249
  const removeListPropertyNode = options?.removeListPropertyNode !== false;
250
+ const trackPropertyChange = options?.trackPropertyChange || null;
212
251
 
213
252
  const paragraphs = (Array.isArray(nodes) ? nodes : [])
214
253
  .filter(node => node && node.nodeType === 1 && node.localName === 'p');
@@ -224,6 +263,9 @@ export function enforceListBindingOnParagraphNodes(nodes, options = {}) {
224
263
  paragraph.insertBefore(pPr, paragraph.firstChild);
225
264
  }
226
265
 
266
+ // Snapshot the pre-binding pPr so Reject All restores the unnumbered paragraph.
267
+ const priorPPr = trackPropertyChange ? clonePropertiesWithoutRevisionHistory(pPr) : null;
268
+
227
269
  if (clearParagraphPropertyChanges) {
228
270
  const pPrChange = getDirectWordChild(pPr, 'pPrChange');
229
271
  if (pPrChange) pPr.removeChild(pPrChange);
@@ -253,6 +295,20 @@ export function enforceListBindingOnParagraphNodes(nodes, options = {}) {
253
295
  numPr.appendChild(numIdEl);
254
296
  }
255
297
  numIdEl.setAttribute('w:val', String(numId));
298
+
299
+ if (trackPropertyChange && !getDirectWordChild(pPr, 'pPrChange')) {
300
+ const metadata = createRevisionMetadata(
301
+ trackPropertyChange.author,
302
+ trackPropertyChange.revisionIdAllocator || ownerDoc,
303
+ 'pPrChange'
304
+ );
305
+ const pPrChange = createWordElement(ownerDoc, 'w:pPrChange');
306
+ pPrChange.setAttribute('w:id', String(metadata.id));
307
+ pPrChange.setAttribute('w:author', metadata.author);
308
+ pPrChange.setAttribute('w:date', metadata.date);
309
+ pPrChange.appendChild(priorPPr || createWordElement(ownerDoc, 'w:pPr'));
310
+ pPr.appendChild(pPrChange);
311
+ }
256
312
  updated++;
257
313
  }
258
314
 
@@ -311,7 +367,8 @@ function extractFirstParagraphNumIdFromOxml(oxml) {
311
367
  // Tracked-change output leads with a deleted-original paragraph that carries
312
368
  // no numPr, so the generated list binding is the first paragraph that has one.
313
369
  for (const paragraph of paragraphs) {
314
- const numIdNodes = Array.from(paragraph.getElementsByTagNameNS('*', 'numId'));
370
+ // Current numIds only; a pPrChange snapshot must not supply the generated list binding.
371
+ const numIdNodes = getCurrentNumIdElements(paragraph);
315
372
  for (const numIdNode of numIdNodes) {
316
373
  const numId = getElementId(numIdNode, ['w:val', 'val']);
317
374
  if (numId != null) return String(numId);
@@ -433,6 +490,7 @@ export function buildSingleLineListStructuralFallbackPlan(options = {}) {
433
490
  numberingKey: `${modifiedCandidate.type}:${modifiedCandidate.numberingStyle}:single`,
434
491
  originalText,
435
492
  wasListParagraph: !!existingListInfo,
493
+ numberingStyle: modifiedCandidate.numberingStyle,
436
494
  startAt: modifiedCandidate.startAt
437
495
  };
438
496
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ansonlai/docx-redline-js",
3
- "version": "0.8.4",
3
+ "version": "0.10.0",
4
4
  "description": "Host-independent OOXML reconciliation engine for .docx manipulation with track changes",
5
5
  "license": "MIT",
6
6
  "type": "module",