@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.
- package/ARCHITECTURE.md +679 -460
- package/CHANGELOG.md +208 -1
- package/README.md +152 -16
- package/core/current-properties.js +136 -0
- package/core/paragraph-text.js +211 -209
- package/core/types.js +9 -3
- package/core/word-revision-semantics.js +256 -0
- package/core/word-xml.js +160 -118
- package/dist/docx-redline-js.esm.js +4338 -796
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +111 -108
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +4463 -921
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +4463 -921
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_FAST_START.md +2 -0
- package/docs/AGENT_KNOWLEDGE_BASE.md +49 -7
- package/docs/SKILL_AUTHORING.md +169 -150
- package/docs/TESTING.md +1146 -1059
- package/docs/schemas/document-operations.schema.json +55 -2
- package/document/docx-document.js +1022 -1010
- package/engine/oxml-engine.js +21 -22
- package/engine/paragraph-mark-revisions.js +292 -0
- package/engine/reconstruction-mapper.js +312 -311
- package/engine/reconstruction-writer.js +67 -10
- package/engine/rpr-helpers.js +42 -0
- package/engine/run-builders.js +0 -73
- package/engine/surgical-diff-application.js +604 -605
- package/engine/table-mode.js +63 -63
- package/index.d.ts +53 -2
- package/index.js +13 -2
- package/node/cli-help.js +1 -0
- package/node/cli.js +21 -3
- package/orchestration/list-structural-fallback.js +67 -9
- package/package.json +1 -1
- package/pipeline/diff-engine.js +130 -1
- package/pipeline/ingestion-export.js +227 -243
- package/pipeline/ingestion-paragraph.js +93 -98
- package/pipeline/list-generation.js +415 -419
- package/pipeline/markdown-processor.js +5 -2
- package/pipeline/pipeline.js +206 -204
- package/scripts/export-validation-fixtures.mjs +19 -6
- package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
- package/scripts/lib/word-coverage-catalogue.mjs +7 -2
- package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
- package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
- package/scripts/word-com-differential.ps1 +443 -335
- package/scripts/word-com-visual-suite.ps1 +2 -2
- package/scripts/word-revision-semantics-matrix.ps1 +108 -0
- package/services/batch-operation-orchestrator.js +23 -3
- package/services/capture-engine.js +228 -226
- package/services/comment-replies.js +1 -1
- package/services/document-operation-applier.js +214 -0
- package/services/document-operation-contract.js +76 -0
- package/services/document-operation-mutations.js +688 -51
- package/services/error-recovery.js +17 -0
- package/services/inspection-comparison.js +82 -0
- package/services/list-conversion-diagnostics.js +296 -0
- package/services/list-format-change.js +586 -0
- package/services/list-format-mutation.js +416 -0
- package/services/list-format.js +305 -0
- package/services/numbering-allocation.js +642 -0
- package/services/numbering-helpers.js +354 -524
- package/services/operation-batch-compiler.js +183 -11
- package/services/operation-preflight.js +43 -33
- package/services/receipt-collector.js +288 -288
- package/services/revision-comment-management.js +38 -5
- package/services/revision-fidelity-verifier.js +484 -0
- package/services/standalone-docx-plumbing.js +627 -631
- package/services/standalone-operation-runner.d.ts +182 -5
- package/services/table-reconciliation.js +59 -7
|
@@ -35,7 +35,10 @@ const MARKDOWN_PATTERNS = [
|
|
|
35
35
|
{ regex: /\*\*\+\+(.+?)\+\+\*\*/, format: { bold: true, underline: true } },
|
|
36
36
|
// Bold: **text** or __text__
|
|
37
37
|
{ regex: /\*\*(.+?)\*\*/, format: { bold: true } },
|
|
38
|
-
|
|
38
|
+
// Underscore delimiters follow CommonMark flanking rules: not intraword, not part of a
|
|
39
|
+
// longer underscore run, and never around whitespace. So signature lines ("_____")
|
|
40
|
+
// and snake_case identifiers stay literal text.
|
|
41
|
+
{ regex: /(?<![A-Za-z0-9_])__(?=[^_\s])(.+?)(?<=[^_\s])__(?![A-Za-z0-9_])/, format: { bold: true } },
|
|
39
42
|
// Underline: ++text++
|
|
40
43
|
{ regex: /\+\+(.+?)\+\+/, format: { underline: true } },
|
|
41
44
|
// Strikethrough: ~~text~~ or ~text~
|
|
@@ -43,7 +46,7 @@ const MARKDOWN_PATTERNS = [
|
|
|
43
46
|
{ regex: /~(.+?)~/, format: { strikethrough: true } },
|
|
44
47
|
// Italic: *text* or _text_ (using lookahead only for compatibility)
|
|
45
48
|
{ regex: /\*(?!\*)(.+?)\*(?!\*)/, format: { italic: true } },
|
|
46
|
-
{ regex: /_(
|
|
49
|
+
{ regex: /(?<![A-Za-z0-9_])_(?=[^_\s])(.+?)(?<=[^_\s])_(?![A-Za-z0-9_])/, format: { italic: true } }
|
|
47
50
|
];
|
|
48
51
|
|
|
49
52
|
/**
|
package/pipeline/pipeline.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { ingestOoxml } from './ingestion.js';
|
|
8
8
|
import { preprocessMarkdown } from './markdown-processor.js';
|
|
9
|
-
import { isListTargetLoose, isListTargetStrict } from './list-markers.js';
|
|
9
|
+
import { isListTargetLoose, isListTargetStrict } from './list-markers.js';
|
|
10
10
|
import { computeWordLevelDiffOps } from './diff-engine.js';
|
|
11
11
|
import { splitRunsAtDiffBoundaries, applyPatches } from './patching.js';
|
|
12
12
|
import { serializeToOoxml, wrapInDocumentFragment } from './serialization.js';
|
|
@@ -15,26 +15,26 @@ import { NumberingService } from '../services/numbering-service.js';
|
|
|
15
15
|
import { detectNumberingContext } from './ingestion.js';
|
|
16
16
|
import { generateTableOoxml } from '../services/table-reconciliation.js';
|
|
17
17
|
import { executeListGeneration, detectIndentationStep } from './list-generation.js';
|
|
18
|
-
import { detectContentType, parseListItems, parseTable } from './content-analysis.js';
|
|
19
|
-
import { parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
20
|
-
import { log, error as logError } from '../adapters/logger.js';
|
|
21
|
-
import { getFirstElementByTagNS, getXmlParseError } from '../core/xml-query.js';
|
|
22
|
-
import { getPlatform } from '../adapters/config.js';
|
|
23
|
-
|
|
24
|
-
const WEB_PLATFORM_NAMES = new Set(['officeonline', 'officeweb', 'web']);
|
|
25
|
-
|
|
26
|
-
function isWebPlatform(platform) {
|
|
27
|
-
if (!platform) return false;
|
|
28
|
-
return WEB_PLATFORM_NAMES.has(String(platform).toLowerCase());
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function isProductionBuild() {
|
|
32
|
-
return typeof process !== 'undefined' && process?.env?.NODE_ENV === 'production';
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function yieldToEventLoop() {
|
|
36
|
-
return new Promise(resolve => setTimeout(resolve, 0));
|
|
37
|
-
}
|
|
18
|
+
import { detectContentType, parseListItems, parseTable } from './content-analysis.js';
|
|
19
|
+
import { parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
20
|
+
import { log, error as logError } from '../adapters/logger.js';
|
|
21
|
+
import { getFirstElementByTagNS, getXmlParseError } from '../core/xml-query.js';
|
|
22
|
+
import { getPlatform } from '../adapters/config.js';
|
|
23
|
+
|
|
24
|
+
const WEB_PLATFORM_NAMES = new Set(['officeonline', 'officeweb', 'web']);
|
|
25
|
+
|
|
26
|
+
function isWebPlatform(platform) {
|
|
27
|
+
if (!platform) return false;
|
|
28
|
+
return WEB_PLATFORM_NAMES.has(String(platform).toLowerCase());
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isProductionBuild() {
|
|
32
|
+
return typeof process !== 'undefined' && process?.env?.NODE_ENV === 'production';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function yieldToEventLoop() {
|
|
36
|
+
return new Promise(resolve => setTimeout(resolve, 0));
|
|
37
|
+
}
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Main reconciliation pipeline class.
|
|
@@ -47,114 +47,114 @@ export class ReconciliationPipeline {
|
|
|
47
47
|
* @param {string} [options.author='AI'] - Author for track changes
|
|
48
48
|
* @param {boolean} [options.validateOutput=true] - Validate output before returning
|
|
49
49
|
*/
|
|
50
|
-
constructor(options = {}) {
|
|
51
|
-
this.generateRedlines = options.generateRedlines ?? true;
|
|
52
|
-
this.author = options.author ?? 'AI';
|
|
53
|
-
this.validateOutput = options.validateOutput ?? true;
|
|
54
|
-
this.validationMode = options.validationMode ?? 'auto';
|
|
55
|
-
this.numberingService = options.numberingService || new NumberingService();
|
|
56
|
-
this.font = options.font || null;
|
|
57
|
-
this.revisionIdAllocator = options.revisionIdAllocator || null;
|
|
58
|
-
this.platform = options.platform ?? getPlatform();
|
|
59
|
-
this.isWebPlatform = options.isWebPlatform ?? isWebPlatform(this.platform);
|
|
60
|
-
this.enableEventLoopYielding = options.enableEventLoopYielding ?? this.isWebPlatform;
|
|
61
|
-
this.yieldRunThreshold = options.yieldRunThreshold ?? 50;
|
|
62
|
-
this.yieldCharThreshold = options.yieldCharThreshold ?? 5000;
|
|
63
|
-
this.disableSemanticCleanupOverChars = options.disableSemanticCleanupOverChars ?? (this.isWebPlatform ? 8000 : Number.POSITIVE_INFINITY);
|
|
64
|
-
}
|
|
50
|
+
constructor(options = {}) {
|
|
51
|
+
this.generateRedlines = options.generateRedlines ?? true;
|
|
52
|
+
this.author = options.author ?? 'AI';
|
|
53
|
+
this.validateOutput = options.validateOutput ?? true;
|
|
54
|
+
this.validationMode = options.validationMode ?? 'auto';
|
|
55
|
+
this.numberingService = options.numberingService || new NumberingService();
|
|
56
|
+
this.font = options.font || null;
|
|
57
|
+
this.revisionIdAllocator = options.revisionIdAllocator || null;
|
|
58
|
+
this.platform = options.platform ?? getPlatform();
|
|
59
|
+
this.isWebPlatform = options.isWebPlatform ?? isWebPlatform(this.platform);
|
|
60
|
+
this.enableEventLoopYielding = options.enableEventLoopYielding ?? this.isWebPlatform;
|
|
61
|
+
this.yieldRunThreshold = options.yieldRunThreshold ?? 50;
|
|
62
|
+
this.yieldCharThreshold = options.yieldCharThreshold ?? 5000;
|
|
63
|
+
this.disableSemanticCleanupOverChars = options.disableSemanticCleanupOverChars ?? (this.isWebPlatform ? 8000 : Number.POSITIVE_INFINITY);
|
|
64
|
+
}
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
|
-
* Executes the reconciliation pipeline.
|
|
68
|
-
*
|
|
69
|
-
* @param {string} originalOoxml - Original OOXML paragraph content
|
|
70
|
-
* @param {string} newText - New text with optional markdown formatting
|
|
71
|
-
* @param {{ xmlDoc?: Document|null }} [options={}] - Optional execution options
|
|
72
|
-
* @returns {Promise<import('../core/types.js').ReconciliationResult>}
|
|
73
|
-
*/
|
|
74
|
-
async execute(originalOoxml, newText, options = {}) {
|
|
75
|
-
const warnings = [];
|
|
76
|
-
|
|
77
|
-
try {
|
|
78
|
-
// Stage 1: Ingest OOXML
|
|
79
|
-
const parsed = options.xmlDoc
|
|
80
|
-
? { doc: options.xmlDoc, error: null, warnings: [] }
|
|
81
|
-
: parseOoxmlSafe(originalOoxml, 'application/xml');
|
|
82
|
-
if (parsed.error || !parsed.doc) {
|
|
83
|
-
return {
|
|
84
|
-
ooxml: originalOoxml,
|
|
85
|
-
isValid: false,
|
|
86
|
-
status: 'error',
|
|
87
|
-
error: parsed.error,
|
|
88
|
-
warnings: parsed.warnings || []
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
warnings.push(...(parsed.warnings || []));
|
|
92
|
-
const doc = parsed.doc;
|
|
93
|
-
const pElement = getFirstElementByTagNS(doc, '*', 'p');
|
|
94
|
-
|
|
95
|
-
const { runModel, acceptedText, pPr } = ingestOoxml(originalOoxml, { xmlDoc: doc });
|
|
96
|
-
const numberingContext = pElement ? detectNumberingContext(pElement) : null;
|
|
97
|
-
|
|
98
|
-
log(`[Reconcile] Ingested ${runModel.length} runs, ${acceptedText.length} chars, numbering:`, numberingContext);
|
|
99
|
-
await this.maybeYield(runModel.length, Math.max(acceptedText.length, newText?.length || 0));
|
|
100
|
-
|
|
101
|
-
// Stage 2: Preprocess markdown
|
|
102
|
-
const { cleanText, formatHints } = preprocessMarkdown(newText);
|
|
103
|
-
log(`[Reconcile] Preprocessed: ${formatHints.length} format hints`);
|
|
104
|
-
await this.maybeYield(runModel.length, Math.max(acceptedText.length, cleanText.length));
|
|
105
|
-
|
|
106
|
-
// Detect list-target content before any no-op short-circuit.
|
|
107
|
-
// Structural conversion may still be required even when text is identical
|
|
108
|
-
// (for example plain "A./B./C." lines -> true Word numbered list paragraphs).
|
|
109
|
-
const isTargetListStrict = isListTargetStrict(cleanText);
|
|
110
|
-
const isTargetListLoose = isListTargetLoose(cleanText);
|
|
111
|
-
const isTargetList = isTargetListStrict || isTargetListLoose;
|
|
112
|
-
if (!isTargetListStrict && isTargetListLoose) {
|
|
113
|
-
log('[Reconcile] List-target detected via loose marker parsing; bypassing no-op short-circuit for structural conversion.');
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Early exit if no change
|
|
117
|
-
if (acceptedText === cleanText && formatHints.length === 0 && !isTargetList) {
|
|
118
|
-
log('[Reconcile] No changes detected');
|
|
119
|
-
return {
|
|
120
|
-
ooxml: originalOoxml,
|
|
121
|
-
isValid: true,
|
|
122
|
-
warnings: ['No changes detected']
|
|
67
|
+
* Executes the reconciliation pipeline.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} originalOoxml - Original OOXML paragraph content
|
|
70
|
+
* @param {string} newText - New text with optional markdown formatting
|
|
71
|
+
* @param {{ xmlDoc?: Document|null }} [options={}] - Optional execution options
|
|
72
|
+
* @returns {Promise<import('../core/types.js').ReconciliationResult>}
|
|
73
|
+
*/
|
|
74
|
+
async execute(originalOoxml, newText, options = {}) {
|
|
75
|
+
const warnings = [];
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
// Stage 1: Ingest OOXML
|
|
79
|
+
const parsed = options.xmlDoc
|
|
80
|
+
? { doc: options.xmlDoc, error: null, warnings: [] }
|
|
81
|
+
: parseOoxmlSafe(originalOoxml, 'application/xml');
|
|
82
|
+
if (parsed.error || !parsed.doc) {
|
|
83
|
+
return {
|
|
84
|
+
ooxml: originalOoxml,
|
|
85
|
+
isValid: false,
|
|
86
|
+
status: 'error',
|
|
87
|
+
error: parsed.error,
|
|
88
|
+
warnings: parsed.warnings || []
|
|
123
89
|
};
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
90
|
+
}
|
|
91
|
+
warnings.push(...(parsed.warnings || []));
|
|
92
|
+
const doc = parsed.doc;
|
|
93
|
+
const pElement = getFirstElementByTagNS(doc, '*', 'p');
|
|
94
|
+
|
|
95
|
+
const { runModel, acceptedText, pPr } = ingestOoxml(originalOoxml, { xmlDoc: doc });
|
|
96
|
+
const numberingContext = pElement ? detectNumberingContext(pElement) : null;
|
|
97
|
+
|
|
98
|
+
log(`[Reconcile] Ingested ${runModel.length} runs, ${acceptedText.length} chars, numbering:`, numberingContext);
|
|
99
|
+
await this.maybeYield(runModel.length, Math.max(acceptedText.length, newText?.length || 0));
|
|
100
|
+
|
|
101
|
+
// Stage 2: Preprocess markdown
|
|
102
|
+
const { cleanText, formatHints } = preprocessMarkdown(newText);
|
|
103
|
+
log(`[Reconcile] Preprocessed: ${formatHints.length} format hints`);
|
|
104
|
+
await this.maybeYield(runModel.length, Math.max(acceptedText.length, cleanText.length));
|
|
105
|
+
|
|
106
|
+
// Detect list-target content before any no-op short-circuit.
|
|
107
|
+
// Structural conversion may still be required even when text is identical
|
|
108
|
+
// (for example plain "A./B./C." lines -> true Word numbered list paragraphs).
|
|
109
|
+
const isTargetListStrict = isListTargetStrict(cleanText);
|
|
110
|
+
const isTargetListLoose = isListTargetLoose(cleanText);
|
|
111
|
+
const isTargetList = isTargetListStrict || isTargetListLoose;
|
|
112
|
+
if (!isTargetListStrict && isTargetListLoose) {
|
|
113
|
+
log('[Reconcile] List-target detected via loose marker parsing; bypassing no-op short-circuit for structural conversion.');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Early exit if no change
|
|
117
|
+
if (acceptedText === cleanText && formatHints.length === 0 && !isTargetList) {
|
|
118
|
+
log('[Reconcile] No changes detected');
|
|
119
|
+
return {
|
|
120
|
+
ooxml: originalOoxml,
|
|
121
|
+
isValid: true,
|
|
122
|
+
warnings: ['No changes detected']
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Stage 3: Compute word-level diff
|
|
127
|
+
const shouldCleanupSemantic = Math.max(acceptedText.length, cleanText.length) < this.disableSemanticCleanupOverChars;
|
|
128
|
+
const diffOps = computeWordLevelDiffOps(acceptedText, cleanText, {
|
|
129
|
+
cleanupSemantic: shouldCleanupSemantic
|
|
130
|
+
});
|
|
131
|
+
if (!shouldCleanupSemantic) {
|
|
132
|
+
log('[Reconcile] Skipping semantic diff cleanup for large web payload');
|
|
133
|
+
}
|
|
134
|
+
await this.maybeYield(runModel.length, Math.max(acceptedText.length, cleanText.length));
|
|
135
|
+
|
|
136
|
+
// Count actual paragraph elements ingested
|
|
137
|
+
const paragraphCount = runModel.filter(r => r.kind === RunKind.PARAGRAPH_START).length;
|
|
138
|
+
const sourceIsMarkedMultilineList = isListTargetStrict(acceptedText) || isListTargetLoose(acceptedText);
|
|
139
|
+
const canPatchExistingMarkedList = isTargetList
|
|
140
|
+
&& sourceIsMarkedMultilineList
|
|
141
|
+
&& paragraphCount > 1
|
|
142
|
+
&& acceptedText !== cleanText;
|
|
143
|
+
|
|
144
|
+
log(`[Reconcile] isTargetList: ${isTargetList}, paragraphCount: ${paragraphCount}`);
|
|
145
|
+
|
|
146
|
+
// Preserve already-marked multi-paragraph lists during ordinary text edits.
|
|
147
|
+
// Rebuilding the whole block would discard unchanged run formatting and
|
|
148
|
+
// place all source deletion runs in the first generated paragraph, which
|
|
149
|
+
// makes rejection merge the original paragraph boundaries.
|
|
150
|
+
if (canPatchExistingMarkedList) {
|
|
151
|
+
log('[Reconcile] Existing marked list edit detected; using run-aware patching to preserve formatting and paragraph boundaries.');
|
|
152
|
+
} else if (isTargetList) {
|
|
153
|
+
log('[Reconcile] 🎯 ENTERING LIST GENERATION PATH');
|
|
154
|
+
log(`[Reconcile] cleanText preview: ${cleanText.substring(0, 100)}...`);
|
|
155
|
+
log(`[Reconcile] acceptedText preview: ${acceptedText.substring(0, 100)}...`);
|
|
156
|
+
return this.executeListGeneration(cleanText, numberingContext, runModel);
|
|
157
|
+
}
|
|
158
158
|
|
|
159
159
|
log(`[Reconcile] Computed ${diffOps.length} diff operations`);
|
|
160
160
|
|
|
@@ -163,29 +163,29 @@ export class ReconciliationPipeline {
|
|
|
163
163
|
log(`[Reconcile] Split into ${splitModel.length} runs`);
|
|
164
164
|
|
|
165
165
|
// Stage 5: Apply patches
|
|
166
|
-
const patchedModel = applyPatches(splitModel, diffOps, {
|
|
167
|
-
generateRedlines: this.generateRedlines,
|
|
168
|
-
author: this.author,
|
|
169
|
-
formatHints,
|
|
170
|
-
numberingService: this.numberingService
|
|
171
|
-
});
|
|
172
|
-
log(`[Reconcile] Patched model has ${patchedModel.length} runs`);
|
|
173
|
-
await this.maybeYield(patchedModel.length, Math.max(acceptedText.length, cleanText.length));
|
|
166
|
+
const patchedModel = applyPatches(splitModel, diffOps, {
|
|
167
|
+
generateRedlines: this.generateRedlines,
|
|
168
|
+
author: this.author,
|
|
169
|
+
formatHints,
|
|
170
|
+
numberingService: this.numberingService
|
|
171
|
+
});
|
|
172
|
+
log(`[Reconcile] Patched model has ${patchedModel.length} runs`);
|
|
173
|
+
await this.maybeYield(patchedModel.length, Math.max(acceptedText.length, cleanText.length));
|
|
174
174
|
|
|
175
175
|
// Stage 6: Serialize to OOXML
|
|
176
|
-
const resultOoxml = serializeToOoxml(patchedModel, pPr, formatHints, {
|
|
177
|
-
author: this.author,
|
|
178
|
-
generateRedlines: this.generateRedlines,
|
|
179
|
-
revisionIdAllocator: this.revisionIdAllocator
|
|
176
|
+
const resultOoxml = serializeToOoxml(patchedModel, pPr, formatHints, {
|
|
177
|
+
author: this.author,
|
|
178
|
+
generateRedlines: this.generateRedlines,
|
|
179
|
+
revisionIdAllocator: this.revisionIdAllocator
|
|
180
180
|
});
|
|
181
181
|
|
|
182
|
-
// Stage 7: Basic validation
|
|
183
|
-
if (this.shouldRunValidation()) {
|
|
184
|
-
const validation = this.validateBasic(resultOoxml);
|
|
185
|
-
if (!validation.isValid) {
|
|
186
|
-
warnings.push(...validation.errors);
|
|
187
|
-
}
|
|
188
|
-
}
|
|
182
|
+
// Stage 7: Basic validation
|
|
183
|
+
if (this.shouldRunValidation()) {
|
|
184
|
+
const validation = this.validateBasic(resultOoxml);
|
|
185
|
+
if (!validation.isValid) {
|
|
186
|
+
warnings.push(...validation.errors);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
189
|
|
|
190
190
|
return {
|
|
191
191
|
ooxml: resultOoxml,
|
|
@@ -193,15 +193,15 @@ export class ReconciliationPipeline {
|
|
|
193
193
|
warnings
|
|
194
194
|
};
|
|
195
195
|
|
|
196
|
-
} catch (error) {
|
|
197
|
-
logError('[Reconcile] Pipeline error:', error);
|
|
198
|
-
return {
|
|
199
|
-
ooxml: originalOoxml,
|
|
200
|
-
isValid: false,
|
|
201
|
-
warnings: [`Pipeline error: ${error.message}`],
|
|
202
|
-
error: error?.code ? { code: error.code, message: error.message } : undefined
|
|
203
|
-
};
|
|
204
|
-
}
|
|
196
|
+
} catch (error) {
|
|
197
|
+
logError('[Reconcile] Pipeline error:', error);
|
|
198
|
+
return {
|
|
199
|
+
ooxml: originalOoxml,
|
|
200
|
+
isValid: false,
|
|
201
|
+
warnings: [`Pipeline error: ${error.message}`],
|
|
202
|
+
error: error?.code ? { code: error.code, message: error.message } : undefined
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
/**
|
|
@@ -210,19 +210,19 @@ export class ReconciliationPipeline {
|
|
|
210
210
|
* @param {string} ooxml - Generated OOXML
|
|
211
211
|
* @returns {{ isValid: boolean, errors: string[] }}
|
|
212
212
|
*/
|
|
213
|
-
validateBasic(ooxml) {
|
|
213
|
+
validateBasic(ooxml) {
|
|
214
214
|
const errors = [];
|
|
215
215
|
|
|
216
216
|
try {
|
|
217
217
|
// Check for well-formed XML by wrapping in namespace container
|
|
218
218
|
const wrappedXml = `<root xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">${ooxml}</root>`;
|
|
219
|
-
const parsed = parseOoxmlSafe(wrappedXml, 'application/xml');
|
|
220
|
-
const doc = parsed.doc;
|
|
221
|
-
|
|
222
|
-
if (parsed.error || !doc) {
|
|
223
|
-
errors.push('Generated OOXML is not well-formed XML: ' + (parsed.error?.message || 'parse error'));
|
|
224
|
-
return { isValid: false, errors };
|
|
225
|
-
}
|
|
219
|
+
const parsed = parseOoxmlSafe(wrappedXml, 'application/xml');
|
|
220
|
+
const doc = parsed.doc;
|
|
221
|
+
|
|
222
|
+
if (parsed.error || !doc) {
|
|
223
|
+
errors.push('Generated OOXML is not well-formed XML: ' + (parsed.error?.message || 'parse error'));
|
|
224
|
+
return { isValid: false, errors };
|
|
225
|
+
}
|
|
226
226
|
|
|
227
227
|
const parseError = getXmlParseError(doc);
|
|
228
228
|
if (parseError) {
|
|
@@ -242,40 +242,40 @@ export class ReconciliationPipeline {
|
|
|
242
242
|
isValid: errors.length === 0,
|
|
243
243
|
errors
|
|
244
244
|
};
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* Decides if basic output validation should run for this pipeline instance.
|
|
249
|
-
*
|
|
250
|
-
* Modes:
|
|
251
|
-
* - `always`: validate whenever `validateOutput` is true
|
|
252
|
-
* - `never`: never validate
|
|
253
|
-
* - `auto` (default): skip only in production web runtime
|
|
254
|
-
*
|
|
255
|
-
* @returns {boolean}
|
|
256
|
-
*/
|
|
257
|
-
shouldRunValidation() {
|
|
258
|
-
if (!this.validateOutput) return false;
|
|
259
|
-
|
|
260
|
-
if (this.validationMode === 'always') return true;
|
|
261
|
-
if (this.validationMode === 'never') return false;
|
|
262
|
-
|
|
263
|
-
// Auto mode: avoid extra parse round-trips in Word Online production.
|
|
264
|
-
return !(this.isWebPlatform && isProductionBuild());
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* Yields to the event loop for large operations to keep web UI responsive.
|
|
269
|
-
*
|
|
270
|
-
* @param {number} runCount - Run model size
|
|
271
|
-
* @param {number} charCount - Text size
|
|
272
|
-
* @returns {Promise<void>}
|
|
273
|
-
*/
|
|
274
|
-
async maybeYield(runCount, charCount) {
|
|
275
|
-
if (!this.enableEventLoopYielding) return;
|
|
276
|
-
if (runCount <= this.yieldRunThreshold && charCount <= this.yieldCharThreshold) return;
|
|
277
|
-
await yieldToEventLoop();
|
|
278
|
-
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Decides if basic output validation should run for this pipeline instance.
|
|
249
|
+
*
|
|
250
|
+
* Modes:
|
|
251
|
+
* - `always`: validate whenever `validateOutput` is true
|
|
252
|
+
* - `never`: never validate
|
|
253
|
+
* - `auto` (default): skip only in production web runtime
|
|
254
|
+
*
|
|
255
|
+
* @returns {boolean}
|
|
256
|
+
*/
|
|
257
|
+
shouldRunValidation() {
|
|
258
|
+
if (!this.validateOutput) return false;
|
|
259
|
+
|
|
260
|
+
if (this.validationMode === 'always') return true;
|
|
261
|
+
if (this.validationMode === 'never') return false;
|
|
262
|
+
|
|
263
|
+
// Auto mode: avoid extra parse round-trips in Word Online production.
|
|
264
|
+
return !(this.isWebPlatform && isProductionBuild());
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Yields to the event loop for large operations to keep web UI responsive.
|
|
269
|
+
*
|
|
270
|
+
* @param {number} runCount - Run model size
|
|
271
|
+
* @param {number} charCount - Text size
|
|
272
|
+
* @returns {Promise<void>}
|
|
273
|
+
*/
|
|
274
|
+
async maybeYield(runCount, charCount) {
|
|
275
|
+
if (!this.enableEventLoopYielding) return;
|
|
276
|
+
if (runCount <= this.yieldRunThreshold && charCount <= this.yieldCharThreshold) return;
|
|
277
|
+
await yieldToEventLoop();
|
|
278
|
+
}
|
|
279
279
|
|
|
280
280
|
/**
|
|
281
281
|
* Wraps the reconciled content for document insertion.
|
|
@@ -303,10 +303,10 @@ export class ReconciliationPipeline {
|
|
|
303
303
|
originalRunModel,
|
|
304
304
|
originalText,
|
|
305
305
|
generateRedlines: this.generateRedlines,
|
|
306
|
-
author: this.author,
|
|
307
|
-
font: this.font,
|
|
308
|
-
revisionIdAllocator: this.revisionIdAllocator,
|
|
309
|
-
numberingService: this.numberingService
|
|
306
|
+
author: this.author,
|
|
307
|
+
font: this.font,
|
|
308
|
+
revisionIdAllocator: this.revisionIdAllocator,
|
|
309
|
+
numberingService: this.numberingService
|
|
310
310
|
});
|
|
311
311
|
}
|
|
312
312
|
|
|
@@ -337,10 +337,12 @@ export class ReconciliationPipeline {
|
|
|
337
337
|
};
|
|
338
338
|
}
|
|
339
339
|
|
|
340
|
-
const tableOoxml = generateTableOoxml(tableData, {
|
|
341
|
-
generateRedlines: this.generateRedlines,
|
|
342
|
-
author: this.author,
|
|
343
|
-
revisionIdAllocator: this.revisionIdAllocator
|
|
340
|
+
const tableOoxml = generateTableOoxml(tableData, {
|
|
341
|
+
generateRedlines: this.generateRedlines,
|
|
342
|
+
author: this.author,
|
|
343
|
+
revisionIdAllocator: this.revisionIdAllocator,
|
|
344
|
+
// Word-native inserted table: tracked rows so Reject All removes the whole table.
|
|
345
|
+
trackAsBlock: true
|
|
344
346
|
});
|
|
345
347
|
|
|
346
348
|
return {
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
rejectTrackedChangesInOoxml
|
|
17
17
|
} from '../services/revision-comment-management.js';
|
|
18
18
|
import { WORD_TASK_CASES } from '../tests/fixtures/word-task-cases.mjs';
|
|
19
|
+
import { mergeNumberingParts } from '../services/numbering-allocation.js';
|
|
19
20
|
|
|
20
21
|
const { DOMParser, XMLSerializer } = await import('@xmldom/xmldom');
|
|
21
22
|
configureXmlProvider({ DOMParser, XMLSerializer });
|
|
@@ -56,7 +57,8 @@ for (const testCase of cases) {
|
|
|
56
57
|
sourceDocumentXml,
|
|
57
58
|
testCase.batchOperations,
|
|
58
59
|
'Validation',
|
|
59
|
-
|
|
60
|
+
// A numbering binding resolves against the source numbering part, as it does in a real package.
|
|
61
|
+
testCase.sourceNumbering ? { numberingXml: testCase.packageParts?.numberingXml } : null,
|
|
60
62
|
operationOptions
|
|
61
63
|
)
|
|
62
64
|
: await applyOperationToDocumentXml(
|
|
@@ -118,7 +120,7 @@ for (const testCase of cases) {
|
|
|
118
120
|
}
|
|
119
121
|
|
|
120
122
|
if (testCase.requiredNumberingFormats) {
|
|
121
|
-
const numberingXml = testCase.packageParts?.numberingXml
|
|
123
|
+
const numberingXml = [testCase.packageParts?.numberingXml, result.numberingXml, ...(result.numberingXmlParts || [])].filter(Boolean).join('');
|
|
122
124
|
for (const format of testCase.requiredNumberingFormats) {
|
|
123
125
|
if (!numberingXml.includes(`<w:numFmt w:val="${format}"`)) {
|
|
124
126
|
console.error(`FAIL ${testCase.name}: required numbering format ${format} is missing`);
|
|
@@ -162,14 +164,20 @@ for (const testCase of cases) {
|
|
|
162
164
|
continue;
|
|
163
165
|
}
|
|
164
166
|
|
|
167
|
+
// A batch returns numbering payloads as parts; merge them into the source part through the one allocator merge.
|
|
168
|
+
const batchNumberingXml = Array.isArray(result.numberingXmlParts) && result.numberingXmlParts.length > 0
|
|
169
|
+
? mergeNumberingParts(testCase.packageParts?.numberingXml || '', result.numberingXmlParts)
|
|
170
|
+
: null;
|
|
171
|
+
const resultNumberingXml = result.numberingXml || batchNumberingXml || null;
|
|
165
172
|
writeFileSync(join(outputDir, `${testCase.name}.document.xml`), result.documentXml, 'utf8');
|
|
166
|
-
if (
|
|
167
|
-
writeFileSync(join(outputDir, `${testCase.name}.numbering.xml`),
|
|
173
|
+
if (resultNumberingXml) {
|
|
174
|
+
writeFileSync(join(outputDir, `${testCase.name}.numbering.xml`), resultNumberingXml, 'utf8');
|
|
168
175
|
}
|
|
169
176
|
|
|
170
177
|
const packageParts = {
|
|
171
|
-
numberingXml:
|
|
172
|
-
...(testCase.packageParts || {})
|
|
178
|
+
numberingXml: resultNumberingXml,
|
|
179
|
+
...(testCase.packageParts || {}),
|
|
180
|
+
...(batchNumberingXml ? { numberingXml: batchNumberingXml } : {})
|
|
173
181
|
};
|
|
174
182
|
let packageEntries;
|
|
175
183
|
let docx;
|
|
@@ -228,6 +236,11 @@ for (const testCase of cases) {
|
|
|
228
236
|
sourceText: testCase.sourceText || testCase.original,
|
|
229
237
|
modifiedText: preprocessMarkdown(testCase.modified).cleanText,
|
|
230
238
|
requiredNumberingFormats: testCase.requiredNumberingFormats || [],
|
|
239
|
+
...(testCase.expectedAcceptedListStrings ? {
|
|
240
|
+
expectedAcceptedListStrings: testCase.expectedAcceptedListStrings,
|
|
241
|
+
expectedRejectedListStrings: testCase.expectedRejectedListStrings
|
|
242
|
+
} : {}),
|
|
243
|
+
...(testCase.expectedAcceptedListGroupSizes ? { expectedAcceptedListGroupSizes: testCase.expectedAcceptedListGroupSizes } : {}),
|
|
231
244
|
untouchedPartSha256
|
|
232
245
|
};
|
|
233
246
|
writeFileSync(join(outputDir, `${testCase.name}.expected.json`), `${JSON.stringify(expected, null, 2)}\n`, 'utf8');
|