@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
@@ -41,9 +41,15 @@ export function createDiffEngine(options = {}) {
41
41
  return engine;
42
42
  }
43
43
 
44
+ // Opening and closing punctuation that hugs a word. Splitting it into its own
45
+ // token lets "agents and attorneys" -> "agents, attorneys," keep both words
46
+ // unchanged instead of replacing the whole phrase.
47
+ const LEADING_PUNCTUATION = /^[("'\[{“‘]+/u;
48
+ const TRAILING_PUNCTUATION = /[)"'\]}”’,.;:!?]+$/u;
49
+
44
50
  function tokenize(text, atomicChars = null) {
45
51
  const tokens = [];
46
- const pushWord = word => {
52
+ const pushPiece = word => {
47
53
  if (!atomicChars || atomicChars.size === 0) {
48
54
  tokens.push(word);
49
55
  return;
@@ -59,6 +65,20 @@ function tokenize(text, atomicChars = null) {
59
65
  }
60
66
  if (start < word.length) tokens.push(word.slice(start));
61
67
  };
68
+ const pushWord = word => {
69
+ const leading = word.match(LEADING_PUNCTUATION)?.[0] || '';
70
+ const rest = word.slice(leading.length);
71
+ const trailing = rest.match(TRAILING_PUNCTUATION)?.[0] || '';
72
+ const core = rest.slice(0, rest.length - trailing.length);
73
+ // A token made only of punctuation stays whole.
74
+ if (!core) {
75
+ pushPiece(word);
76
+ return;
77
+ }
78
+ for (const char of leading) pushPiece(char);
79
+ pushPiece(core);
80
+ for (const char of trailing) pushPiece(char);
81
+ };
62
82
  const leading = text.match(/^\s+/);
63
83
  if (leading) tokens.push(leading[0]);
64
84
 
@@ -243,11 +263,120 @@ export function computeWordDiffs(originalText, newText, options = {}) {
243
263
  const charDiffs = dmp.diff_main(encodeTokenIdsForDmp(tokenIds1), encodeTokenIdsForDmp(tokenIds2));
244
264
  if (cleanupSemantic) {
245
265
  dmp.diff_cleanupSemantic(charDiffs);
266
+ refineReplacementsAroundAnchors(dmp, charDiffs, wordArray);
246
267
  }
247
268
 
248
269
  return decodeBmpDiffs(charDiffs, wordArray);
249
270
  }
250
271
 
272
+ // A token that is a substantive word: four or more letters or digits. Short
273
+ // function words ("a", "the", "and") and punctuation never anchor on their own.
274
+ const ANCHOR_TOKEN = /^[\p{L}\p{N}][\p{L}\p{N}'’-]{3,}$/u;
275
+
276
+ function isAnchorEquality(chars, wordArray) {
277
+ for (let index = 0; index < chars.length; index++) {
278
+ if (ANCHOR_TOKEN.test(wordArray[bmpCharToTokenId(chars.charCodeAt(index))])) return true;
279
+ }
280
+ return false;
281
+ }
282
+
283
+ const MAX_ANCHOR_REFINEMENT_CELLS = 250000;
284
+
285
+ /**
286
+ * Token LCS that weights substantive words far above whitespace, punctuation
287
+ * and function words. A plain LCS would rather keep " and " (three tokens)
288
+ * than "attorneys" (one token), which is the opposite of a useful redline.
289
+ * Returns null when the replacement is too large to refine cheaply.
290
+ *
291
+ * @param {string} deleted - BMP-encoded deleted tokens
292
+ * @param {string} inserted - BMP-encoded inserted tokens
293
+ * @param {string[]} wordArray
294
+ * @returns {Array<[number, string]>|null}
295
+ */
296
+ function anchorWeightedDiff(deleted, inserted, wordArray) {
297
+ const rows = deleted.length;
298
+ const cols = inserted.length;
299
+ if (!rows || !cols || (rows + 1) * (cols + 1) > MAX_ANCHOR_REFINEMENT_CELLS) return null;
300
+
301
+ const weight = char => {
302
+ const token = wordArray[bmpCharToTokenId(char.charCodeAt(0))];
303
+ return ANCHOR_TOKEN.test(token) ? token.length * 100 : token.length;
304
+ };
305
+ const width = cols + 1;
306
+ const scores = new Float64Array((rows + 1) * width);
307
+ for (let row = rows - 1; row >= 0; row--) {
308
+ for (let col = cols - 1; col >= 0; col--) {
309
+ const skip = Math.max(scores[(row + 1) * width + col], scores[row * width + col + 1]);
310
+ scores[row * width + col] = deleted[row] === inserted[col]
311
+ ? Math.max(skip, weight(deleted[row]) + scores[(row + 1) * width + col + 1])
312
+ : skip;
313
+ }
314
+ }
315
+
316
+ const result = [];
317
+ const push = (op, char) => {
318
+ const last = result[result.length - 1];
319
+ if (last?.[0] === op) last[1] += char;
320
+ else result.push([op, char]);
321
+ };
322
+ let row = 0;
323
+ let col = 0;
324
+ while (row < rows && col < cols) {
325
+ const here = scores[row * width + col];
326
+ if (deleted[row] === inserted[col]
327
+ && here === weight(deleted[row]) + scores[(row + 1) * width + col + 1]) {
328
+ push(0, deleted[row]);
329
+ row++;
330
+ col++;
331
+ } else if (scores[(row + 1) * width + col] >= scores[row * width + col + 1]) {
332
+ push(-1, deleted[row++]);
333
+ } else {
334
+ push(1, inserted[col++]);
335
+ }
336
+ }
337
+ while (row < rows) push(-1, deleted[row++]);
338
+ while (col < cols) push(1, inserted[col++]);
339
+ return result;
340
+ }
341
+
342
+ /**
343
+ * Semantic cleanup measures equalities in tokens, so a single unchanged word
344
+ * between two edits ("agents[ and] attorneys[, accountants ...]") is folded
345
+ * into one whole-phrase replacement. Re-diff each replacement and keep any
346
+ * unchanged run that contains a substantive word, so the redline only marks
347
+ * the text that actually changed.
348
+ */
349
+ function refineReplacementsAroundAnchors(dmp, diffs, wordArray) {
350
+ for (let index = 0; index < diffs.length; index++) {
351
+ let deleteIndex = -1;
352
+ let insertIndex = -1;
353
+ if (diffs[index][0] === -1 && diffs[index + 1]?.[0] === 1) {
354
+ deleteIndex = index;
355
+ insertIndex = index + 1;
356
+ } else if (diffs[index][0] === 1 && diffs[index + 1]?.[0] === -1) {
357
+ insertIndex = index;
358
+ deleteIndex = index + 1;
359
+ } else {
360
+ continue;
361
+ }
362
+
363
+ const inner = anchorWeightedDiff(diffs[deleteIndex][1], diffs[insertIndex][1], wordArray);
364
+ if (!inner?.some(([op, chars]) => op === 0 && isAnchorEquality(chars, wordArray))) continue;
365
+
366
+ const refined = [];
367
+ for (const [op, chars] of inner) {
368
+ if (op === 0 && !isAnchorEquality(chars, wordArray)) {
369
+ refined.push([-1, chars], [1, chars]);
370
+ } else {
371
+ refined.push([op, chars]);
372
+ }
373
+ }
374
+ dmp.diff_cleanupMerge(refined);
375
+ diffs.splice(index, 2, ...refined);
376
+ index += refined.length - 1;
377
+ }
378
+ }
379
+
251
380
  /**
252
381
  * Computes a character-local diff without semantic cleanup. This is used to
253
382
  * refine whitespace-only substitutions that a word-level token groups with
@@ -1,243 +1,227 @@
1
- /**
2
- * Word OOXML ingestion helpers for plain-text/markdown export.
3
- *
4
- * These helpers are intentionally conservative and only map obvious signals:
5
- * - Paragraph structure
6
- * - Heading styles
7
- * - List paragraph properties
8
- * - Run-level bold/italic formatting
9
- */
10
-
11
- import { parseOoxmlSafe } from '../adapters/xml-adapter.js';
12
- import { NS_W } from '../core/types.js';
13
- import { getXmlParseError } from '../core/xml-query.js';
14
- import { isNodeVisibleInRevisionView, readCanonicalRunText } from '../core/paragraph-text.js';
15
-
16
- function hasParserError(doc) {
17
- if (!doc || !doc.documentElement) return true;
18
- if (doc.documentElement.localName === 'parsererror') return true;
19
- return !!getXmlParseError(doc);
20
- }
21
-
22
- function parseWordOoxml(ooxml) {
23
- const parsed = parseOoxmlSafe(ooxml, 'application/xml');
24
- if (parsed.error || hasParserError(parsed.doc)) return { ...parsed, doc: null };
25
- return parsed;
26
- }
27
-
28
- function getWordParagraphs(doc) {
29
- if (!doc) return [];
30
- const namespaced = Array.from(doc.getElementsByTagNameNS(NS_W, 'p'));
31
- if (namespaced.length > 0) return namespaced;
32
- return Array.from(doc.getElementsByTagNameNS('*', 'p')).filter(node => node?.localName === 'p');
33
- }
34
-
35
- function getDirectWordChild(node, localName) {
36
- const children = Array.from(node?.childNodes || []);
37
- for (const child of children) {
38
- if (child?.nodeType !== 1) continue;
39
- if (child.namespaceURI !== NS_W) continue;
40
- if (child.localName === localName) return child;
41
- }
42
- return null;
43
- }
44
-
45
- function getWordDescendants(node, localName) {
46
- return Array.from(node?.getElementsByTagNameNS?.(NS_W, localName) || []);
47
- }
48
-
49
- function getWordAttribute(element, names) {
50
- if (!element) return '';
51
- for (const name of names) {
52
- const value = element.getAttribute(name);
53
- if (value != null && value !== '') return value;
54
- }
55
- return '';
56
- }
57
-
58
- function getRunFormatting(run) {
59
- const rPr = getDirectWordChild(run, 'rPr');
60
- if (!rPr) return { bold: false, italic: false };
61
- const rStyle = getWordDescendants(rPr, 'rStyle')[0] || null;
62
- const rStyleValue = getWordAttribute(rStyle, ['w:val', 'val']).toLowerCase();
63
- const styleImpliesBold = rStyleValue.includes('strong') || rStyleValue.includes('bold');
64
- const styleImpliesItalic = rStyleValue.includes('italic') || rStyleValue.includes('emphasis');
65
- return {
66
- bold: getWordDescendants(rPr, 'b').length > 0 || styleImpliesBold,
67
- italic: getWordDescendants(rPr, 'i').length > 0 || styleImpliesItalic
68
- };
69
- }
70
-
71
- function collectParagraphSegments(paragraph) {
72
- const segments = [];
73
- const runs = Array.from(paragraph?.getElementsByTagNameNS?.(NS_W, 'r') || []);
74
- for (const run of runs) {
75
- if (!isNodeVisibleInRevisionView(run, paragraph, 'accepted')) continue;
76
- const text = readCanonicalRunText(run, { boundary: paragraph, revisionView: 'accepted' });
77
- if (!text) continue;
78
- segments.push({
79
- text,
80
- ...getRunFormatting(run)
81
- });
82
- }
83
- return segments;
84
- }
85
-
86
- function normalizeInlineWhitespace(text) {
87
- const normalizedLines = String(text || '')
88
- .replace(/\r/g, '')
89
- .split('\n')
90
- .map(line => line.replace(/[ \t]+/g, ' ').trim());
91
- return normalizedLines.join('\n').trim();
92
- }
93
-
94
- function wrapRunMarkdown(text, format) {
95
- const raw = String(text || '');
96
- if (!raw) return '';
97
- if (!format?.bold && !format?.italic) return raw;
98
-
99
- const match = raw.match(/^(\s*)([\s\S]*?)(\s*)$/);
100
- if (!match) return raw;
101
- const leading = match[1] || '';
102
- const core = match[2] || '';
103
- const trailing = match[3] || '';
104
- if (!core.trim()) return raw;
105
-
106
- let wrapped = core;
107
- if (format.bold && format.italic) wrapped = `***${core}***`;
108
- else if (format.bold) wrapped = `**${core}**`;
109
- else if (format.italic) wrapped = `*${core}*`;
110
- return `${leading}${wrapped}${trailing}`;
111
- }
112
-
113
- function parseHeadingLevel(paragraph) {
114
- const pPr = getDirectWordChild(paragraph, 'pPr');
115
- if (!pPr) return null;
116
-
117
- const pStyle = getWordDescendants(pPr, 'pStyle')[0] || null;
118
- const styleVal = getWordAttribute(pStyle, ['w:val', 'val']);
119
- if (styleVal) {
120
- const match = styleVal.match(/^heading\s*([1-9])$/i);
121
- if (match) {
122
- const level = Number.parseInt(match[1], 10);
123
- if (Number.isInteger(level)) return Math.min(Math.max(level, 1), 6);
124
- }
125
- }
126
-
127
- const outlineLvl = getWordDescendants(pPr, 'outlineLvl')[0] || null;
128
- const outlineRaw = getWordAttribute(outlineLvl, ['w:val', 'val']);
129
- const outline = Number.parseInt(outlineRaw, 10);
130
- if (Number.isInteger(outline) && outline >= 0) {
131
- return Math.min(outline + 1, 6);
132
- }
133
-
134
- return null;
135
- }
136
-
137
- function parseListInfo(paragraph) {
138
- const pPr = getDirectWordChild(paragraph, 'pPr');
139
- if (!pPr) return null;
140
-
141
- const numPr = getWordDescendants(pPr, 'numPr')[0] || null;
142
- if (!numPr) return null;
143
-
144
- const ilvlEl = getWordDescendants(numPr, 'ilvl')[0] || null;
145
- const numIdEl = getWordDescendants(numPr, 'numId')[0] || null;
146
- const ilvl = Number.parseInt(getWordAttribute(ilvlEl, ['w:val', 'val']), 10);
147
- const numId = getWordAttribute(numIdEl, ['w:val', 'val']);
148
-
149
- const safeLevel = Number.isInteger(ilvl) && ilvl >= 0 ? ilvl : 0;
150
- const marker = numId === '1' ? '-' : '1.';
151
- return { level: safeLevel, marker };
152
- }
153
-
154
- function paragraphToPlainText(paragraph) {
155
- const text = collectParagraphSegments(paragraph).map(segment => segment.text).join('');
156
- return normalizeInlineWhitespace(text);
157
- }
158
-
159
- function paragraphToMarkdown(paragraph) {
160
- const segments = collectParagraphSegments(paragraph);
161
- const inline = segments.map(segment => wrapRunMarkdown(segment.text, segment)).join('');
162
- const normalizedInline = normalizeInlineWhitespace(inline);
163
- if (!normalizedInline) return '';
164
-
165
- const headingLevel = parseHeadingLevel(paragraph);
166
- if (headingLevel != null) {
167
- return `${'#'.repeat(headingLevel)} ${normalizedInline}`;
168
- }
169
-
170
- const list = parseListInfo(paragraph);
171
- if (list) {
172
- const indent = ' '.repeat(list.level);
173
- return `${indent}${list.marker} ${normalizedInline}`;
174
- }
175
-
176
- return normalizedInline;
177
- }
178
-
179
- /**
180
- * Ingests Word OOXML and returns readable plain text.
181
- *
182
- * @param {string} ooxml
183
- * @returns {string}
184
- */
185
- export function ingestWordOoxmlToPlainText(ooxml) {
186
- return ingestWordOoxmlToPlainTextResult(ooxml).text;
187
- }
188
-
189
- /**
190
- * Result-returning plain-text ingestion for callers that need parse failures
191
- * distinguished from legitimately empty documents.
192
- *
193
- * @param {unknown} ooxml
194
- * @returns {{ text: string, status: 'ok'|'error', error?: {code:string,message:string}, warnings?: string[] }}
195
- */
196
- export function ingestWordOoxmlToPlainTextResult(ooxml) {
197
- const parsed = parseWordOoxml(ooxml);
198
- if (!parsed.doc) {
199
- return { text: '', status: 'error', error: parsed.error, warnings: parsed.warnings };
200
- }
201
- const doc = parsed.doc;
202
-
203
- const paragraphs = getWordParagraphs(doc);
204
- if (paragraphs.length === 0) {
205
- const fallback = normalizeInlineWhitespace(doc.documentElement?.textContent || '');
206
- return { text: fallback, status: 'ok', warnings: parsed.warnings };
207
- }
208
-
209
- const lines = paragraphs.map(paragraphToPlainText);
210
- return { text: lines.join('\n\n').trim(), status: 'ok', warnings: parsed.warnings };
211
- }
212
-
213
- /**
214
- * Ingests Word OOXML and returns basic markdown.
215
- *
216
- * @param {string} ooxml
217
- * @returns {string}
218
- */
219
- export function ingestWordOoxmlToMarkdown(ooxml) {
220
- return ingestWordOoxmlToMarkdownResult(ooxml).text;
221
- }
222
-
223
- /**
224
- * Result-returning markdown ingestion counterpart.
225
- *
226
- * @param {unknown} ooxml
227
- * @returns {{ text: string, status: 'ok'|'error', error?: {code:string,message:string}, warnings?: string[] }}
228
- */
229
- export function ingestWordOoxmlToMarkdownResult(ooxml) {
230
- const parsed = parseWordOoxml(ooxml);
231
- if (!parsed.doc) {
232
- return { text: '', status: 'error', error: parsed.error, warnings: parsed.warnings };
233
- }
234
- const doc = parsed.doc;
235
-
236
- const paragraphs = getWordParagraphs(doc);
237
- if (paragraphs.length === 0) {
238
- return { text: '', status: 'ok', warnings: parsed.warnings };
239
- }
240
-
241
- const lines = paragraphs.map(paragraphToMarkdown);
242
- return { text: lines.join('\n\n').trim(), status: 'ok', warnings: parsed.warnings };
243
- }
1
+ /**
2
+ * Word OOXML ingestion helpers for plain-text/markdown export.
3
+ *
4
+ * These helpers are intentionally conservative and only map obvious signals:
5
+ * - Paragraph structure
6
+ * - Heading styles
7
+ * - List paragraph properties
8
+ * - Run-level bold/italic formatting
9
+ */
10
+
11
+ import { parseOoxmlSafe } from '../adapters/xml-adapter.js';
12
+ import { NS_W } from '../core/types.js';
13
+ import { getCurrentChild, getCurrentParagraphProperties, getCurrentRunProperties } from '../core/current-properties.js';
14
+ import { getXmlParseError } from '../core/xml-query.js';
15
+ import { isNodeVisibleInRevisionView, readCanonicalRunText } from '../core/paragraph-text.js';
16
+
17
+ function hasParserError(doc) {
18
+ if (!doc || !doc.documentElement) return true;
19
+ if (doc.documentElement.localName === 'parsererror') return true;
20
+ return !!getXmlParseError(doc);
21
+ }
22
+
23
+ function parseWordOoxml(ooxml) {
24
+ const parsed = parseOoxmlSafe(ooxml, 'application/xml');
25
+ if (parsed.error || hasParserError(parsed.doc)) return { ...parsed, doc: null };
26
+ return parsed;
27
+ }
28
+
29
+ function getWordParagraphs(doc) {
30
+ if (!doc) return [];
31
+ const namespaced = Array.from(doc.getElementsByTagNameNS(NS_W, 'p'));
32
+ if (namespaced.length > 0) return namespaced;
33
+ return Array.from(doc.getElementsByTagNameNS('*', 'p')).filter(node => node?.localName === 'p');
34
+ }
35
+
36
+
37
+ function getWordAttribute(element, names) {
38
+ if (!element) return '';
39
+ for (const name of names) {
40
+ const value = element.getAttribute(name);
41
+ if (value != null && value !== '') return value;
42
+ }
43
+ return '';
44
+ }
45
+
46
+ function getRunFormatting(run) {
47
+ // Current run properties only: a w:rPrChange snapshot must not make historical b/i/rStyle look current.
48
+ const rPr = getCurrentRunProperties(run);
49
+ if (!rPr) return { bold: false, italic: false };
50
+ const rStyle = getCurrentChild(rPr, 'rStyle');
51
+ const rStyleValue = getWordAttribute(rStyle, ['w:val', 'val']).toLowerCase();
52
+ const styleImpliesBold = rStyleValue.includes('strong') || rStyleValue.includes('bold');
53
+ const styleImpliesItalic = rStyleValue.includes('italic') || rStyleValue.includes('emphasis');
54
+ return {
55
+ bold: !!getCurrentChild(rPr, 'b') || styleImpliesBold,
56
+ italic: !!getCurrentChild(rPr, 'i') || styleImpliesItalic
57
+ };
58
+ }
59
+
60
+ function collectParagraphSegments(paragraph) {
61
+ const segments = [];
62
+ const runs = Array.from(paragraph?.getElementsByTagNameNS?.(NS_W, 'r') || []);
63
+ for (const run of runs) {
64
+ if (!isNodeVisibleInRevisionView(run, paragraph, 'accepted')) continue;
65
+ const text = readCanonicalRunText(run, { boundary: paragraph, revisionView: 'accepted' });
66
+ if (!text) continue;
67
+ segments.push({
68
+ text,
69
+ ...getRunFormatting(run)
70
+ });
71
+ }
72
+ return segments;
73
+ }
74
+
75
+ function normalizeInlineWhitespace(text) {
76
+ const normalizedLines = String(text || '')
77
+ .replace(/\r/g, '')
78
+ .split('\n')
79
+ .map(line => line.replace(/[ \t]+/g, ' ').trim());
80
+ return normalizedLines.join('\n').trim();
81
+ }
82
+
83
+ function wrapRunMarkdown(text, format) {
84
+ const raw = String(text || '');
85
+ if (!raw) return '';
86
+ if (!format?.bold && !format?.italic) return raw;
87
+
88
+ const match = raw.match(/^(\s*)([\s\S]*?)(\s*)$/);
89
+ if (!match) return raw;
90
+ const leading = match[1] || '';
91
+ const core = match[2] || '';
92
+ const trailing = match[3] || '';
93
+ if (!core.trim()) return raw;
94
+
95
+ let wrapped = core;
96
+ if (format.bold && format.italic) wrapped = `***${core}***`;
97
+ else if (format.bold) wrapped = `**${core}**`;
98
+ else if (format.italic) wrapped = `*${core}*`;
99
+ return `${leading}${wrapped}${trailing}`;
100
+ }
101
+
102
+ function parseHeadingLevel(paragraph) {
103
+ const { pPr, pStyleElement, outlineLvlElement } = getCurrentParagraphProperties(paragraph);
104
+ if (!pPr) return null;
105
+
106
+ const styleVal = getWordAttribute(pStyleElement, ['w:val', 'val']);
107
+ if (styleVal) {
108
+ const match = styleVal.match(/^heading\s*([1-9])$/i);
109
+ if (match) {
110
+ const level = Number.parseInt(match[1], 10);
111
+ if (Number.isInteger(level)) return Math.min(Math.max(level, 1), 6);
112
+ }
113
+ }
114
+
115
+ const outlineRaw = getWordAttribute(outlineLvlElement, ['w:val', 'val']);
116
+ const outline = Number.parseInt(outlineRaw, 10);
117
+ if (Number.isInteger(outline) && outline >= 0) {
118
+ return Math.min(outline + 1, 6);
119
+ }
120
+
121
+ return null;
122
+ }
123
+
124
+ function parseListInfo(paragraph) {
125
+ const { numPr } = getCurrentParagraphProperties(paragraph);
126
+ if (!numPr) return null;
127
+
128
+ const ilvlEl = numPr.ilvlElement;
129
+ const numIdEl = numPr.numIdElement;
130
+ const ilvl = Number.parseInt(getWordAttribute(ilvlEl, ['w:val', 'val']), 10);
131
+ const numId = getWordAttribute(numIdEl, ['w:val', 'val']);
132
+
133
+ const safeLevel = Number.isInteger(ilvl) && ilvl >= 0 ? ilvl : 0;
134
+ const marker = numId === '1' ? '-' : '1.';
135
+ return { level: safeLevel, marker };
136
+ }
137
+
138
+ function paragraphToPlainText(paragraph) {
139
+ const text = collectParagraphSegments(paragraph).map(segment => segment.text).join('');
140
+ return normalizeInlineWhitespace(text);
141
+ }
142
+
143
+ function paragraphToMarkdown(paragraph) {
144
+ const segments = collectParagraphSegments(paragraph);
145
+ const inline = segments.map(segment => wrapRunMarkdown(segment.text, segment)).join('');
146
+ const normalizedInline = normalizeInlineWhitespace(inline);
147
+ if (!normalizedInline) return '';
148
+
149
+ const headingLevel = parseHeadingLevel(paragraph);
150
+ if (headingLevel != null) {
151
+ return `${'#'.repeat(headingLevel)} ${normalizedInline}`;
152
+ }
153
+
154
+ const list = parseListInfo(paragraph);
155
+ if (list) {
156
+ const indent = ' '.repeat(list.level);
157
+ return `${indent}${list.marker} ${normalizedInline}`;
158
+ }
159
+
160
+ return normalizedInline;
161
+ }
162
+
163
+ /**
164
+ * Ingests Word OOXML and returns readable plain text.
165
+ *
166
+ * @param {string} ooxml
167
+ * @returns {string}
168
+ */
169
+ export function ingestWordOoxmlToPlainText(ooxml) {
170
+ return ingestWordOoxmlToPlainTextResult(ooxml).text;
171
+ }
172
+
173
+ /**
174
+ * Result-returning plain-text ingestion for callers that need parse failures
175
+ * distinguished from legitimately empty documents.
176
+ *
177
+ * @param {unknown} ooxml
178
+ * @returns {{ text: string, status: 'ok'|'error', error?: {code:string,message:string}, warnings?: string[] }}
179
+ */
180
+ export function ingestWordOoxmlToPlainTextResult(ooxml) {
181
+ const parsed = parseWordOoxml(ooxml);
182
+ if (!parsed.doc) {
183
+ return { text: '', status: 'error', error: parsed.error, warnings: parsed.warnings };
184
+ }
185
+ const doc = parsed.doc;
186
+
187
+ const paragraphs = getWordParagraphs(doc);
188
+ if (paragraphs.length === 0) {
189
+ const fallback = normalizeInlineWhitespace(doc.documentElement?.textContent || '');
190
+ return { text: fallback, status: 'ok', warnings: parsed.warnings };
191
+ }
192
+
193
+ const lines = paragraphs.map(paragraphToPlainText);
194
+ return { text: lines.join('\n\n').trim(), status: 'ok', warnings: parsed.warnings };
195
+ }
196
+
197
+ /**
198
+ * Ingests Word OOXML and returns basic markdown.
199
+ *
200
+ * @param {string} ooxml
201
+ * @returns {string}
202
+ */
203
+ export function ingestWordOoxmlToMarkdown(ooxml) {
204
+ return ingestWordOoxmlToMarkdownResult(ooxml).text;
205
+ }
206
+
207
+ /**
208
+ * Result-returning markdown ingestion counterpart.
209
+ *
210
+ * @param {unknown} ooxml
211
+ * @returns {{ text: string, status: 'ok'|'error', error?: {code:string,message:string}, warnings?: string[] }}
212
+ */
213
+ export function ingestWordOoxmlToMarkdownResult(ooxml) {
214
+ const parsed = parseWordOoxml(ooxml);
215
+ if (!parsed.doc) {
216
+ return { text: '', status: 'error', error: parsed.error, warnings: parsed.warnings };
217
+ }
218
+ const doc = parsed.doc;
219
+
220
+ const paragraphs = getWordParagraphs(doc);
221
+ if (paragraphs.length === 0) {
222
+ return { text: '', status: 'ok', warnings: parsed.warnings };
223
+ }
224
+
225
+ const lines = paragraphs.map(paragraphToMarkdown);
226
+ return { text: lines.join('\n\n').trim(), status: 'ok', warnings: parsed.warnings };
227
+ }