@ansonlai/docx-redline-js 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/AGENTS.md +176 -0
  2. package/ARCHITECTURE.md +121 -0
  3. package/LICENSE +21 -0
  4. package/README.md +177 -0
  5. package/adapters/config.js +43 -0
  6. package/adapters/logger.js +89 -0
  7. package/adapters/xml-adapter.js +74 -0
  8. package/core/list-targeting.js +398 -0
  9. package/core/ooxml-identifiers.js +15 -0
  10. package/core/paragraph-offset-policy.js +50 -0
  11. package/core/paragraph-targeting.js +501 -0
  12. package/core/table-targeting.js +233 -0
  13. package/core/types.js +204 -0
  14. package/core/xml-query.js +99 -0
  15. package/dist/docx-redline-js.esm.js +8801 -0
  16. package/dist/docx-redline-js.esm.js.map +7 -0
  17. package/dist/docx-redline-js.esm.min.js +195 -0
  18. package/dist/docx-redline-js.esm.min.js.map +7 -0
  19. package/engine/format-application.js +358 -0
  20. package/engine/format-extraction.js +232 -0
  21. package/engine/format-paragraph-targeting.js +208 -0
  22. package/engine/format-span-application.js +178 -0
  23. package/engine/formatting-removal.js +330 -0
  24. package/engine/oxml-engine.js +279 -0
  25. package/engine/reconstruction-mapper.js +270 -0
  26. package/engine/reconstruction-mode.js +38 -0
  27. package/engine/reconstruction-writer.js +276 -0
  28. package/engine/rpr-helpers.js +194 -0
  29. package/engine/run-builders.js +235 -0
  30. package/engine/surgical-mode.js +520 -0
  31. package/engine/table-cell-context.js +151 -0
  32. package/engine/table-mode.js +172 -0
  33. package/index.js +308 -0
  34. package/orchestration/list-markdown.js +141 -0
  35. package/orchestration/list-parsing.js +73 -0
  36. package/orchestration/list-structural-fallback.js +530 -0
  37. package/orchestration/redline-operation-converter.js +141 -0
  38. package/orchestration/route-plan.js +160 -0
  39. package/package.json +76 -0
  40. package/pipeline/content-analysis.js +107 -0
  41. package/pipeline/diff-engine.js +204 -0
  42. package/pipeline/ingestion-export.js +255 -0
  43. package/pipeline/ingestion-paragraph.js +351 -0
  44. package/pipeline/ingestion-table.js +169 -0
  45. package/pipeline/ingestion-xml.js +39 -0
  46. package/pipeline/ingestion.js +8 -0
  47. package/pipeline/list-generation.js +280 -0
  48. package/pipeline/list-markers.js +77 -0
  49. package/pipeline/markdown-processor.js +160 -0
  50. package/pipeline/patching.js +408 -0
  51. package/pipeline/pipeline.js +326 -0
  52. package/pipeline/serialization.js +395 -0
  53. package/services/browser-demo-prompt-context.js +345 -0
  54. package/services/comment-builders.js +60 -0
  55. package/services/comment-engine.js +248 -0
  56. package/services/comment-locator.js +197 -0
  57. package/services/comment-package.js +113 -0
  58. package/services/numbering-helpers.js +416 -0
  59. package/services/numbering-service.js +290 -0
  60. package/services/package-builder.js +147 -0
  61. package/services/standalone-docx-plumbing.js +443 -0
  62. package/services/standalone-operation-runner.js +1169 -0
  63. package/services/table-reconciliation.js +344 -0
  64. package/standalone.js +5 -0
@@ -0,0 +1,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
+