@ansonlai/docx-redline-js 0.8.2 → 0.8.4

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 (33) hide show
  1. package/CHANGELOG.md +75 -1
  2. package/README.md +28 -13
  3. package/core/list-targeting.js +12 -10
  4. package/dist/docx-redline-js.esm.js +837 -549
  5. package/dist/docx-redline-js.esm.js.map +4 -4
  6. package/dist/docx-redline-js.esm.min.js +93 -90
  7. package/dist/docx-redline-js.esm.min.js.map +4 -4
  8. package/dist/docx-redline.bundle.cjs +837 -549
  9. package/dist/docx-redline.bundle.cjs.map +4 -4
  10. package/dist/docx-redline.bundle.js +837 -549
  11. package/dist/docx-redline.bundle.js.map +4 -4
  12. package/docs/AGENT_KNOWLEDGE_BASE.md +4 -1
  13. package/docs/schemas/document-operations.schema.json +168 -168
  14. package/engine/oxml-engine.js +215 -195
  15. package/engine/reconstruction-mode.js +183 -183
  16. package/index.d.ts +4 -0
  17. package/index.js +36 -1
  18. package/node/cli.js +2 -1
  19. package/orchestration/list-structural-fallback.js +530 -518
  20. package/package.json +1 -1
  21. package/pipeline/list-generation.js +203 -168
  22. package/pipeline/serialization.js +111 -94
  23. package/services/batch-operation-orchestrator.js +6 -0
  24. package/services/document-inspection.js +10 -7
  25. package/services/document-operation-applier.js +2 -0
  26. package/services/document-operation-contract.js +553 -537
  27. package/services/document-operation-mutations.js +1140 -1011
  28. package/services/numbering-helpers.js +108 -0
  29. package/services/operation-batch-compiler.js +440 -436
  30. package/services/operation-heuristics.js +197 -173
  31. package/services/revision-comment-management.js +601 -594
  32. package/services/standalone-operation-runner.d.ts +429 -427
  33. package/services/table-reconciliation.js +48 -46
