@ansonlai/docx-redline-js 0.8.3 → 0.9.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 (71) hide show
  1. package/ARCHITECTURE.md +221 -12
  2. package/CHANGELOG.md +803 -586
  3. package/README.md +1018 -901
  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/dist/docx-redline-js.esm.js +4551 -967
  9. package/dist/docx-redline-js.esm.js.map +4 -4
  10. package/dist/docx-redline-js.esm.min.js +112 -106
  11. package/dist/docx-redline-js.esm.min.js.map +4 -4
  12. package/dist/docx-redline.bundle.cjs +4690 -1106
  13. package/dist/docx-redline.bundle.cjs.map +4 -4
  14. package/dist/docx-redline.bundle.js +4690 -1106
  15. package/dist/docx-redline.bundle.js.map +4 -4
  16. package/docs/AGENT_FAST_START.md +2 -0
  17. package/docs/AGENT_KNOWLEDGE_BASE.md +23 -4
  18. package/docs/SKILL_AUTHORING.md +165 -150
  19. package/docs/TESTING.md +1142 -1059
  20. package/docs/schemas/document-operations.schema.json +221 -168
  21. package/document/docx-document.js +1022 -1010
  22. package/engine/oxml-engine.js +567 -560
  23. package/engine/paragraph-mark-revisions.js +292 -0
  24. package/engine/reconstruction-mapper.js +312 -311
  25. package/engine/reconstruction-mode.js +183 -183
  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 +51 -1
  32. package/index.js +47 -3
  33. package/node/cli-help.js +1 -0
  34. package/node/cli.js +992 -974
  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/pipeline.js +206 -204
  42. package/pipeline/serialization.js +111 -94
  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 +26 -0
  52. package/services/capture-engine.js +228 -226
  53. package/services/document-operation-applier.js +215 -0
  54. package/services/document-operation-contract.js +628 -537
  55. package/services/document-operation-mutations.js +789 -46
  56. package/services/error-recovery.js +17 -0
  57. package/services/list-conversion-diagnostics.js +296 -0
  58. package/services/list-format-change.js +586 -0
  59. package/services/list-format-mutation.js +416 -0
  60. package/services/list-format.js +305 -0
  61. package/services/numbering-allocation.js +642 -0
  62. package/services/numbering-helpers.js +354 -524
  63. package/services/operation-batch-compiler.js +612 -436
  64. package/services/operation-heuristics.js +197 -173
  65. package/services/operation-preflight.js +1 -0
  66. package/services/receipt-collector.js +288 -288
  67. package/services/revision-comment-management.js +634 -594
  68. package/services/revision-fidelity-verifier.js +498 -0
  69. package/services/standalone-docx-plumbing.js +627 -631
  70. package/services/standalone-operation-runner.d.ts +606 -427
  71. package/services/table-reconciliation.js +103 -49
@@ -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
+ }