@ansonlai/docx-redline-js 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +176 -0
- package/ARCHITECTURE.md +121 -0
- package/LICENSE +21 -0
- package/README.md +177 -0
- package/adapters/config.js +43 -0
- package/adapters/logger.js +89 -0
- package/adapters/xml-adapter.js +74 -0
- package/core/list-targeting.js +398 -0
- package/core/ooxml-identifiers.js +15 -0
- package/core/paragraph-offset-policy.js +50 -0
- package/core/paragraph-targeting.js +501 -0
- package/core/table-targeting.js +233 -0
- package/core/types.js +204 -0
- package/core/xml-query.js +99 -0
- package/dist/docx-redline-js.esm.js +8801 -0
- package/dist/docx-redline-js.esm.js.map +7 -0
- package/dist/docx-redline-js.esm.min.js +195 -0
- package/dist/docx-redline-js.esm.min.js.map +7 -0
- package/engine/format-application.js +358 -0
- package/engine/format-extraction.js +232 -0
- package/engine/format-paragraph-targeting.js +208 -0
- package/engine/format-span-application.js +178 -0
- package/engine/formatting-removal.js +330 -0
- package/engine/oxml-engine.js +279 -0
- package/engine/reconstruction-mapper.js +270 -0
- package/engine/reconstruction-mode.js +38 -0
- package/engine/reconstruction-writer.js +276 -0
- package/engine/rpr-helpers.js +194 -0
- package/engine/run-builders.js +235 -0
- package/engine/surgical-mode.js +520 -0
- package/engine/table-cell-context.js +151 -0
- package/engine/table-mode.js +172 -0
- package/index.js +308 -0
- package/orchestration/list-markdown.js +141 -0
- package/orchestration/list-parsing.js +73 -0
- package/orchestration/list-structural-fallback.js +530 -0
- package/orchestration/redline-operation-converter.js +141 -0
- package/orchestration/route-plan.js +160 -0
- package/package.json +76 -0
- package/pipeline/content-analysis.js +107 -0
- package/pipeline/diff-engine.js +204 -0
- package/pipeline/ingestion-export.js +255 -0
- package/pipeline/ingestion-paragraph.js +351 -0
- package/pipeline/ingestion-table.js +169 -0
- package/pipeline/ingestion-xml.js +39 -0
- package/pipeline/ingestion.js +8 -0
- package/pipeline/list-generation.js +280 -0
- package/pipeline/list-markers.js +77 -0
- package/pipeline/markdown-processor.js +160 -0
- package/pipeline/patching.js +408 -0
- package/pipeline/pipeline.js +326 -0
- package/pipeline/serialization.js +395 -0
- package/services/browser-demo-prompt-context.js +345 -0
- package/services/comment-builders.js +60 -0
- package/services/comment-engine.js +248 -0
- package/services/comment-locator.js +197 -0
- package/services/comment-package.js +113 -0
- package/services/numbering-helpers.js +416 -0
- package/services/numbering-service.js +290 -0
- package/services/package-builder.js +147 -0
- package/services/standalone-docx-plumbing.js +443 -0
- package/services/standalone-operation-runner.js +1169 -0
- package/services/table-reconciliation.js +344 -0
- package/standalone.js +5 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OOXML Reconciliation Pipeline - Main Pipeline
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates the reconciliation process from OOXML input to output.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { ingestOoxml } from './ingestion.js';
|
|
8
|
+
import { preprocessMarkdown } from './markdown-processor.js';
|
|
9
|
+
import { isListTargetLoose, isListTargetStrict } from './list-markers.js';
|
|
10
|
+
import { computeWordLevelDiffOps } from './diff-engine.js';
|
|
11
|
+
import { splitRunsAtDiffBoundaries, applyPatches } from './patching.js';
|
|
12
|
+
import { serializeToOoxml, wrapInDocumentFragment } from './serialization.js';
|
|
13
|
+
import { RunKind } from '../core/types.js';
|
|
14
|
+
import { NumberingService } from '../services/numbering-service.js';
|
|
15
|
+
import { detectNumberingContext } from './ingestion.js';
|
|
16
|
+
import { generateTableOoxml } from '../services/table-reconciliation.js';
|
|
17
|
+
import { executeListGeneration, detectIndentationStep } from './list-generation.js';
|
|
18
|
+
import { detectContentType, parseListItems, parseTable } from './content-analysis.js';
|
|
19
|
+
import { createParser } 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
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Main reconciliation pipeline class.
|
|
41
|
+
* Orchestrates the process of diffing and patching OOXML content.
|
|
42
|
+
*/
|
|
43
|
+
export class ReconciliationPipeline {
|
|
44
|
+
/**
|
|
45
|
+
* @param {Object} options - Pipeline options
|
|
46
|
+
* @param {boolean} [options.generateRedlines=true] - Generate track changes
|
|
47
|
+
* @param {string} [options.author='AI'] - Author for track changes
|
|
48
|
+
* @param {boolean} [options.validateOutput=true] - Validate output before returning
|
|
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.platform = options.platform ?? getPlatform();
|
|
58
|
+
this.isWebPlatform = options.isWebPlatform ?? isWebPlatform(this.platform);
|
|
59
|
+
this.enableEventLoopYielding = options.enableEventLoopYielding ?? this.isWebPlatform;
|
|
60
|
+
this.yieldRunThreshold = options.yieldRunThreshold ?? 50;
|
|
61
|
+
this.yieldCharThreshold = options.yieldCharThreshold ?? 5000;
|
|
62
|
+
this.disableSemanticCleanupOverChars = options.disableSemanticCleanupOverChars ?? (this.isWebPlatform ? 8000 : Number.POSITIVE_INFINITY);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Executes the reconciliation pipeline.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} originalOoxml - Original OOXML paragraph content
|
|
69
|
+
* @param {string} newText - New text with optional markdown formatting
|
|
70
|
+
* @param {{ xmlDoc?: Document|null }} [options={}] - Optional execution options
|
|
71
|
+
* @returns {Promise<import('../core/types.js').ReconciliationResult>}
|
|
72
|
+
*/
|
|
73
|
+
async execute(originalOoxml, newText, options = {}) {
|
|
74
|
+
const warnings = [];
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
// Stage 1: Ingest OOXML
|
|
78
|
+
const doc = options.xmlDoc || (() => {
|
|
79
|
+
const parser = createParser();
|
|
80
|
+
return parser.parseFromString(originalOoxml, 'application/xml');
|
|
81
|
+
})();
|
|
82
|
+
const pElement = getFirstElementByTagNS(doc, '*', 'p');
|
|
83
|
+
|
|
84
|
+
const { runModel, acceptedText, pPr } = ingestOoxml(originalOoxml, { xmlDoc: doc });
|
|
85
|
+
const numberingContext = pElement ? detectNumberingContext(pElement) : null;
|
|
86
|
+
|
|
87
|
+
log(`[Reconcile] Ingested ${runModel.length} runs, ${acceptedText.length} chars, numbering:`, numberingContext);
|
|
88
|
+
await this.maybeYield(runModel.length, Math.max(acceptedText.length, newText?.length || 0));
|
|
89
|
+
|
|
90
|
+
// Stage 2: Preprocess markdown
|
|
91
|
+
const { cleanText, formatHints } = preprocessMarkdown(newText);
|
|
92
|
+
log(`[Reconcile] Preprocessed: ${formatHints.length} format hints`);
|
|
93
|
+
await this.maybeYield(runModel.length, Math.max(acceptedText.length, cleanText.length));
|
|
94
|
+
|
|
95
|
+
// Detect list-target content before any no-op short-circuit.
|
|
96
|
+
// Structural conversion may still be required even when text is identical
|
|
97
|
+
// (for example plain "A./B./C." lines -> true Word numbered list paragraphs).
|
|
98
|
+
const isTargetListStrict = isListTargetStrict(cleanText);
|
|
99
|
+
const isTargetListLoose = isListTargetLoose(cleanText);
|
|
100
|
+
const isTargetList = isTargetListStrict || isTargetListLoose;
|
|
101
|
+
if (!isTargetListStrict && isTargetListLoose) {
|
|
102
|
+
log('[Reconcile] List-target detected via loose marker parsing; bypassing no-op short-circuit for structural conversion.');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Early exit if no change
|
|
106
|
+
if (acceptedText === cleanText && formatHints.length === 0 && !isTargetList) {
|
|
107
|
+
log('[Reconcile] No changes detected');
|
|
108
|
+
return {
|
|
109
|
+
ooxml: originalOoxml,
|
|
110
|
+
isValid: true,
|
|
111
|
+
warnings: ['No changes detected']
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Stage 3: Compute word-level diff
|
|
116
|
+
const shouldCleanupSemantic = Math.max(acceptedText.length, cleanText.length) < this.disableSemanticCleanupOverChars;
|
|
117
|
+
const diffOps = computeWordLevelDiffOps(acceptedText, cleanText, {
|
|
118
|
+
cleanupSemantic: shouldCleanupSemantic
|
|
119
|
+
});
|
|
120
|
+
if (!shouldCleanupSemantic) {
|
|
121
|
+
log('[Reconcile] Skipping semantic diff cleanup for large web payload');
|
|
122
|
+
}
|
|
123
|
+
await this.maybeYield(runModel.length, Math.max(acceptedText.length, cleanText.length));
|
|
124
|
+
|
|
125
|
+
// Count actual paragraph elements ingested
|
|
126
|
+
const paragraphCount = runModel.filter(r => r.kind === RunKind.PARAGRAPH_START).length;
|
|
127
|
+
|
|
128
|
+
log(`[Reconcile] isTargetList: ${isTargetList}, paragraphCount: ${paragraphCount}`);
|
|
129
|
+
|
|
130
|
+
// If target is a list, always use list generation logic
|
|
131
|
+
// This handles both expansion (1 para -> N items) and conversion (N paras -> M items)
|
|
132
|
+
if (isTargetList) {
|
|
133
|
+
log('[Reconcile] 🎯 ENTERING LIST GENERATION PATH');
|
|
134
|
+
log(`[Reconcile] cleanText preview: ${cleanText.substring(0, 100)}...`);
|
|
135
|
+
log(`[Reconcile] acceptedText preview: ${acceptedText.substring(0, 100)}...`);
|
|
136
|
+
return this.executeListGeneration(cleanText, numberingContext, runModel);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
log(`[Reconcile] Computed ${diffOps.length} diff operations`);
|
|
140
|
+
|
|
141
|
+
// Stage 4: Pre-split runs at boundaries
|
|
142
|
+
const splitModel = splitRunsAtDiffBoundaries(runModel, diffOps);
|
|
143
|
+
log(`[Reconcile] Split into ${splitModel.length} runs`);
|
|
144
|
+
|
|
145
|
+
// Stage 5: Apply patches
|
|
146
|
+
const patchedModel = applyPatches(splitModel, diffOps, {
|
|
147
|
+
generateRedlines: this.generateRedlines,
|
|
148
|
+
author: this.author,
|
|
149
|
+
formatHints,
|
|
150
|
+
numberingService: this.numberingService
|
|
151
|
+
});
|
|
152
|
+
log(`[Reconcile] Patched model has ${patchedModel.length} runs`);
|
|
153
|
+
await this.maybeYield(patchedModel.length, Math.max(acceptedText.length, cleanText.length));
|
|
154
|
+
|
|
155
|
+
// Stage 6: Serialize to OOXML
|
|
156
|
+
const resultOoxml = serializeToOoxml(patchedModel, pPr, formatHints, {
|
|
157
|
+
author: this.author,
|
|
158
|
+
generateRedlines: this.generateRedlines
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Stage 7: Basic validation
|
|
162
|
+
if (this.shouldRunValidation()) {
|
|
163
|
+
const validation = this.validateBasic(resultOoxml);
|
|
164
|
+
if (!validation.isValid) {
|
|
165
|
+
warnings.push(...validation.errors);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
ooxml: resultOoxml,
|
|
171
|
+
isValid: warnings.length === 0,
|
|
172
|
+
warnings
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
} catch (error) {
|
|
176
|
+
logError('[Reconcile] Pipeline error:', error);
|
|
177
|
+
return {
|
|
178
|
+
ooxml: originalOoxml,
|
|
179
|
+
isValid: false,
|
|
180
|
+
warnings: [`Pipeline error: ${error.message}`]
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Performs basic validation on generated OOXML.
|
|
187
|
+
*
|
|
188
|
+
* @param {string} ooxml - Generated OOXML
|
|
189
|
+
* @returns {{ isValid: boolean, errors: string[] }}
|
|
190
|
+
*/
|
|
191
|
+
validateBasic(ooxml) {
|
|
192
|
+
const errors = [];
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
// Check for well-formed XML by wrapping in namespace container
|
|
196
|
+
const wrappedXml = `<root xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">${ooxml}</root>`;
|
|
197
|
+
const parser = createParser();
|
|
198
|
+
const doc = parser.parseFromString(wrappedXml, 'application/xml');
|
|
199
|
+
|
|
200
|
+
const parseError = getXmlParseError(doc);
|
|
201
|
+
if (parseError) {
|
|
202
|
+
errors.push('Generated OOXML is not well-formed XML: ' + parseError.textContent.substring(0, 100));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Check for basic structure
|
|
206
|
+
if (!ooxml.includes('<w:p')) {
|
|
207
|
+
errors.push('Generated OOXML missing paragraph element');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
} catch (e) {
|
|
211
|
+
errors.push(`Validation error: ${e.message}`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
isValid: errors.length === 0,
|
|
216
|
+
errors
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Decides if basic output validation should run for this pipeline instance.
|
|
222
|
+
*
|
|
223
|
+
* Modes:
|
|
224
|
+
* - `always`: validate whenever `validateOutput` is true
|
|
225
|
+
* - `never`: never validate
|
|
226
|
+
* - `auto` (default): skip only in production web runtime
|
|
227
|
+
*
|
|
228
|
+
* @returns {boolean}
|
|
229
|
+
*/
|
|
230
|
+
shouldRunValidation() {
|
|
231
|
+
if (!this.validateOutput) return false;
|
|
232
|
+
|
|
233
|
+
if (this.validationMode === 'always') return true;
|
|
234
|
+
if (this.validationMode === 'never') return false;
|
|
235
|
+
|
|
236
|
+
// Auto mode: avoid extra parse round-trips in Word Online production.
|
|
237
|
+
return !(this.isWebPlatform && isProductionBuild());
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Yields to the event loop for large operations to keep web UI responsive.
|
|
242
|
+
*
|
|
243
|
+
* @param {number} runCount - Run model size
|
|
244
|
+
* @param {number} charCount - Text size
|
|
245
|
+
* @returns {Promise<void>}
|
|
246
|
+
*/
|
|
247
|
+
async maybeYield(runCount, charCount) {
|
|
248
|
+
if (!this.enableEventLoopYielding) return;
|
|
249
|
+
if (runCount <= this.yieldRunThreshold && charCount <= this.yieldCharThreshold) return;
|
|
250
|
+
await yieldToEventLoop();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Wraps the reconciled content for document insertion.
|
|
255
|
+
*
|
|
256
|
+
* @param {string} ooxml - Reconciled OOXML paragraph
|
|
257
|
+
* @param {import('../core/types.js').DocumentFragmentOptions|boolean} [options={}] - Fragment options
|
|
258
|
+
* @returns {string} Wrapped document fragment
|
|
259
|
+
*/
|
|
260
|
+
wrapForInsertion(ooxml, options = {}) {
|
|
261
|
+
return wrapInDocumentFragment(ooxml, options);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Executes list generation when a single paragraph expands into a list.
|
|
266
|
+
*
|
|
267
|
+
* @param {string} cleanText - Preprocessed new text (markdown list)
|
|
268
|
+
* @param {Object} numberingContext - Original numbering context
|
|
269
|
+
* @param {Array} originalRunModel - Run model of the original paragraph (optional)
|
|
270
|
+
* @param {string} originalText - Plain text of the original paragraph (optional, used if runModel not provided)
|
|
271
|
+
*/
|
|
272
|
+
async executeListGeneration(cleanText, numberingContext, originalRunModel, originalText = '') {
|
|
273
|
+
return executeListGeneration({
|
|
274
|
+
cleanText,
|
|
275
|
+
numberingContext,
|
|
276
|
+
originalRunModel,
|
|
277
|
+
originalText,
|
|
278
|
+
generateRedlines: this.generateRedlines,
|
|
279
|
+
author: this.author,
|
|
280
|
+
font: this.font,
|
|
281
|
+
numberingService: this.numberingService
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Heuristically detects the indentation step (number of spaces or tabs per level).
|
|
287
|
+
*
|
|
288
|
+
* @param {string[]} lines - Array of lines
|
|
289
|
+
* @returns {number} The detected step (defaulting to 2)
|
|
290
|
+
*/
|
|
291
|
+
detectIndentationStep(lines) {
|
|
292
|
+
return detectIndentationStep(lines);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Executes table generation from markdown text.
|
|
298
|
+
*
|
|
299
|
+
* @param {string} markdownTable - Markdown table text
|
|
300
|
+
* @returns {Object} ReconciliationResult containing the table OOXML
|
|
301
|
+
*/
|
|
302
|
+
executeTableGeneration(markdownTable) {
|
|
303
|
+
const tableData = parseTable(markdownTable);
|
|
304
|
+
if (tableData.rows.length === 0 && tableData.headers.length === 0) {
|
|
305
|
+
return {
|
|
306
|
+
ooxml: '',
|
|
307
|
+
isValid: false,
|
|
308
|
+
warnings: ['Could not parse Markdown table']
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const tableOoxml = generateTableOoxml(tableData, {
|
|
313
|
+
generateRedlines: this.generateRedlines,
|
|
314
|
+
author: this.author
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
ooxml: tableOoxml,
|
|
319
|
+
isValid: true,
|
|
320
|
+
warnings: [],
|
|
321
|
+
includeNumbering: false
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
export { detectContentType, parseListItems, parseTable };
|
|
326
|
+
|