@@ -1,173 +1,197 @@
1
- import {
2
- getParagraphText,
3
- normalizeWhitespaceForTargeting
4
- } from '../core/paragraph-targeting.js';
5
- import {
6
- getParagraphListInfo,
7
- stripRedundantLeadingListMarkers
8
- } from '../core/list-targeting.js';
9
-
10
- const LIST_LINE_REGEX = /^(\s*)((?:\d+(?:\.\d+)*\.?|\((?:\d+|[a-zA-Z]|[ivxlcIVXLC]+)\)|[a-zA-Z]\.|[ivxlcIVXLC]+\.|[-*+\u2022]))\s+(.*)$/;
11
- const INLINE_LIST_MARKER_REGEX = /(?:^|\s)(?:\d+(?:\.\d+)*\.?|[A-Za-z]\.|[ivxlcIVXLC]+\.)\s+/g;
12
-
13
- function parseOutlineLevelFromMarker(marker) {
14
- const normalized = String(marker || '').trim();
15
- if (!/^\d+(?:\.\d+)+\.?$/.test(normalized)) return null;
16
- const parts = normalized.replace(/\.$/, '').split('.');
17
- return Math.max(0, parts.length - 1);
18
- }
19
-
20
- function parseModifiedListLines(modifiedText) {
21
- const lines = String(modifiedText || '')
22
- .split(/\r?\n/g)
23
- .map(line => line.trimEnd())
24
- .filter(line => line.trim().length > 0);
25
- if (lines.length < 2) return null;
26
-
27
- const parsed = [];
28
- for (const line of lines) {
29
- const markerMatch = line.match(LIST_LINE_REGEX);
30
- if (!markerMatch) return null;
31
- const marker = markerMatch[2];
32
- const markerType = /^[-*+\u2022]$/.test(marker) ? 'bullet' : 'numbered';
33
- parsed.push({
34
- marker,
35
- markerType,
36
- level: Math.floor((markerMatch[1] || '').length / 2),
37
- outlineLevel: markerType === 'numbered' ? parseOutlineLevelFromMarker(marker) : null,
38
- text: stripRedundantLeadingListMarkers(markerMatch[3])
39
- });
40
- }
41
- return parsed.length >= 2 ? parsed : null;
42
- }
43
-
44
- export function buildExplicitRangeInsertionEntries(explicitRangeParagraphs, modifiedText) {
45
- if (!Array.isArray(explicitRangeParagraphs) || explicitRangeParagraphs.length === 0) return null;
46
- const parsedLines = parseModifiedListLines(modifiedText);
47
- if (!parsedLines) return null;
48
-
49
- const originalTexts = explicitRangeParagraphs.map(paragraph =>
50
- normalizeWhitespaceForTargeting(getParagraphText(paragraph))
51
- );
52
- const modifiedTexts = parsedLines.map(item => normalizeWhitespaceForTargeting(item.text));
53
- if (originalTexts.some(text => !text) || modifiedTexts.some(text => !text)) return null;
54
-
55
- const listInfos = explicitRangeParagraphs.map(paragraph => getParagraphListInfo(paragraph));
56
- if (listInfos.some(info => !info || !info.numId)) return null;
57
- const baselineNumId = String(listInfos[0].numId);
58
- if (listInfos.some(info => String(info.numId) !== baselineNumId)) return null;
59
-
60
- const matchedPairs = [];
61
- let originalIndex = 0;
62
- for (let modifiedIndex = 0; modifiedIndex < modifiedTexts.length && originalIndex < originalTexts.length; modifiedIndex += 1) {
63
- if (modifiedTexts[modifiedIndex] === originalTexts[originalIndex]) {
64
- matchedPairs.push({ originalIndex, modifiedIndex });
65
- originalIndex += 1;
66
- }
67
- }
68
- if (originalIndex !== originalTexts.length) return null;
69
-
70
- const matchedModifiedIndexes = new Set(matchedPairs.map(pair => pair.modifiedIndex));
71
- const insertedIndexes = [];
72
- for (let idx = 0; idx < parsedLines.length; idx += 1) {
73
- if (!matchedModifiedIndexes.has(idx)) insertedIndexes.push(idx);
74
- }
75
- if (insertedIndexes.length === 0) return null;
76
-
77
- const baseIndentLevel = parsedLines[0]?.level || 0;
78
- return insertedIndexes.map(modifiedIndex => {
79
- const nextMatch = matchedPairs.find(pair => pair.modifiedIndex > modifiedIndex) || null;
80
- const prevMatch = [...matchedPairs].reverse().find(pair => pair.modifiedIndex < modifiedIndex) || null;
81
- const referenceMatch = nextMatch || prevMatch;
82
- if (!referenceMatch) return null;
83
- const referenceListInfo = listInfos[referenceMatch.originalIndex] || listInfos[0];
84
- if (!referenceListInfo) return null;
85
-
86
- const entry = parsedLines[modifiedIndex];
87
- const relativeLevel = Math.max(0, entry.level - baseIndentLevel);
88
- const explicitOutlineLevel = Number.isInteger(entry.outlineLevel) ? entry.outlineLevel : null;
89
- return {
90
- text: entry.text,
91
- markerType: entry.markerType,
92
- ilvl: explicitOutlineLevel != null
93
- ? explicitOutlineLevel
94
- : Math.max(0, (referenceListInfo.ilvl || 0) + relativeLevel),
95
- numId: String(referenceListInfo.numId),
96
- insertBeforeOriginalIndex: nextMatch ? nextMatch.originalIndex : null
97
- };
98
- }).filter(Boolean);
99
- }
100
-
101
- function countWords(text) {
102
- return String(text || '').trim().split(/\s+/).filter(Boolean).length;
103
- }
104
-
105
- function hasMultipleInlineListMarkers(text) {
106
- const source = String(text || '');
107
- if (!source) return false;
108
- let count = 0;
109
- const regex = new RegExp(INLINE_LIST_MARKER_REGEX.source, INLINE_LIST_MARKER_REGEX.flags);
110
- while (regex.exec(source)) {
111
- count += 1;
112
- if (count >= 2) return true;
113
- }
114
- return false;
115
- }
116
-
117
- export function deriveSingleParagraphListAdjacencyInsertion(currentParagraphText, modifiedText) {
118
- const rawCurrent = String(currentParagraphText || '').trim();
119
- const rawModified = String(modifiedText || '').trim();
120
- if (!rawCurrent || !rawModified || rawModified === rawCurrent || rawModified.includes('\n')) return null;
121
- const normalizedCurrent = normalizeWhitespaceForTargeting(rawCurrent);
122
- const sanitizeCandidate = text => stripRedundantLeadingListMarkers(String(text || '').trim()).trim();
123
- const buildCandidate = (position, text) => {
124
- const cleanedText = sanitizeCandidate(text);
125
- const cleanedNormalized = normalizeWhitespaceForTargeting(cleanedText);
126
- if (!cleanedText || cleanedText === rawCurrent || countWords(cleanedText) < 6) return null;
127
- if (hasMultipleInlineListMarkers(cleanedText)) return null;
128
- if (normalizedCurrent && cleanedNormalized.includes(normalizedCurrent)) return null;
129
- return { position, text: cleanedText };
130
- };
131
-
132
- if (rawModified.endsWith(rawCurrent)) {
133
- const candidate = buildCandidate('before', rawModified.slice(0, -rawCurrent.length));
134
- if (candidate) return candidate;
135
- }
136
- if (rawModified.startsWith(rawCurrent)) {
137
- const candidate = buildCandidate('after', rawModified.slice(rawCurrent.length));
138
- if (candidate) return candidate;
139
- }
140
-
141
- const normalizedModified = normalizeWhitespaceForTargeting(rawModified);
142
- if (!normalizedCurrent || normalizedCurrent === normalizedModified) return null;
143
- if (normalizedModified.endsWith(normalizedCurrent)) {
144
- const candidate = buildCandidate('before', normalizedModified.slice(0, -normalizedCurrent.length));
145
- if (candidate) return candidate;
146
- }
147
- if (normalizedModified.startsWith(normalizedCurrent)) {
148
- const candidate = buildCandidate('after', normalizedModified.slice(normalizedCurrent.length));
149
- if (candidate) return candidate;
150
- }
151
- return null;
152
- }
153
-
154
- export function deriveSingleParagraphPlainAdjacencyInsertion(currentParagraphText, modifiedText) {
155
- const rawCurrent = String(currentParagraphText || '').trim();
156
- const rawModified = String(modifiedText || '');
157
- if (!rawCurrent || !rawModified || !rawModified.includes('\n')) return null;
158
-
159
- const lines = rawModified.split(/\r?\n/g).map(line => String(line || '').trim()).filter(Boolean);
160
- if (lines.length < 2) return null;
161
- const normalize = value => normalizeWhitespaceForTargeting(String(value || ''));
162
- const normalizedCurrent = normalize(rawCurrent);
163
-
164
- if (normalize(lines[lines.length - 1]) === normalizedCurrent) {
165
- const paragraphs = lines.slice(0, -1).filter(Boolean);
166
- if (paragraphs.length > 0) return { position: 'before', paragraphs };
167
- }
168
- if (normalize(lines[0]) === normalizedCurrent) {
169
- const paragraphs = lines.slice(1).filter(Boolean);
170
- if (paragraphs.length > 0) return { position: 'after', paragraphs };
171
- }
172
- return null;
173
- }
1
+ import {
2
+ getParagraphText,
3
+ normalizeWhitespaceForTargeting
4
+ } from '../core/paragraph-targeting.js';
5
+ import {
6
+ getParagraphListInfo,
7
+ stripRedundantLeadingListMarkers
8
+ } from '../core/list-targeting.js';
9
+
10
+ const LIST_LINE_REGEX = /^(\s*)((?:\d+(?:\.\d+)*\.?|\((?:\d+|[a-zA-Z]|[ivxlcIVXLC]+)\)|[a-zA-Z]\.|[ivxlcIVXLC]+\.|[-*+\u2022]))\s+(.*)$/;
11
+ const INLINE_LIST_MARKER_REGEX = /(?:^|\s)(?:\d+(?:\.\d+)*\.?|[A-Za-z]\.|[ivxlcIVXLC]+\.)\s+/g;
12
+
13
+ function parseOutlineLevelFromMarker(marker) {
14
+ const normalized = String(marker || '').trim();
15
+ if (!/^\d+(?:\.\d+)+\.?$/.test(normalized)) return null;
16
+ const parts = normalized.replace(/\.$/, '').split('.');
17
+ return Math.max(0, parts.length - 1);
18
+ }
19
+
20
+ function parseModifiedListLines(modifiedText) {
21
+ const lines = String(modifiedText || '')
22
+ .split(/\r?\n/g)
23
+ .map(line => line.trimEnd())
24
+ .filter(line => line.trim().length > 0);
25
+ if (lines.length < 2) return null;
26
+
27
+ const parsed = [];
28
+ for (const line of lines) {
29
+ const markerMatch = line.match(LIST_LINE_REGEX);
30
+ if (!markerMatch) return null;
31
+ const marker = markerMatch[2];
32
+ const markerType = /^[-*+\u2022]$/.test(marker) ? 'bullet' : 'numbered';
33
+ parsed.push({
34
+ marker,
35
+ markerType,
36
+ level: Math.floor((markerMatch[1] || '').length / 2),
37
+ outlineLevel: markerType === 'numbered' ? parseOutlineLevelFromMarker(marker) : null,
38
+ text: stripRedundantLeadingListMarkers(markerMatch[3])
39
+ });
40
+ }
41
+ return parsed.length >= 2 ? parsed : null;
42
+ }
43
+
44
+ export function buildExplicitRangeInsertionEntries(explicitRangeParagraphs, modifiedText) {
45
+ if (!Array.isArray(explicitRangeParagraphs) || explicitRangeParagraphs.length === 0) return null;
46
+ const parsedLines = parseModifiedListLines(modifiedText);
47
+ if (!parsedLines) return null;
48
+
49
+ const originalTexts = explicitRangeParagraphs.map(paragraph =>
50
+ normalizeWhitespaceForTargeting(getParagraphText(paragraph))
51
+ );
52
+ const modifiedTexts = parsedLines.map(item => normalizeWhitespaceForTargeting(item.text));
53
+ if (originalTexts.some(text => !text) || modifiedTexts.some(text => !text)) return null;
54
+
55
+ const listInfos = explicitRangeParagraphs.map(paragraph => getParagraphListInfo(paragraph));
56
+ if (listInfos.some(info => !info || !info.numId)) return null;
57
+ const baselineNumId = String(listInfos[0].numId);
58
+ if (listInfos.some(info => String(info.numId) !== baselineNumId)) return null;
59
+
60
+ const matchedPairs = [];
61
+ let originalIndex = 0;
62
+ for (let modifiedIndex = 0; modifiedIndex < modifiedTexts.length && originalIndex < originalTexts.length; modifiedIndex += 1) {
63
+ if (modifiedTexts[modifiedIndex] === originalTexts[originalIndex]) {
64
+ matchedPairs.push({ originalIndex, modifiedIndex });
65
+ originalIndex += 1;
66
+ }
67
+ }
68
+ if (originalIndex !== originalTexts.length) return null;
69
+
70
+ const matchedModifiedIndexes = new Set(matchedPairs.map(pair => pair.modifiedIndex));
71
+ const insertedIndexes = [];
72
+ for (let idx = 0; idx < parsedLines.length; idx += 1) {
73
+ if (!matchedModifiedIndexes.has(idx)) insertedIndexes.push(idx);
74
+ }
75
+ if (insertedIndexes.length === 0) return null;
76
+
77
+ const baseIndentLevel = parsedLines[0]?.level || 0;
78
+ return insertedIndexes.map(modifiedIndex => {
79
+ const nextMatch = matchedPairs.find(pair => pair.modifiedIndex > modifiedIndex) || null;
80
+ const prevMatch = [...matchedPairs].reverse().find(pair => pair.modifiedIndex < modifiedIndex) || null;
81
+ const referenceMatch = nextMatch || prevMatch;
82
+ if (!referenceMatch) return null;
83
+ const referenceListInfo = listInfos[referenceMatch.originalIndex] || listInfos[0];
84
+ if (!referenceListInfo) return null;
85
+
86
+ const entry = parsedLines[modifiedIndex];
87
+ const relativeLevel = Math.max(0, entry.level - baseIndentLevel);
88
+ const explicitOutlineLevel = Number.isInteger(entry.outlineLevel) ? entry.outlineLevel : null;
89
+ return {
90
+ text: entry.text,
91
+ markerType: entry.markerType,
92
+ ilvl: explicitOutlineLevel != null
93
+ ? explicitOutlineLevel
94
+ : Math.max(0, (referenceListInfo.ilvl || 0) + relativeLevel),
95
+ numId: String(referenceListInfo.numId),
96
+ insertBeforeOriginalIndex: nextMatch ? nextMatch.originalIndex : null
97
+ };
98
+ }).filter(Boolean);
99
+ }
100
+
101
+ function countWords(text) {
102
+ return String(text || '').trim().split(/\s+/).filter(Boolean).length;
103
+ }
104
+
105
+ function hasMultipleInlineListMarkers(text) {
106
+ const source = String(text || '');
107
+ if (!source) return false;
108
+ let count = 0;
109
+ const regex = new RegExp(INLINE_LIST_MARKER_REGEX.source, INLINE_LIST_MARKER_REGEX.flags);
110
+ while (regex.exec(source)) {
111
+ count += 1;
112
+ if (count >= 2) return true;
113
+ }
114
+ return false;
115
+ }
116
+
117
+ export function deriveSingleParagraphListAdjacencyInsertion(currentParagraphText, modifiedText) {
118
+ const rawCurrent = String(currentParagraphText || '').trim();
119
+ const rawModified = String(modifiedText || '').trim();
120
+ if (!rawCurrent || !rawModified || rawModified === rawCurrent || rawModified.includes('\n')) return null;
121
+ const normalizedCurrent = normalizeWhitespaceForTargeting(rawCurrent);
122
+ const sanitizeCandidate = text => stripRedundantLeadingListMarkers(String(text || '').trim()).trim();
123
+ const buildCandidate = (position, text) => {
124
+ const cleanedText = sanitizeCandidate(text);
125
+ const cleanedNormalized = normalizeWhitespaceForTargeting(cleanedText);
126
+ if (!cleanedText || cleanedText === rawCurrent || countWords(cleanedText) < 6) return null;
127
+ if (hasMultipleInlineListMarkers(cleanedText)) return null;
128
+ if (normalizedCurrent && cleanedNormalized.includes(normalizedCurrent)) return null;
129
+ return { position, text: cleanedText };
130
+ };
131
+
132
+ if (rawModified.endsWith(rawCurrent)) {
133
+ const candidate = buildCandidate('before', rawModified.slice(0, -rawCurrent.length));
134
+ if (candidate) return candidate;
135
+ }
136
+ if (rawModified.startsWith(rawCurrent)) {
137
+ const candidate = buildCandidate('after', rawModified.slice(rawCurrent.length));
138
+ if (candidate) return candidate;
139
+ }
140
+
141
+ const normalizedModified = normalizeWhitespaceForTargeting(rawModified);
142
+ if (!normalizedCurrent || normalizedCurrent === normalizedModified) return null;
143
+ if (normalizedModified.endsWith(normalizedCurrent)) {
144
+ const candidate = buildCandidate('before', normalizedModified.slice(0, -normalizedCurrent.length));
145
+ if (candidate) return candidate;
146
+ }
147
+ if (normalizedModified.startsWith(normalizedCurrent)) {
148
+ const candidate = buildCandidate('after', normalizedModified.slice(normalizedCurrent.length));
149
+ if (candidate) return candidate;
150
+ }
151
+ return null;
152
+ }
153
+
154
+ export function deriveSingleParagraphPlainAdjacencyInsertion(currentParagraphText, modifiedText) {
155
+ const rawCurrent = String(currentParagraphText || '').trim();
156
+ const rawModified = String(modifiedText || '');
157
+ if (!rawCurrent || !rawModified || !rawModified.includes('\n')) return null;
158
+
159
+ const lines = rawModified.split(/\r?\n/g).map(line => String(line || '').trim()).filter(Boolean);
160
+ if (lines.length < 2) return null;
161
+ const normalize = value => normalizeWhitespaceForTargeting(String(value || ''));
162
+ const normalizedCurrent = normalize(rawCurrent);
163
+
164
+ if (normalize(lines[lines.length - 1]) === normalizedCurrent) {
165
+ const paragraphs = lines.slice(0, -1).filter(Boolean);
166
+ if (paragraphs.length > 0) return { position: 'before', paragraphs };
167
+ }
168
+ if (normalize(lines[0]) === normalizedCurrent) {
169
+ const paragraphs = lines.slice(1).filter(Boolean);
170
+ if (paragraphs.length > 0) return { position: 'after', paragraphs };
171
+ }
172
+ return null;
173
+ }
174
+
175
+ /**
176
+ * Detects "retained paragraph followed by one Markdown table": a single leading
177
+ * paragraph line (inline Markdown allowed) then a contiguous pipe table with a
178
+ * separator row. The caller verifies that the paragraph line's plain text equals
179
+ * the target paragraph.
180
+ *
181
+ * @param {string} modifiedText - Requested replacement text
182
+ * @returns {{ paragraphMarkdown: string, tableText: string }|null}
183
+ */
184
+ export function deriveParagraphThenTableAppend(modifiedText) {
185
+ const lines = String(modifiedText || '')
186
+ .split(/\r?\n/g)
187
+ .map(line => line.trimEnd())
188
+ .filter(line => line.trim().length > 0);
189
+ if (lines.length < 3) return null;
190
+ const tableStart = lines.findIndex(line => line.trim().startsWith('|'));
191
+ if (tableStart !== 1) return null;
192
+ const tableLines = lines.slice(1);
193
+ if (!tableLines.every(line => line.trim().startsWith('|'))) return null;
194
+ const separator = tableLines[1].replace(/\s+/g, '');
195
+ if (!/^\|:?-{3,}:?(\|:?-{3,}:?)+\|?$/.test(separator)) return null;
196
+ return { paragraphMarkdown: lines[0].trim(), tableText: tableLines.join('\n') };
197
+ }