@ansonlai/docx-redline-js 0.8.1 → 0.8.3

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.
@@ -1,44 +1,44 @@
1
- /**
2
- * List generation flow extracted from ReconciliationPipeline.
3
- */
4
-
5
- import { preprocessMarkdown } from './markdown-processor.js';
1
+ /**
2
+ * List generation flow extracted from ReconciliationPipeline.
3
+ */
4
+
5
+ import { preprocessMarkdown } from './markdown-processor.js';
6
6
  import { matchListMarker, stripListMarker } from './list-markers.js';
7
- import { serializeToOoxml } from './serialization.js';
8
- import { generateTableOoxml } from '../services/table-reconciliation.js';
7
+ import { serializeToOoxml } from './serialization.js';
8
+ import { generateTableOoxml } from '../services/table-reconciliation.js';
9
9
  import { parseTable } from './content-analysis.js';
10
10
  import { log } from '../adapters/logger.js';
11
11
  import { createRevisionMetadata, NS_W, RunKind } from '../core/types.js';
12
12
  import { parseOoxmlSafe, serializeXml } from '../adapters/xml-adapter.js';
13
13
  import { createWordElement } from '../core/word-xml.js';
14
-
15
- /**
16
- * Executes list generation when paragraph content expands into list/table blocks.
17
- *
18
- * @param {Object} options - Generation options
19
- * @param {string} options.cleanText - Preprocessed target text
20
- * @param {Object|null} options.numberingContext - Existing numbering context
21
- * @param {Array} [options.originalRunModel=[]] - Original run model
22
- * @param {string} [options.originalText=''] - Original text fallback
23
- * @param {boolean} [options.generateRedlines=true] - Track-change toggle
24
- * @param {string} [options.author='AI'] - Author metadata
25
- * @param {string|null} [options.font=null] - Optional font
26
- * @param {import('../services/numbering-service.js').NumberingService} options.numberingService - Numbering service
27
- * @returns {Promise<import('../core/types.js').ReconciliationResult>}
28
- */
14
+
15
+ /**
16
+ * Executes list generation when paragraph content expands into list/table blocks.
17
+ *
18
+ * @param {Object} options - Generation options
19
+ * @param {string} options.cleanText - Preprocessed target text
20
+ * @param {Object|null} options.numberingContext - Existing numbering context
21
+ * @param {Array} [options.originalRunModel=[]] - Original run model
22
+ * @param {string} [options.originalText=''] - Original text fallback
23
+ * @param {boolean} [options.generateRedlines=true] - Track-change toggle
24
+ * @param {string} [options.author='AI'] - Author metadata
25
+ * @param {string|null} [options.font=null] - Optional font
26
+ * @param {import('../services/numbering-service.js').NumberingService} options.numberingService - Numbering service
27
+ * @returns {Promise<import('../core/types.js').ReconciliationResult>}
28
+ */
29
29
  export async function executeListGeneration(options) {
30
- const {
31
- cleanText,
32
- numberingContext,
33
- originalRunModel = [],
34
- originalText = '',
35
- generateRedlines = true,
30
+ const {
31
+ cleanText,
32
+ numberingContext,
33
+ originalRunModel = [],
34
+ originalText = '',
35
+ generateRedlines = true,
36
36
  author = 'AI',
37
37
  font = null,
38
38
  revisionIdAllocator = null,
39
39
  numberingService
40
- } = options;
41
-
40
+ } = options;
41
+
42
42
  const normalizedListText = normalizeCompositeListMarkers(cleanText);
43
43
  const lineMetadata = buildLineMetadata(normalizedListText);
44
44
  const rawLines = lineMetadata.map(line => line.raw);
@@ -50,26 +50,61 @@ export async function executeListGeneration(options) {
50
50
  sourcePPr,
51
51
  ['rFonts', 'kern', 'position', 'rtl', 'cs', 'lang']
52
52
  );
53
-
54
- let deletionRuns = [];
53
+
54
+ let deletionRuns = [];
55
55
  if (generateRedlines) {
56
- if (originalRunModel && originalRunModel.length > 0) {
57
- deletionRuns = originalRunModel
58
- .filter(run => run.kind === 'text' || run.kind === 'run')
59
- .map(run => ({ ...run, kind: 'deletion', author }));
60
- } else if (originalText && originalText.trim().length > 0) {
61
- const trimmed = originalText.trim();
62
- deletionRuns = [{
63
- kind: 'deletion',
64
- text: trimmed,
65
- author,
66
- startOffset: 0,
67
- endOffset: trimmed.length
68
- }];
56
+ if (originalRunModel && originalRunModel.length > 0) {
57
+ deletionRuns = originalRunModel
58
+ .filter(run => run.kind === 'text' || run.kind === 'run')
59
+ .map(run => ({ ...run, kind: 'deletion', author }));
60
+ } else if (originalText && originalText.trim().length > 0) {
61
+ const trimmed = originalText.trim();
62
+ deletionRuns = [{
63
+ kind: 'deletion',
64
+ text: trimmed,
65
+ author,
66
+ startOffset: 0,
67
+ endOffset: trimmed.length
68
+ }];
69
69
  }
70
70
  }
71
71
 
72
- if (generateRedlines && deletionRuns.length > 0) {
72
+ const sourceParagraphCount = (originalRunModel || [])
73
+ .filter(run => run.kind === RunKind.PARAGRAPH_START).length;
74
+ if (generateRedlines && sourceParagraphCount > 0 && (sourceParagraphCount > 1 || deletionRuns.length === 0)) {
75
+ // Each source paragraph keeps its own deleted paragraph (content and tracked-deleted mark), so
76
+ // Reject All restores every original paragraph boundary instead of merging them into one.
77
+ // Serialize each source paragraph on its own so empty paragraphs are emitted too (an empty
78
+ // paragraph carrying only its tracked-deleted mark).
79
+ let segment = null;
80
+ const flushSegment = () => {
81
+ if (!segment) return;
82
+ results.push(serializeToOoxml(segment, null, [], {
83
+ author,
84
+ generateRedlines,
85
+ revisionIdAllocator
86
+ }));
87
+ segment = null;
88
+ };
89
+ for (const run of originalRunModel) {
90
+ if (run.kind === RunKind.PARAGRAPH_START) {
91
+ flushSegment();
92
+ segment = [{
93
+ ...run,
94
+ pPrElement: null,
95
+ pPrXml: addParagraphMarkRevision(
96
+ run.pPrElement || run.pPrXml || null,
97
+ 'del',
98
+ author,
99
+ revisionIdAllocator
100
+ )
101
+ }];
102
+ } else if (segment && (run.kind === 'text' || run.kind === 'run')) {
103
+ segment.push({ ...run, kind: 'deletion', author });
104
+ }
105
+ }
106
+ flushSegment();
107
+ } else if (generateRedlines && deletionRuns.length > 0) {
73
108
  const deletedPPr = addParagraphMarkRevision(sourcePPr, 'del', author, revisionIdAllocator);
74
109
  const deletedParagraph = serializeToOoxml(deletionRuns, deletedPPr, [], {
75
110
  author,
@@ -78,102 +113,102 @@ export async function executeListGeneration(options) {
78
113
  });
79
114
  results.push(deletedParagraph);
80
115
  }
81
-
82
- const indentStep = detectIndentationStep(rawLines);
83
- log(`[ListGen] Detected indentation step: ${indentStep} spaces/chars`);
84
-
116
+
117
+ const indentStep = detectIndentationStep(rawLines);
118
+ log(`[ListGen] Detected indentation step: ${indentStep} spaces/chars`);
119
+
85
120
  const firstMarker = lineMetadata.find(line => line.marker)?.marker || '';
86
- const { format: defaultFormat } = numberingService.detectNumberingFormat(firstMarker);
87
- log(`[ListGen] Detected primary marker: "${firstMarker}", format: ${defaultFormat}`);
88
-
89
- for (let i = 0; i < lineMetadata.length; i++) {
90
- const tableBlock = collectMarkdownTableBlock(lineMetadata, i);
91
- if (tableBlock) {
92
- const tableData = parseTable(tableBlock.tableText);
93
- if (tableData.headers.length > 0 || tableData.rows.length > 0) {
121
+ const { format: defaultFormat } = numberingService.detectNumberingFormat(firstMarker);
122
+ log(`[ListGen] Detected primary marker: "${firstMarker}", format: ${defaultFormat}`);
123
+
124
+ for (let i = 0; i < lineMetadata.length; i++) {
125
+ const tableBlock = collectMarkdownTableBlock(lineMetadata, i);
126
+ if (tableBlock) {
127
+ const tableData = parseTable(tableBlock.tableText);
128
+ if (tableData.headers.length > 0 || tableData.rows.length > 0) {
94
129
  results.push(generateTableOoxml(tableData, {
95
130
  generateRedlines,
96
131
  author,
97
132
  revisionIdAllocator,
98
133
  trackAsBlock: true
99
134
  }));
100
- i = tableBlock.endIndex;
101
- continue;
102
- }
103
- }
104
-
105
- const line = lineMetadata[i];
135
+ i = tableBlock.endIndex;
136
+ continue;
137
+ }
138
+ }
139
+
140
+ const line = lineMetadata[i];
106
141
  const entry = buildListEntry(
107
142
  line,
108
143
  indentStep,
109
- numberingContext,
110
- numberingService,
111
- generateRedlines,
144
+ numberingContext,
145
+ numberingService,
146
+ generateRedlines,
112
147
  author,
113
148
  font,
114
149
  revisionIdAllocator,
115
150
  inheritedTypographyRPrXml,
116
151
  inheritedHeadingRPrXml
117
152
  );
118
- results.push(entry.ooxml);
119
- }
120
-
121
- const numberingXml = numberingService.generateNumberingXml();
122
- const finalOoxml = results.join('');
153
+ results.push(entry.ooxml);
154
+ }
155
+
156
+ const numberingXml = numberingService.generateNumberingXml();
157
+ const finalOoxml = results.join('');
123
158
 
124
159
  log(`[ListGen] ✅ Generated OOXML for ${results.length} paragraphs, total length: ${finalOoxml.length}`);
125
160
  log(`[ListGen] First 200 chars: ${finalOoxml.substring(0, 200)}...`);
126
-
127
- return {
161
+
162
+ return {
128
163
  ooxml: finalOoxml,
129
- isValid: true,
130
- warnings: ['Paragraph expanded to list fragment'],
131
- type: 'fragment',
132
- includeNumbering: true,
133
- numberingXml
134
- };
164
+ isValid: true,
165
+ warnings: ['Paragraph expanded to list fragment'],
166
+ type: 'fragment',
167
+ includeNumbering: true,
168
+ numberingXml
169
+ };
135
170
  }
136
-
137
- /**
138
- * Heuristically detects indentation step (spaces/tabs per level).
139
- *
140
- * @param {string[]} lines - Raw lines
141
- * @returns {number}
142
- */
143
- export function detectIndentationStep(lines) {
144
- const indentations = lines
145
- .map(line => line.match(/^(\s*)/)[0].length)
146
- .filter(length => length > 0)
147
- .sort((a, b) => a - b);
148
-
149
- if (indentations.length === 0) return 2;
150
-
151
- let minJump = indentations[0];
152
- for (let i = 1; i < indentations.length; i++) {
153
- const jump = indentations[i] - indentations[i - 1];
154
- if (jump > 0 && jump < minJump) {
155
- minJump = jump;
156
- }
157
- }
158
-
159
- return minJump || 2;
160
- }
161
-
171
+
172
+ /**
173
+ * Heuristically detects indentation step (spaces/tabs per level).
174
+ *
175
+ * @param {string[]} lines - Raw lines
176
+ * @returns {number}
177
+ */
178
+ export function detectIndentationStep(lines) {
179
+ const indentations = lines
180
+ .map(line => line.match(/^(\s*)/)[0].length)
181
+ .filter(length => length > 0)
182
+ .sort((a, b) => a - b);
183
+
184
+ if (indentations.length === 0) return 2;
185
+
186
+ let minJump = indentations[0];
187
+ for (let i = 1; i < indentations.length; i++) {
188
+ const jump = indentations[i] - indentations[i - 1];
189
+ if (jump > 0 && jump < minJump) {
190
+ minJump = jump;
191
+ }
192
+ }
193
+
194
+ return minJump || 2;
195
+ }
196
+
162
197
  function buildLineMetadata(cleanText) {
163
- return cleanText
164
- .split('\n')
165
- .filter(line => line.trim().length > 0)
166
- .map(raw => {
167
- const markerMatch = matchListMarker(raw);
168
- const headerMatch = raw.match(/^\s*(#{1,9})\s+(.*)/);
169
- return {
170
- raw,
171
- marker: markerMatch ? markerMatch[2].trim() : '',
172
- headerMatch,
173
- indentSize: (raw.match(/^(\s*)/)?.[1].length) || 0,
174
- isTableLine: /^\s*\|/.test(raw),
175
- isTableSeparator: /^\s*\|?[\s:-]*-[-\s|:]*\|?\s*$/.test(raw)
176
- };
198
+ return cleanText
199
+ .split('\n')
200
+ .filter(line => line.trim().length > 0)
201
+ .map(raw => {
202
+ const markerMatch = matchListMarker(raw);
203
+ const headerMatch = raw.match(/^\s*(#{1,9})\s+(.*)/);
204
+ return {
205
+ raw,
206
+ marker: markerMatch ? markerMatch[2].trim() : '',
207
+ headerMatch,
208
+ indentSize: (raw.match(/^(\s*)/)?.[1].length) || 0,
209
+ isTableLine: /^\s*\|/.test(raw),
210
+ isTableSeparator: /^\s*\|?[\s:-]*-[-\s|:]*\|?\s*$/.test(raw)
211
+ };
177
212
  });
178
213
  }
179
214
 
@@ -214,34 +249,34 @@ function normalizeCompositeListMarkers(text) {
214
249
  log(`[ListGen] Normalized ${rewrittenCount} composite list markers (e.g., "- A." -> "A.").`);
215
250
  return updated.join('\n');
216
251
  }
217
-
218
- function collectMarkdownTableBlock(lineMetadata, index) {
219
- const current = lineMetadata[index];
220
- const next = lineMetadata[index + 1];
221
-
222
- if (!current?.isTableLine || !next?.isTableLine || !next?.isTableSeparator) {
223
- return null;
224
- }
225
-
226
- const tableLines = [];
227
- let cursor = index;
228
- while (cursor < lineMetadata.length && lineMetadata[cursor].isTableLine) {
229
- tableLines.push(lineMetadata[cursor].raw);
230
- cursor++;
231
- }
232
-
233
- return {
234
- tableText: tableLines.join('\n'),
235
- endIndex: cursor - 1
236
- };
237
- }
238
-
252
+
253
+ function collectMarkdownTableBlock(lineMetadata, index) {
254
+ const current = lineMetadata[index];
255
+ const next = lineMetadata[index + 1];
256
+
257
+ if (!current?.isTableLine || !next?.isTableLine || !next?.isTableSeparator) {
258
+ return null;
259
+ }
260
+
261
+ const tableLines = [];
262
+ let cursor = index;
263
+ while (cursor < lineMetadata.length && lineMetadata[cursor].isTableLine) {
264
+ tableLines.push(lineMetadata[cursor].raw);
265
+ cursor++;
266
+ }
267
+
268
+ return {
269
+ tableText: tableLines.join('\n'),
270
+ endIndex: cursor - 1
271
+ };
272
+ }
273
+
239
274
  function buildListEntry(
240
275
  line,
241
276
  indentStep,
242
- numberingContext,
243
- numberingService,
244
- generateRedlines,
277
+ numberingContext,
278
+ numberingService,
279
+ generateRedlines,
245
280
  author,
246
281
  font,
247
282
  revisionIdAllocator,
@@ -251,11 +286,11 @@ function buildListEntry(
251
286
  let pPrXml = '';
252
287
  let segmentText = '';
253
288
  let insertedRPrXml = inheritedTypographyRPrXml;
254
-
255
- if (line.headerMatch) {
256
- const level = Math.min(line.headerMatch[1].length, 9);
257
- const outlineLevel = Math.min(level - 1, 8);
258
- const headingSizes = [32, 28, 26, 24, 22, 20, 20, 20, 20];
289
+
290
+ if (line.headerMatch) {
291
+ const level = Math.min(line.headerMatch[1].length, 9);
292
+ const outlineLevel = Math.min(level - 1, 8);
293
+ const headingSizes = [32, 28, 26, 24, 22, 20, 20, 20, 20];
259
294
  const headingSize = headingSizes[level - 1] || headingSizes[headingSizes.length - 1];
260
295
  segmentText = line.headerMatch[2].trim();
261
296
  pPrXml = `<w:pPr><w:pStyle w:val="Heading${level}"/><w:outlineLvl w:val="${outlineLevel}"/><w:rPr><w:b/><w:sz w:val="${headingSize}"/><w:szCs w:val="${headingSize}"/></w:rPr></w:pPr>`;
@@ -263,17 +298,17 @@ function buildListEntry(
263
298
  inheritedHeadingRPrXml,
264
299
  `<w:rPr><w:b/><w:sz w:val="${headingSize}"/><w:szCs w:val="${headingSize}"/></w:rPr>`
265
300
  );
266
- } else if (line.marker) {
267
- const lineFormat = numberingService.detectNumberingFormat(line.marker);
268
- const indentLevel = indentStep > 0 ? Math.floor(line.indentSize / indentStep) : 0;
269
- const contextLevel = numberingContext?.ilvl || 0;
270
- const ilvl = lineFormat.format === 'outline'
271
- ? Math.min(8, lineFormat.depth)
272
- : Math.min(8, indentLevel + contextLevel);
273
-
274
- segmentText = stripListMarker(line.raw);
275
- const numId = numberingService.getOrCreateNumId({ type: lineFormat.format }, numberingContext);
276
- pPrXml = numberingService.buildListPPr(numId, ilvl);
301
+ } else if (line.marker) {
302
+ const lineFormat = numberingService.detectNumberingFormat(line.marker);
303
+ const indentLevel = indentStep > 0 ? Math.floor(line.indentSize / indentStep) : 0;
304
+ const contextLevel = numberingContext?.ilvl || 0;
305
+ const ilvl = lineFormat.format === 'outline'
306
+ ? Math.min(8, lineFormat.depth)
307
+ : Math.min(8, indentLevel + contextLevel);
308
+
309
+ segmentText = stripListMarker(line.raw);
310
+ const numId = numberingService.getOrCreateNumId({ type: lineFormat.format }, numberingContext);
311
+ pPrXml = numberingService.buildListPPr(numId, ilvl);
277
312
  } else {
278
313
  segmentText = line.raw;
279
314
  }
@@ -294,13 +329,13 @@ function buildListEntry(
294
329
  kind: generateRedlines ? 'insertion' : 'run',
295
330
  text: cleanText,
296
331
  rPrXml: insertedRPrXml,
297
- author,
298
- startOffset: 0,
299
- endOffset: cleanText.length
300
- });
301
-
302
- return {
303
- ooxml: serializeToOoxml(runModel, pPrXml, formatHints, {
332
+ author,
333
+ startOffset: 0,
334
+ endOffset: cleanText.length
335
+ });
336
+
337
+ return {
338
+ ooxml: serializeToOoxml(runModel, pPrXml, formatHints, {
304
339
  author,
305
340
  generateRedlines,
306
341
  font,
@@ -26,9 +26,19 @@ export function resolveTestConcurrency(value = process.env.DOCX_TEST_CONCURRENCY
26
26
  return Number(value);
27
27
  }
28
28
 
29
+ export function resolveTestTimeout(value = process.env.DOCX_TEST_TIMEOUT) {
30
+ if (value == null || value === '') {
31
+ return 180000;
32
+ }
33
+ if (!/^\d+$/.test(String(value)) || Number(value) < 1) {
34
+ throw new Error('DOCX_TEST_TIMEOUT must be a positive integer.');
35
+ }
36
+ return Number(value);
37
+ }
38
+
29
39
  export function runTestFile(file, options = {}) {
30
40
  const testDir = options.testDir || defaultTestDir;
31
- const timeout = options.timeout ?? 30000;
41
+ const timeout = options.timeout ?? resolveTestTimeout();
32
42
  const filePath = join(testDir, file);
33
43
 
34
44
  return new Promise(resolveResult => {
@@ -41,7 +51,9 @@ export function runTestFile(file, options = {}) {
41
51
  windowsHide: true
42
52
  }, (error, stdout = '', stderr = '') => {
43
53
  let executionError = error;
44
- if (!executionError && failOutputPattern.test(stdout)) {
54
+ if (error && (error.killed || /timed? ?out/i.test(error.message))) {
55
+ executionError = new Error(`Test timed out after ${timeout}ms: ${file}`);
56
+ } else if (!executionError && failOutputPattern.test(stdout)) {
45
57
  executionError = new Error(`Test printed a failure marker while exiting successfully:\n${stdout}`);
46
58
  }
47
59
  resolveResult({
@@ -8,6 +8,9 @@ import { extractDocumentPartsEntries, computeRevisionTokenSync } from './revisio
8
8
  const attr = (node, name) => node?.getAttribute?.(`w:${name}`) || node?.getAttribute?.(name) || '';
9
9
  const descendants = (node, name) => Array.from(node?.getElementsByTagNameNS?.(NS_W, name) || []);
10
10
  const first = (node, name) => descendants(node, name)[0] || null;
11
+ const directChild = (node, name) => Array.from(node?.childNodes || []).find(child => child?.nodeType === 1 && child.localName === name && (!child.namespaceURI || child.namespaceURI === NS_W)) || null;
12
+ /** Current paragraph properties: the paragraph's direct w:pPr only (never the historical w:pPrChange/w:pPr). */
13
+ const currentPPr = paragraph => directChild(paragraph, 'pPr');
11
14
  function hasAncestor(node, localName) { let cursor = node?.parentNode; while (cursor) { if (cursor.localName === localName && (!cursor.namespaceURI || cursor.namespaceURI === NS_W)) return true; cursor = cursor.parentNode; } return false; }
12
15
 
13
16
  function parseXml(xml, partName, required = false) {
@@ -18,11 +21,11 @@ function parseXml(xml, partName, required = false) {
18
21
  }
19
22
 
20
23
  function paragraphListProperties(paragraph) {
21
- const numPr = first(first(paragraph, 'pPr'), 'numPr');
24
+ const numPr = directChild(currentPPr(paragraph), 'numPr');
22
25
  if (!numPr) return null;
23
- const numId = attr(first(numPr, 'numId'), 'val');
26
+ const numId = attr(directChild(numPr, 'numId'), 'val');
24
27
  if (!numId || numId === '0') return null;
25
- return { numId, level: Number.parseInt(attr(first(numPr, 'ilvl'), 'val') || '0', 10) || 0 };
28
+ return { numId, level: Number.parseInt(attr(directChild(numPr, 'ilvl'), 'val') || '0', 10) || 0 };
26
29
  }
27
30
 
28
31
  function parseNumbering(numberingDoc) {
@@ -100,11 +103,11 @@ function createNumberingResolver(numberingDoc) {
100
103
  }
101
104
 
102
105
  function headingLevel(paragraph) {
103
- const pPr = first(paragraph, 'pPr');
104
- const style = attr(first(pPr, 'pStyle'), 'val');
106
+ const pPr = currentPPr(paragraph);
107
+ const style = attr(directChild(pPr, 'pStyle'), 'val');
105
108
  const match = style.match(/^heading\s*([1-9])$/i);
106
109
  if (match) return Math.min(Number(match[1]), 6);
107
- const outline = Number.parseInt(attr(first(pPr, 'outlineLvl'), 'val'), 10);
110
+ const outline = Number.parseInt(attr(directChild(pPr, 'outlineLvl'), 'val'), 10);
108
111
  return Number.isInteger(outline) ? Math.min(outline + 1, 6) : null;
109
112
  }
110
113
 
@@ -283,7 +286,7 @@ export function inspectDocumentParts(parts, options = {}) {
283
286
  const ids = [...new Set([...descendants(paragraph, 'commentRangeStart'), ...descendants(paragraph, 'commentReference')].map(node => attr(node, 'id')).filter(Boolean))];
284
287
  const authors = revisionAuthors(paragraph);
285
288
  const list = resolveNumbering(paragraphListProperties(paragraph));
286
- const styleId = attr(first(first(paragraph, 'pPr'), 'pStyle'), 'val') || null;
289
+ const styleId = attr(directChild(currentPPr(paragraph), 'pStyle'), 'val') || null;
287
290
  const structure = structuralContext(paragraph, text);
288
291
  const index = zeroIndex + 1;
289
292
  const provision = list?.label && list.format !== 'bullet' ? list.label : null;