@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
package/pipeline/diff-engine.js
CHANGED
|
@@ -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
|
|
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 {
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (doc.documentElement
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
return parsed;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (
|
|
97
|
-
if (
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
if (!
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
+
}
|