@ansonlai/docx-redline-js 0.1.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/AGENTS.md +176 -0
- package/ARCHITECTURE.md +121 -0
- package/LICENSE +21 -0
- package/README.md +177 -0
- package/adapters/config.js +43 -0
- package/adapters/logger.js +89 -0
- package/adapters/xml-adapter.js +74 -0
- package/core/list-targeting.js +398 -0
- package/core/ooxml-identifiers.js +15 -0
- package/core/paragraph-offset-policy.js +50 -0
- package/core/paragraph-targeting.js +501 -0
- package/core/table-targeting.js +233 -0
- package/core/types.js +204 -0
- package/core/xml-query.js +99 -0
- package/dist/docx-redline-js.esm.js +8801 -0
- package/dist/docx-redline-js.esm.js.map +7 -0
- package/dist/docx-redline-js.esm.min.js +195 -0
- package/dist/docx-redline-js.esm.min.js.map +7 -0
- package/engine/format-application.js +358 -0
- package/engine/format-extraction.js +232 -0
- package/engine/format-paragraph-targeting.js +208 -0
- package/engine/format-span-application.js +178 -0
- package/engine/formatting-removal.js +330 -0
- package/engine/oxml-engine.js +279 -0
- package/engine/reconstruction-mapper.js +270 -0
- package/engine/reconstruction-mode.js +38 -0
- package/engine/reconstruction-writer.js +276 -0
- package/engine/rpr-helpers.js +194 -0
- package/engine/run-builders.js +235 -0
- package/engine/surgical-mode.js +520 -0
- package/engine/table-cell-context.js +151 -0
- package/engine/table-mode.js +172 -0
- package/index.js +308 -0
- package/orchestration/list-markdown.js +141 -0
- package/orchestration/list-parsing.js +73 -0
- package/orchestration/list-structural-fallback.js +530 -0
- package/orchestration/redline-operation-converter.js +141 -0
- package/orchestration/route-plan.js +160 -0
- package/package.json +76 -0
- package/pipeline/content-analysis.js +107 -0
- package/pipeline/diff-engine.js +204 -0
- package/pipeline/ingestion-export.js +255 -0
- package/pipeline/ingestion-paragraph.js +351 -0
- package/pipeline/ingestion-table.js +169 -0
- package/pipeline/ingestion-xml.js +39 -0
- package/pipeline/ingestion.js +8 -0
- package/pipeline/list-generation.js +280 -0
- package/pipeline/list-markers.js +77 -0
- package/pipeline/markdown-processor.js +160 -0
- package/pipeline/patching.js +408 -0
- package/pipeline/pipeline.js +326 -0
- package/pipeline/serialization.js +395 -0
- package/services/browser-demo-prompt-context.js +345 -0
- package/services/comment-builders.js +60 -0
- package/services/comment-engine.js +248 -0
- package/services/comment-locator.js +197 -0
- package/services/comment-package.js +113 -0
- package/services/numbering-helpers.js +416 -0
- package/services/numbering-service.js +290 -0
- package/services/package-builder.js +147 -0
- package/services/standalone-docx-plumbing.js +443 -0
- package/services/standalone-operation-runner.js +1169 -0
- package/services/table-reconciliation.js +344 -0
- package/standalone.js +5 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds prompt sections for browser-demo paragraph context.
|
|
3
|
+
*
|
|
4
|
+
* Plain lines are always emitted as `[P#] ...`.
|
|
5
|
+
* Formatting lines are emitted as `[P#_FMT] ...` only when markdown projection
|
|
6
|
+
* differs from plain text.
|
|
7
|
+
*
|
|
8
|
+
* @param {Array<{ index?: number, text?: string, formattedText?: string }>} paragraphs
|
|
9
|
+
* @returns {{ plainListing: string, formattingListing: string }}
|
|
10
|
+
*/
|
|
11
|
+
export function buildPromptParagraphSections(paragraphs) {
|
|
12
|
+
const items = Array.isArray(paragraphs) ? paragraphs : [];
|
|
13
|
+
const plainLines = [];
|
|
14
|
+
const formattingLines = [];
|
|
15
|
+
|
|
16
|
+
for (let i = 0; i < items.length; i += 1) {
|
|
17
|
+
const paragraph = items[i] || {};
|
|
18
|
+
const index = Number.isInteger(paragraph.index) && paragraph.index > 0 ? paragraph.index : (i + 1);
|
|
19
|
+
const text = String(paragraph.text || '').trim();
|
|
20
|
+
if (!text) continue;
|
|
21
|
+
|
|
22
|
+
const formattedText = String(paragraph.formattedText || text).trim() || text;
|
|
23
|
+
plainLines.push(`[P${index}] ${text}`);
|
|
24
|
+
if (formattedText !== text) {
|
|
25
|
+
formattingLines.push(`[P${index}_FMT] ${formattedText}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
plainListing: plainLines.join('\n'),
|
|
31
|
+
formattingListing: formattingLines.join('\n')
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeWhitespace(value) {
|
|
36
|
+
return String(value || '').replace(/\s+/g, ' ').trim();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function truncateForLog(value, maxChars = 140) {
|
|
40
|
+
const text = normalizeWhitespace(value);
|
|
41
|
+
if (text.length <= maxChars) return text;
|
|
42
|
+
return `${text.slice(0, maxChars - 1)}…`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function extractQueryPhrases(userMessage) {
|
|
46
|
+
const message = String(userMessage || '');
|
|
47
|
+
const queries = [];
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
const addQuery = (raw) => {
|
|
50
|
+
const normalized = normalizeWhitespace(raw);
|
|
51
|
+
if (!normalized || normalized.length < 2) return;
|
|
52
|
+
const key = normalized.toLowerCase();
|
|
53
|
+
if (seen.has(key)) return;
|
|
54
|
+
seen.add(key);
|
|
55
|
+
queries.push(normalized);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const quotedRegex = /"([^"]+)"|'([^']+)'/g;
|
|
59
|
+
let quotedMatch = quotedRegex.exec(message);
|
|
60
|
+
while (quotedMatch) {
|
|
61
|
+
addQuery(quotedMatch[1] || quotedMatch[2]);
|
|
62
|
+
quotedMatch = quotedRegex.exec(message);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const capitalizedPhraseRegex = /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b/g;
|
|
66
|
+
let capMatch = capitalizedPhraseRegex.exec(message);
|
|
67
|
+
while (capMatch) {
|
|
68
|
+
addQuery(capMatch[1]);
|
|
69
|
+
capMatch = capitalizedPhraseRegex.exec(message);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return queries.slice(0, 8);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function extractQuotedPhrases(text) {
|
|
76
|
+
const message = String(text || '');
|
|
77
|
+
const phrases = [];
|
|
78
|
+
const seen = new Set();
|
|
79
|
+
const add = (raw) => {
|
|
80
|
+
const normalized = normalizeWhitespace(raw);
|
|
81
|
+
if (!normalized || normalized.length < 2) return;
|
|
82
|
+
const key = normalized.toLowerCase();
|
|
83
|
+
if (seen.has(key)) return;
|
|
84
|
+
seen.add(key);
|
|
85
|
+
phrases.push(normalized);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const regexes = [
|
|
89
|
+
/"([^"]+)"/g,
|
|
90
|
+
/'([^']+)'/g,
|
|
91
|
+
/“([^”]+)”/g,
|
|
92
|
+
/‘([^’]+)’/g
|
|
93
|
+
];
|
|
94
|
+
for (const pattern of regexes) {
|
|
95
|
+
let match = pattern.exec(message);
|
|
96
|
+
while (match) {
|
|
97
|
+
add(match[1]);
|
|
98
|
+
match = pattern.exec(message);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return phrases;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const MESSAGE_STOP_WORDS = new Set([
|
|
106
|
+
'a', 'an', 'and', 'as', 'at', 'be', 'by', 'for', 'from', 'in', 'into', 'is', 'it', 'of', 'on', 'or', 'the',
|
|
107
|
+
'to', 'with', 'please', 'can', 'could', 'would', 'should', 'this', 'that', 'there', 'here',
|
|
108
|
+
'section', 'paragraph', 'clause', 'line',
|
|
109
|
+
'bold', 'unbold', 'rebold', 'remove', 'make', 'set', 'change', 'format', 'formatting', 'text'
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
function inferQueriesFromParagraphCorpus(paragraphs, userMessage, maxQueries = 8) {
|
|
113
|
+
const corpus = [];
|
|
114
|
+
for (const paragraph of paragraphs || []) {
|
|
115
|
+
const plain = normalizeWhitespace(paragraph?.text).toLowerCase();
|
|
116
|
+
const formatted = normalizeWhitespace(paragraph?.formattedText || paragraph?.text).toLowerCase();
|
|
117
|
+
if (plain) corpus.push(plain);
|
|
118
|
+
if (formatted && formatted !== plain) corpus.push(formatted);
|
|
119
|
+
}
|
|
120
|
+
if (corpus.length === 0) return [];
|
|
121
|
+
|
|
122
|
+
const messageTokens = (String(userMessage || '').toLowerCase().match(/[a-z0-9']+/g) || [])
|
|
123
|
+
.filter(token => token.length > 1)
|
|
124
|
+
.filter(token => !MESSAGE_STOP_WORDS.has(token));
|
|
125
|
+
if (messageTokens.length === 0) return [];
|
|
126
|
+
|
|
127
|
+
const candidates = [];
|
|
128
|
+
const seen = new Set();
|
|
129
|
+
const maxN = Math.min(5, messageTokens.length);
|
|
130
|
+
for (let size = maxN; size >= 2; size -= 1) {
|
|
131
|
+
for (let i = 0; i <= messageTokens.length - size; i += 1) {
|
|
132
|
+
const phrase = messageTokens.slice(i, i + size).join(' ');
|
|
133
|
+
if (seen.has(phrase)) continue;
|
|
134
|
+
seen.add(phrase);
|
|
135
|
+
if (corpus.some(line => line.includes(phrase))) {
|
|
136
|
+
candidates.push(phrase);
|
|
137
|
+
if (candidates.length >= maxQueries) return candidates;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (candidates.length > 0) return candidates;
|
|
143
|
+
|
|
144
|
+
// Last resort: allow single-token phrase when it is distinctive and present.
|
|
145
|
+
for (const token of messageTokens) {
|
|
146
|
+
if (seen.has(token)) continue;
|
|
147
|
+
seen.add(token);
|
|
148
|
+
if (corpus.some(line => line.includes(token))) {
|
|
149
|
+
candidates.push(token);
|
|
150
|
+
if (candidates.length >= maxQueries) break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return candidates;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Builds turn-level diagnostics for formatting-aware targeting.
|
|
159
|
+
*
|
|
160
|
+
* Uses quoted phrases and capitalized phrases from the user message to find
|
|
161
|
+
* paragraphs where plain/formatting snapshots match.
|
|
162
|
+
*
|
|
163
|
+
* @param {Array<{ index?: number, text?: string, formattedText?: string }>} paragraphs
|
|
164
|
+
* @param {string} userMessage
|
|
165
|
+
* @param {{ maxMatches?: number }} [options]
|
|
166
|
+
* @returns {{
|
|
167
|
+
* queries: string[],
|
|
168
|
+
* matches: Array<{ query: string, index: number, differs: boolean, text: string, formattedText: string }>,
|
|
169
|
+
* logLines: string[]
|
|
170
|
+
* }}
|
|
171
|
+
*/
|
|
172
|
+
export function buildFormattingDiagnostics(paragraphs, userMessage, options = {}) {
|
|
173
|
+
const maxMatches = Number.isInteger(options?.maxMatches) && options.maxMatches > 0
|
|
174
|
+
? options.maxMatches
|
|
175
|
+
: 12;
|
|
176
|
+
const items = Array.isArray(paragraphs) ? paragraphs : [];
|
|
177
|
+
const queries = extractQueryPhrases(userMessage);
|
|
178
|
+
if (queries.length === 0) {
|
|
179
|
+
queries.push(...inferQueriesFromParagraphCorpus(items, userMessage, 8));
|
|
180
|
+
}
|
|
181
|
+
const matches = [];
|
|
182
|
+
const logLines = [];
|
|
183
|
+
|
|
184
|
+
if (queries.length === 0) {
|
|
185
|
+
return {
|
|
186
|
+
queries,
|
|
187
|
+
matches,
|
|
188
|
+
logLines: ['No candidate quoted/capitalized query phrases detected in user message.']
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for (const query of queries) {
|
|
193
|
+
const queryLower = query.toLowerCase();
|
|
194
|
+
for (let i = 0; i < items.length; i += 1) {
|
|
195
|
+
if (matches.length >= maxMatches) break;
|
|
196
|
+
const paragraph = items[i] || {};
|
|
197
|
+
const index = Number.isInteger(paragraph.index) && paragraph.index > 0 ? paragraph.index : (i + 1);
|
|
198
|
+
const text = normalizeWhitespace(paragraph.text);
|
|
199
|
+
if (!text) continue;
|
|
200
|
+
const formattedText = normalizeWhitespace(paragraph.formattedText || text) || text;
|
|
201
|
+
const plainHas = text.toLowerCase().includes(queryLower);
|
|
202
|
+
const formattedHas = formattedText.toLowerCase().includes(queryLower);
|
|
203
|
+
if (!plainHas && !formattedHas) continue;
|
|
204
|
+
|
|
205
|
+
const differs = formattedText !== text;
|
|
206
|
+
matches.push({ query, index, differs, text, formattedText });
|
|
207
|
+
}
|
|
208
|
+
if (matches.length >= maxMatches) break;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (matches.length === 0) {
|
|
212
|
+
logLines.push(`No paragraph matches found for queries: ${queries.map(q => `"${q}"`).join(', ')}`);
|
|
213
|
+
return { queries, matches, logLines };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for (const match of matches) {
|
|
217
|
+
logLines.push(
|
|
218
|
+
`Query "${match.query}" -> P${match.index} differs=${match.differs}; `
|
|
219
|
+
+ `plain="${truncateForLog(match.text)}"; formatted="${truncateForLog(match.formattedText)}"`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
if (matches.length >= maxMatches) {
|
|
223
|
+
logLines.push(`Match output truncated at ${maxMatches} entries.`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { queries, matches, logLines };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Detects whether a user message is requesting formatting removal/unformat.
|
|
231
|
+
*
|
|
232
|
+
* @param {string} text
|
|
233
|
+
* @returns {boolean}
|
|
234
|
+
*/
|
|
235
|
+
export function isFormattingRemovalIntent(text) {
|
|
236
|
+
const input = String(text || '').toLowerCase();
|
|
237
|
+
if (!input.trim()) return false;
|
|
238
|
+
return /(unbold|unitalic|ununderline|clear formatting|remove formatting|remove .*format|remove .*style|plain text|de-?bold|strip formatting)/i.test(input)
|
|
239
|
+
|| (/\bremove\b/i.test(input) && /\b(bold|italic|underline|highlight|format|formatting|style)\b/i.test(input));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function rankFallbackCandidate(candidate) {
|
|
243
|
+
let score = 0;
|
|
244
|
+
if (candidate.differs) score += 1000;
|
|
245
|
+
if (candidate.source === 'assistant_quote') score += 120;
|
|
246
|
+
if (candidate.source === 'user_quote') score += 100;
|
|
247
|
+
if (candidate.source === 'user_query') score += 80;
|
|
248
|
+
score += Math.min(40, String(candidate.phrase || '').length);
|
|
249
|
+
return score;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Builds a deterministic fallback redline candidate for formatting-removal
|
|
254
|
+
* turns when the model returns zero operations.
|
|
255
|
+
*
|
|
256
|
+
* The candidate uses unchanged plain paragraph text as `modified`, which
|
|
257
|
+
* triggers format-removal behavior in the reconciliation engine.
|
|
258
|
+
*
|
|
259
|
+
* @param {Array<{ index?: number, text?: string, formattedText?: string }>} paragraphs
|
|
260
|
+
* @param {string} userMessage
|
|
261
|
+
* @param {string} [assistantExplanation]
|
|
262
|
+
* @returns {{ type: 'redline', targetRef: number, target: string, modified: string }|null}
|
|
263
|
+
*/
|
|
264
|
+
export function buildFormattingRemovalFallbackCandidate(paragraphs, userMessage, assistantExplanation = '') {
|
|
265
|
+
const items = Array.isArray(paragraphs) ? paragraphs : [];
|
|
266
|
+
if (items.length === 0) return null;
|
|
267
|
+
|
|
268
|
+
const diagnostics = buildFormattingDiagnostics(items, userMessage);
|
|
269
|
+
const phraseSources = [];
|
|
270
|
+
const seen = new Set();
|
|
271
|
+
const pushPhrase = (phrase, source) => {
|
|
272
|
+
const normalized = normalizeWhitespace(phrase);
|
|
273
|
+
if (!normalized) return;
|
|
274
|
+
const key = normalized.toLowerCase();
|
|
275
|
+
if (seen.has(`${source}:${key}`)) return;
|
|
276
|
+
seen.add(`${source}:${key}`);
|
|
277
|
+
phraseSources.push({ phrase: normalized, source });
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
for (const query of diagnostics.queries || []) pushPhrase(query, 'user_query');
|
|
281
|
+
for (const phrase of extractQuotedPhrases(userMessage)) pushPhrase(phrase, 'user_quote');
|
|
282
|
+
for (const phrase of extractQuotedPhrases(assistantExplanation)) pushPhrase(phrase, 'assistant_quote');
|
|
283
|
+
|
|
284
|
+
const candidates = [];
|
|
285
|
+
for (const sourceEntry of phraseSources) {
|
|
286
|
+
const phraseLower = sourceEntry.phrase.toLowerCase();
|
|
287
|
+
for (let i = 0; i < items.length; i += 1) {
|
|
288
|
+
const paragraph = items[i] || {};
|
|
289
|
+
const index = Number.isInteger(paragraph.index) && paragraph.index > 0 ? paragraph.index : (i + 1);
|
|
290
|
+
const text = normalizeWhitespace(paragraph.text);
|
|
291
|
+
if (!text) continue;
|
|
292
|
+
const formattedText = normalizeWhitespace(paragraph.formattedText || text) || text;
|
|
293
|
+
const plainHas = text.toLowerCase().includes(phraseLower);
|
|
294
|
+
const formattedHas = formattedText.toLowerCase().includes(phraseLower);
|
|
295
|
+
if (!plainHas && !formattedHas) continue;
|
|
296
|
+
const differs = formattedText !== text;
|
|
297
|
+
if (!differs) continue;
|
|
298
|
+
|
|
299
|
+
candidates.push({
|
|
300
|
+
phrase: sourceEntry.phrase,
|
|
301
|
+
source: sourceEntry.source,
|
|
302
|
+
index,
|
|
303
|
+
text,
|
|
304
|
+
modified: text,
|
|
305
|
+
differs
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (candidates.length === 0 && Array.isArray(diagnostics.matches) && diagnostics.matches.length > 0) {
|
|
311
|
+
for (const anchor of diagnostics.matches) {
|
|
312
|
+
const anchorIndex = Number.parseInt(anchor.index, 10);
|
|
313
|
+
if (!Number.isInteger(anchorIndex) || anchorIndex < 1) continue;
|
|
314
|
+
const nearbyIndexes = [anchorIndex + 1, anchorIndex - 1, anchorIndex + 2, anchorIndex - 2];
|
|
315
|
+
for (const idx of nearbyIndexes) {
|
|
316
|
+
const paragraph = items.find(item => Number(item?.index) === idx);
|
|
317
|
+
if (!paragraph) continue;
|
|
318
|
+
const text = normalizeWhitespace(paragraph.text);
|
|
319
|
+
if (!text) continue;
|
|
320
|
+
const formattedText = normalizeWhitespace(paragraph.formattedText || text) || text;
|
|
321
|
+
const differs = formattedText !== text;
|
|
322
|
+
if (!differs) continue;
|
|
323
|
+
|
|
324
|
+
candidates.push({
|
|
325
|
+
phrase: `nearby:P${anchorIndex}`,
|
|
326
|
+
source: 'section_proximity',
|
|
327
|
+
index: idx,
|
|
328
|
+
text,
|
|
329
|
+
modified: text,
|
|
330
|
+
differs
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (candidates.length === 0) return null;
|
|
337
|
+
candidates.sort((a, b) => rankFallbackCandidate(b) - rankFallbackCandidate(a));
|
|
338
|
+
const best = candidates[0];
|
|
339
|
+
return {
|
|
340
|
+
type: 'redline',
|
|
341
|
+
targetRef: best.index,
|
|
342
|
+
target: best.text,
|
|
343
|
+
modified: best.modified
|
|
344
|
+
};
|
|
345
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comment XML builders.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { NS_W, escapeXml } from '../core/types.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Builds a single w:comment element.
|
|
9
|
+
*
|
|
10
|
+
* @param {number} commentId - Unique comment ID
|
|
11
|
+
* @param {string} author - Author name
|
|
12
|
+
* @param {string} content - Comment text content
|
|
13
|
+
* @param {string} date - ISO date string
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
export function buildCommentElement(commentId, author, content, date) {
|
|
17
|
+
const initials = author.split(' ').map(word => word[0]).join('').toUpperCase() || 'AI';
|
|
18
|
+
const escapedContent = escapeXml(content);
|
|
19
|
+
const escapedAuthor = escapeXml(author);
|
|
20
|
+
|
|
21
|
+
return `<w:comment w:id="${commentId}" w:author="${escapedAuthor}" w:date="${date}" w:initials="${initials}">
|
|
22
|
+
<w:p>
|
|
23
|
+
<w:r><w:t>${escapedContent}</w:t></w:r>
|
|
24
|
+
</w:p>
|
|
25
|
+
</w:comment>`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Builds the complete comments.xml part.
|
|
30
|
+
*
|
|
31
|
+
* @param {Array<{id:number,content:string,author:string,date:string}>} comments - Placed comments
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
export function buildCommentsPartXml(comments) {
|
|
35
|
+
if (!comments || comments.length === 0) {
|
|
36
|
+
return `<w:comments xmlns:w="${NS_W}"></w:comments>`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const commentElements = comments.map(comment =>
|
|
40
|
+
buildCommentElement(comment.id, comment.author, comment.content, comment.date)
|
|
41
|
+
).join('\n ');
|
|
42
|
+
|
|
43
|
+
return `<w:comments xmlns:w="${NS_W}">
|
|
44
|
+
${commentElements}
|
|
45
|
+
</w:comments>`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Builds inline range/reference markers for a comment id.
|
|
50
|
+
*
|
|
51
|
+
* @param {number} commentId - The comment ID
|
|
52
|
+
* @returns {{ start: string, end: string, reference: string }}
|
|
53
|
+
*/
|
|
54
|
+
export function buildCommentMarkers(commentId) {
|
|
55
|
+
return {
|
|
56
|
+
start: `<w:commentRangeStart w:id="${commentId}"/>`,
|
|
57
|
+
end: `<w:commentRangeEnd w:id="${commentId}"/>`,
|
|
58
|
+
reference: `<w:r><w:rPr></w:rPr><w:commentReference w:id="${commentId}"/></w:r>`
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OOXML Comment Engine
|
|
3
|
+
*
|
|
4
|
+
* Provides pure OOXML-based comment insertion without Word JS API calls.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { NS_W, getNextRevisionId, getRevisionTimestamp, resetRevisionIdCounter } from '../core/types.js';
|
|
8
|
+
import { createParser, createSerializer } from '../adapters/xml-adapter.js';
|
|
9
|
+
import { log, error as logError } from '../adapters/logger.js';
|
|
10
|
+
import { getElementsByTag, getFirstElementByTag, getXmlParseError } from '../core/xml-query.js';
|
|
11
|
+
import { buildCommentElement, buildCommentsPartXml, buildCommentMarkers } from './comment-builders.js';
|
|
12
|
+
import { getDefaultAuthor } from '../adapters/config.js';
|
|
13
|
+
import { createParagraphTextIndex, injectMarkersIntoParagraph } from './comment-locator.js';
|
|
14
|
+
import {
|
|
15
|
+
injectCommentsIntoPackage as injectCommentsIntoExistingPackage,
|
|
16
|
+
wrapParagraphWithComments,
|
|
17
|
+
wrapWithCommentsPart
|
|
18
|
+
} from './comment-package.js';
|
|
19
|
+
|
|
20
|
+
export { getNextRevisionId, resetRevisionIdCounter };
|
|
21
|
+
export { buildCommentElement, buildCommentsPartXml, buildCommentMarkers };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {Object} CommentRequest
|
|
25
|
+
* @property {number} paragraphIndex - 1-based paragraph index
|
|
26
|
+
* @property {string} textToFind - Text to attach comment to
|
|
27
|
+
* @property {string} commentContent - The comment text
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {Object} CommentInjectionResult
|
|
32
|
+
* @property {string} oxml - Complete OOXML package with comments
|
|
33
|
+
* @property {string} [commentsXml] - comments.xml content when comments are applied
|
|
34
|
+
* @property {number} commentsApplied - Number of successfully placed comments
|
|
35
|
+
* @property {string[]} warnings - Any issues encountered
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
function parseDocumentOxml(oxml, parser, parseFailureWarning) {
|
|
39
|
+
try {
|
|
40
|
+
const xmlDoc = parser.parseFromString(oxml, 'text/xml');
|
|
41
|
+
const parseError = getXmlParseError(xmlDoc);
|
|
42
|
+
if (parseError) {
|
|
43
|
+
return { xmlDoc: null, warning: parseFailureWarning(parseError.textContent || 'parse error') };
|
|
44
|
+
}
|
|
45
|
+
return { xmlDoc, warning: null };
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return { xmlDoc: null, warning: parseFailureWarning(error.message) };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Injects comments into OOXML using pure XML manipulation.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} oxml - Original document OOXML
|
|
55
|
+
* @param {CommentRequest[]} comments - Comment requests
|
|
56
|
+
* @param {Object} [options={}] - Options
|
|
57
|
+
* @param {string} [options.author] - Author for comments (defaults to configured default author)
|
|
58
|
+
* @returns {CommentInjectionResult}
|
|
59
|
+
*/
|
|
60
|
+
export function injectCommentsIntoOoxml(oxml, comments, options = {}) {
|
|
61
|
+
const author = options?.author || getDefaultAuthor();
|
|
62
|
+
const date = getRevisionTimestamp();
|
|
63
|
+
const warnings = [];
|
|
64
|
+
const placedComments = [];
|
|
65
|
+
|
|
66
|
+
if (!comments || comments.length === 0) {
|
|
67
|
+
return {
|
|
68
|
+
oxml,
|
|
69
|
+
commentsApplied: 0,
|
|
70
|
+
warnings: ['No comments to inject']
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const parser = createParser();
|
|
75
|
+
const serializer = createSerializer();
|
|
76
|
+
const parseResult = parseDocumentOxml(
|
|
77
|
+
oxml,
|
|
78
|
+
parser,
|
|
79
|
+
warning => `Failed to parse OXML: ${warning}`
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (!parseResult.xmlDoc) {
|
|
83
|
+
logError('[CommentEngine] Parse failure:', parseResult.warning);
|
|
84
|
+
return {
|
|
85
|
+
oxml,
|
|
86
|
+
commentsApplied: 0,
|
|
87
|
+
warnings: [parseResult.warning]
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const xmlDoc = parseResult.xmlDoc;
|
|
92
|
+
const paragraphs = getElementsByTag(xmlDoc, 'w:p');
|
|
93
|
+
log(`[CommentEngine] Found ${paragraphs.length} paragraphs, processing ${comments.length} comment requests`);
|
|
94
|
+
|
|
95
|
+
/** @type {Map<number, number>} */
|
|
96
|
+
const remainingRequestsByParagraph = new Map();
|
|
97
|
+
for (const request of comments) {
|
|
98
|
+
const paragraphIndex = request.paragraphIndex - 1;
|
|
99
|
+
if (paragraphIndex < 0 || paragraphIndex >= paragraphs.length) {
|
|
100
|
+
warnings.push(`Paragraph ${request.paragraphIndex} out of range (1-${paragraphs.length})`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
remainingRequestsByParagraph.set(paragraphIndex, (remainingRequestsByParagraph.get(paragraphIndex) || 0) + 1);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** @type {Map<number, { fullText: string, runOffsets: Array<{run: Element, start: number, end: number}> }>} */
|
|
107
|
+
const paragraphIndexes = new Map();
|
|
108
|
+
|
|
109
|
+
for (const request of comments) {
|
|
110
|
+
const paragraphIndex = request.paragraphIndex - 1;
|
|
111
|
+
if (paragraphIndex < 0 || paragraphIndex >= paragraphs.length) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const targetParagraph = paragraphs[paragraphIndex];
|
|
116
|
+
let textIndex = paragraphIndexes.get(paragraphIndex);
|
|
117
|
+
if (!textIndex) {
|
|
118
|
+
textIndex = createParagraphTextIndex(targetParagraph);
|
|
119
|
+
paragraphIndexes.set(paragraphIndex, textIndex);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const commentId = getNextRevisionId();
|
|
123
|
+
const success = injectMarkersIntoParagraph(
|
|
124
|
+
xmlDoc,
|
|
125
|
+
targetParagraph,
|
|
126
|
+
request.textToFind,
|
|
127
|
+
commentId,
|
|
128
|
+
textIndex
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const remaining = (remainingRequestsByParagraph.get(paragraphIndex) || 1) - 1;
|
|
132
|
+
remainingRequestsByParagraph.set(paragraphIndex, remaining);
|
|
133
|
+
|
|
134
|
+
if (!success) {
|
|
135
|
+
warnings.push(`Could not find "${request.textToFind.substring(0, 30)}..." in paragraph ${request.paragraphIndex}`);
|
|
136
|
+
if (remaining === 0) {
|
|
137
|
+
paragraphIndexes.delete(paragraphIndex);
|
|
138
|
+
}
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
placedComments.push({
|
|
143
|
+
id: commentId,
|
|
144
|
+
content: request.commentContent,
|
|
145
|
+
author,
|
|
146
|
+
date
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
if (remaining > 0) {
|
|
150
|
+
// Rebuild only when another request still targets this paragraph.
|
|
151
|
+
paragraphIndexes.set(paragraphIndex, createParagraphTextIndex(targetParagraph));
|
|
152
|
+
} else {
|
|
153
|
+
paragraphIndexes.delete(paragraphIndex);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (placedComments.length === 0) {
|
|
158
|
+
return {
|
|
159
|
+
oxml,
|
|
160
|
+
commentsApplied: 0,
|
|
161
|
+
warnings
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
oxml: serializer.serializeToString(xmlDoc),
|
|
167
|
+
commentsXml: buildCommentsPartXml(placedComments),
|
|
168
|
+
commentsApplied: placedComments.length,
|
|
169
|
+
warnings
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Injects a comment into a single paragraph OOXML and returns a complete mini-package.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} paragraphOoxml - Paragraph OOXML (raw paragraph or pkg:package)
|
|
177
|
+
* @param {string} textToFind - Target text
|
|
178
|
+
* @param {string} commentContent - Comment body
|
|
179
|
+
* @param {Object} [options={}] - Options
|
|
180
|
+
* @param {string} [options.author='AI Assistant'] - Comment author
|
|
181
|
+
* @returns {{ success: boolean, package?: string, warning?: string, commentId?: number }}
|
|
182
|
+
*/
|
|
183
|
+
export function injectCommentIntoParagraphOoxml(paragraphOoxml, textToFind, commentContent, options = {}) {
|
|
184
|
+
const { author = 'AI Assistant' } = options;
|
|
185
|
+
const date = getRevisionTimestamp();
|
|
186
|
+
const commentId = getNextRevisionId();
|
|
187
|
+
|
|
188
|
+
const parser = createParser();
|
|
189
|
+
const serializer = createSerializer();
|
|
190
|
+
const parseResult = parseDocumentOxml(
|
|
191
|
+
paragraphOoxml,
|
|
192
|
+
parser,
|
|
193
|
+
warning => `Failed to parse paragraph OOXML: ${warning}`
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
if (!parseResult.xmlDoc) {
|
|
197
|
+
return { success: false, warning: parseResult.warning };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const xmlDoc = parseResult.xmlDoc;
|
|
201
|
+
const paragraphs = getElementsByTag(xmlDoc, 'w:p');
|
|
202
|
+
if (paragraphs.length === 0) {
|
|
203
|
+
return { success: false, warning: 'No paragraph found in OOXML' };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const paragraph = paragraphs[0];
|
|
207
|
+
const paragraphIndex = createParagraphTextIndex(paragraph);
|
|
208
|
+
const success = injectMarkersIntoParagraph(xmlDoc, paragraph, textToFind, commentId, paragraphIndex);
|
|
209
|
+
if (!success) {
|
|
210
|
+
return { success: false, warning: `Could not find "${textToFind.substring(0, 30)}..." in paragraph` };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const commentElement = buildCommentElement(commentId, author, commentContent, date);
|
|
214
|
+
const commentsXml = `<w:comments xmlns:w="${NS_W}">${commentElement}</w:comments>`;
|
|
215
|
+
const pkgPackage = getFirstElementByTag(xmlDoc, 'pkg:package');
|
|
216
|
+
|
|
217
|
+
if (pkgPackage) {
|
|
218
|
+
const withComments = injectCommentsIntoExistingPackage(serializer.serializeToString(xmlDoc), commentsXml);
|
|
219
|
+
return { success: true, package: withComments, commentId };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const modifiedParagraphXml = serializer.serializeToString(xmlDoc);
|
|
223
|
+
return {
|
|
224
|
+
success: true,
|
|
225
|
+
package: wrapParagraphWithComments(modifiedParagraphXml, commentsXml),
|
|
226
|
+
commentId
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Injects comments part into an existing OOXML package from getOoxml().
|
|
232
|
+
*
|
|
233
|
+
* @param {string} packageOxml - Existing pkg:package
|
|
234
|
+
* @param {string} commentsXml - comments.xml payload
|
|
235
|
+
* @returns {string}
|
|
236
|
+
*/
|
|
237
|
+
export function injectCommentsIntoPackage(packageOxml, commentsXml) {
|
|
238
|
+
return injectCommentsIntoExistingPackage(packageOxml, commentsXml);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* @deprecated Use injectCommentsIntoPackage instead.
|
|
243
|
+
*
|
|
244
|
+
* @param {string} documentXml - Document XML
|
|
245
|
+
* @param {string} commentsXml - comments.xml payload
|
|
246
|
+
* @returns {string}
|
|
247
|
+
*/
|
|
248
|
+
export { wrapWithCommentsPart };
|