@ansonlai/docx-redline-js 0.4.0 → 0.5.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 +589 -287
- package/ARCHITECTURE.md +215 -9
- package/CHANGELOG.md +319 -0
- package/README.md +604 -360
- package/adapters/config.js +45 -43
- package/bin/docx-redline.js +3 -0
- package/core/list-targeting.js +101 -110
- package/core/paragraph-targeting.js +501 -61
- package/core/paragraph-text.js +209 -0
- package/core/revision-cloning.js +38 -0
- package/core/types.js +64 -10
- package/core/word-xml.js +43 -15
- package/dist/docx-redline-js.esm.js +2849 -466
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +87 -76
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/TESTING.md +342 -23
- package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
- package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
- package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
- package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
- package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
- package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
- package/docs/schemas/document-operations.schema.json +109 -0
- package/docs/test-comparison-dashboard.html +4250 -7
- package/engine/formatting-removal.js +11 -2
- package/engine/oxml-engine.js +491 -336
- package/engine/reconstruction-mode.js +15 -14
- package/engine/reconstruction-writer.js +247 -142
- package/engine/route-selection.js +35 -0
- package/engine/rpr-helpers.js +334 -35
- package/engine/run-builders.js +239 -196
- package/engine/surgical-diff-application.js +222 -37
- package/engine/surgical-mode.js +134 -6
- package/engine/surgical-spans.js +52 -1
- package/engine/table-cell-context.js +3 -6
- package/engine/table-mode.js +1 -1
- package/index.d.ts +234 -6
- package/index.js +24 -1
- package/node/cli.js +317 -0
- package/node/docx-document.js +302 -0
- package/node/index.d.ts +31 -0
- package/node/index.js +2 -0
- package/node/zip-archive.js +52 -0
- package/orchestration/list-markdown.js +10 -16
- package/orchestration/list-parsing.js +7 -12
- package/orchestration/list-structural-fallback.js +21 -10
- package/package.json +24 -3
- package/pipeline/content-analysis.js +12 -17
- package/pipeline/ingestion-export.js +3 -31
- package/pipeline/ingestion-paragraph.js +10 -5
- package/pipeline/list-generation.js +150 -55
- package/pipeline/list-markers.js +70 -3
- package/pipeline/serialization.js +4 -2
- package/pipeline/structured-content.js +160 -0
- package/scripts/apply_changes.mjs +27 -0
- package/scripts/benchmark-operation-session.mjs +137 -0
- package/scripts/benchmark-targeting-browser.html +74 -0
- package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
- package/scripts/benchmark-test-runner.mjs +59 -0
- package/scripts/build-test-dashboard.mjs +23 -0
- package/scripts/export-lane1-fixtures.mjs +380 -0
- package/scripts/export-reredline-stress-fixtures.mjs +317 -0
- package/scripts/export-validation-fixtures.mjs +1 -1
- package/scripts/extract_text.mjs +7 -0
- package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
- package/scripts/generate-test-dashboard.mjs +362 -11
- package/scripts/lib/word-coverage-catalogue.mjs +6 -2
- package/scripts/profile-route-selection.mjs +19 -0
- package/scripts/render-agenda-multilevel.mjs +0 -5
- package/scripts/render-multilevel-cases.mjs +0 -1
- package/scripts/run-tests.mjs +107 -35
- package/scripts/word-com-corpus-suite.ps1 +3 -0
- package/scripts/word-com-differential.ps1 +64 -4
- package/scripts/word-com-suite.ps1 +3 -0
- package/services/batch-operation-orchestrator.js +494 -0
- package/services/capture-engine.js +226 -0
- package/services/comment-builders.js +23 -6
- package/services/comment-engine.js +108 -47
- package/services/comment-locator.js +187 -82
- package/services/comment-replies.js +95 -0
- package/services/document-inspection.js +258 -0
- package/services/document-operation-applier.js +372 -0
- package/services/document-operation-contract.js +323 -0
- package/services/document-operation-mutations.js +1733 -0
- package/services/document-operation-session.js +258 -0
- package/services/numbering-service.js +14 -5
- package/services/operation-heuristics.js +173 -0
- package/services/operation-preflight.js +366 -0
- package/services/receipt-collector.js +288 -0
- package/services/revision-comment-management.js +37 -5
- package/services/revision-token.js +290 -0
- package/services/standalone-docx-plumbing.js +123 -8
- package/services/standalone-operation-runner.d.ts +296 -0
- package/services/standalone-operation-runner.js +10 -1455
- package/services/table-reconciliation.js +15 -6
- package/docs/VALIDATION.md +0 -183
- package/docs/WORD-MANUAL-REVIEW.md +0 -138
- package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
- /package/docs/plans/{2026-08-30-reliability-testing-improvements.md → completed/2026-08-30-reliability-testing-improvements.md} +0 -0
|
@@ -6,8 +6,11 @@ import { preprocessMarkdown } from './markdown-processor.js';
|
|
|
6
6
|
import { matchListMarker, stripListMarker } from './list-markers.js';
|
|
7
7
|
import { serializeToOoxml } from './serialization.js';
|
|
8
8
|
import { generateTableOoxml } from '../services/table-reconciliation.js';
|
|
9
|
-
import { parseTable } from './content-analysis.js';
|
|
10
|
-
import { log } from '../adapters/logger.js';
|
|
9
|
+
import { parseTable } from './content-analysis.js';
|
|
10
|
+
import { log } from '../adapters/logger.js';
|
|
11
|
+
import { createRevisionMetadata, NS_W, RunKind } from '../core/types.js';
|
|
12
|
+
import { parseOoxmlSafe, serializeXml } from '../adapters/xml-adapter.js';
|
|
13
|
+
import { createWordElement } from '../core/word-xml.js';
|
|
11
14
|
|
|
12
15
|
/**
|
|
13
16
|
* Executes list generation when paragraph content expands into list/table blocks.
|
|
@@ -39,10 +42,17 @@ export async function executeListGeneration(options) {
|
|
|
39
42
|
const normalizedListText = normalizeCompositeListMarkers(cleanText);
|
|
40
43
|
const lineMetadata = buildLineMetadata(normalizedListText);
|
|
41
44
|
const rawLines = lineMetadata.map(line => line.raw);
|
|
42
|
-
const results = [];
|
|
45
|
+
const results = [];
|
|
46
|
+
const sourcePPr = getSourceParagraphProperties(originalRunModel);
|
|
47
|
+
const inheritedTypographyRPrXml = extractInheritedTypographyRPrXml(originalRunModel, sourcePPr);
|
|
48
|
+
const inheritedHeadingRPrXml = extractInheritedTypographyRPrXml(
|
|
49
|
+
originalRunModel,
|
|
50
|
+
sourcePPr,
|
|
51
|
+
['rFonts', 'kern', 'position', 'rtl', 'cs', 'lang']
|
|
52
|
+
);
|
|
43
53
|
|
|
44
54
|
let deletionRuns = [];
|
|
45
|
-
if (generateRedlines) {
|
|
55
|
+
if (generateRedlines) {
|
|
46
56
|
if (originalRunModel && originalRunModel.length > 0) {
|
|
47
57
|
deletionRuns = originalRunModel
|
|
48
58
|
.filter(run => run.kind === 'text' || run.kind === 'run')
|
|
@@ -56,8 +66,18 @@ export async function executeListGeneration(options) {
|
|
|
56
66
|
startOffset: 0,
|
|
57
67
|
endOffset: trimmed.length
|
|
58
68
|
}];
|
|
59
|
-
}
|
|
60
|
-
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (generateRedlines && deletionRuns.length > 0) {
|
|
73
|
+
const deletedPPr = addParagraphMarkRevision(sourcePPr, 'del', author, revisionIdAllocator);
|
|
74
|
+
const deletedParagraph = serializeToOoxml(deletionRuns, deletedPPr, [], {
|
|
75
|
+
author,
|
|
76
|
+
generateRedlines,
|
|
77
|
+
revisionIdAllocator
|
|
78
|
+
});
|
|
79
|
+
results.push(deletedParagraph);
|
|
80
|
+
}
|
|
61
81
|
|
|
62
82
|
const indentStep = detectIndentationStep(rawLines);
|
|
63
83
|
log(`[ListGen] Detected indentation step: ${indentStep} spaces/chars`);
|
|
@@ -71,19 +91,11 @@ export async function executeListGeneration(options) {
|
|
|
71
91
|
if (tableBlock) {
|
|
72
92
|
const tableData = parseTable(tableBlock.tableText);
|
|
73
93
|
if (tableData.headers.length > 0 || tableData.rows.length > 0) {
|
|
74
|
-
if (generateRedlines && results.length === 0 && deletionRuns.length > 0) {
|
|
75
|
-
results.push(serializeToOoxml(deletionRuns, null, [], {
|
|
76
|
-
author,
|
|
77
|
-
generateRedlines,
|
|
78
|
-
font,
|
|
79
|
-
revisionIdAllocator
|
|
80
|
-
}));
|
|
81
|
-
}
|
|
82
|
-
|
|
83
94
|
results.push(generateTableOoxml(tableData, {
|
|
84
95
|
generateRedlines,
|
|
85
96
|
author,
|
|
86
|
-
revisionIdAllocator
|
|
97
|
+
revisionIdAllocator,
|
|
98
|
+
trackAsBlock: true
|
|
87
99
|
}));
|
|
88
100
|
i = tableBlock.endIndex;
|
|
89
101
|
continue;
|
|
@@ -91,31 +103,29 @@ export async function executeListGeneration(options) {
|
|
|
91
103
|
}
|
|
92
104
|
|
|
93
105
|
const line = lineMetadata[i];
|
|
94
|
-
const entry = buildListEntry(
|
|
95
|
-
line,
|
|
96
|
-
|
|
97
|
-
indentStep,
|
|
106
|
+
const entry = buildListEntry(
|
|
107
|
+
line,
|
|
108
|
+
indentStep,
|
|
98
109
|
numberingContext,
|
|
99
110
|
numberingService,
|
|
100
111
|
generateRedlines,
|
|
101
112
|
author,
|
|
102
113
|
font,
|
|
103
114
|
revisionIdAllocator,
|
|
104
|
-
|
|
105
|
-
|
|
115
|
+
inheritedTypographyRPrXml,
|
|
116
|
+
inheritedHeadingRPrXml
|
|
117
|
+
);
|
|
106
118
|
results.push(entry.ooxml);
|
|
107
119
|
}
|
|
108
120
|
|
|
109
121
|
const numberingXml = numberingService.generateNumberingXml();
|
|
110
122
|
const finalOoxml = results.join('');
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
log(`[ListGen] ✅ Generated OOXML for ${results.length} list items, total length: ${oxmlWithSpacing.length}`);
|
|
115
|
-
log(`[ListGen] First 200 chars: ${oxmlWithSpacing.substring(0, 200)}...`);
|
|
123
|
+
|
|
124
|
+
log(`[ListGen] ✅ Generated OOXML for ${results.length} paragraphs, total length: ${finalOoxml.length}`);
|
|
125
|
+
log(`[ListGen] First 200 chars: ${finalOoxml.substring(0, 200)}...`);
|
|
116
126
|
|
|
117
127
|
return {
|
|
118
|
-
ooxml:
|
|
128
|
+
ooxml: finalOoxml,
|
|
119
129
|
isValid: true,
|
|
120
130
|
warnings: ['Paragraph expanded to list fragment'],
|
|
121
131
|
type: 'fragment',
|
|
@@ -226,28 +236,33 @@ function collectMarkdownTableBlock(lineMetadata, index) {
|
|
|
226
236
|
};
|
|
227
237
|
}
|
|
228
238
|
|
|
229
|
-
function buildListEntry(
|
|
230
|
-
line,
|
|
231
|
-
|
|
232
|
-
indentStep,
|
|
239
|
+
function buildListEntry(
|
|
240
|
+
line,
|
|
241
|
+
indentStep,
|
|
233
242
|
numberingContext,
|
|
234
243
|
numberingService,
|
|
235
244
|
generateRedlines,
|
|
236
245
|
author,
|
|
237
246
|
font,
|
|
238
247
|
revisionIdAllocator,
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
let
|
|
248
|
+
inheritedTypographyRPrXml,
|
|
249
|
+
inheritedHeadingRPrXml
|
|
250
|
+
) {
|
|
251
|
+
let pPrXml = '';
|
|
252
|
+
let segmentText = '';
|
|
253
|
+
let insertedRPrXml = inheritedTypographyRPrXml;
|
|
243
254
|
|
|
244
255
|
if (line.headerMatch) {
|
|
245
256
|
const level = Math.min(line.headerMatch[1].length, 9);
|
|
246
257
|
const outlineLevel = Math.min(level - 1, 8);
|
|
247
258
|
const headingSizes = [32, 28, 26, 24, 22, 20, 20, 20, 20];
|
|
248
|
-
const headingSize = headingSizes[level - 1] || headingSizes[headingSizes.length - 1];
|
|
249
|
-
segmentText = line.headerMatch[2].trim();
|
|
250
|
-
pPrXml = `<w:pPr><w:pStyle w:val="Heading${level}"/><w:outlineLvl w:val="${outlineLevel}"/><w:rPr><w:b/><w:sz w:val="${headingSize}"/><w:szCs w:val="${headingSize}"/></w:rPr></w:pPr>`;
|
|
259
|
+
const headingSize = headingSizes[level - 1] || headingSizes[headingSizes.length - 1];
|
|
260
|
+
segmentText = line.headerMatch[2].trim();
|
|
261
|
+
pPrXml = `<w:pPr><w:pStyle w:val="Heading${level}"/><w:outlineLvl w:val="${outlineLevel}"/><w:rPr><w:b/><w:sz w:val="${headingSize}"/><w:szCs w:val="${headingSize}"/></w:rPr></w:pPr>`;
|
|
262
|
+
insertedRPrXml = mergeRunProperties(
|
|
263
|
+
inheritedHeadingRPrXml,
|
|
264
|
+
`<w:rPr><w:b/><w:sz w:val="${headingSize}"/><w:szCs w:val="${headingSize}"/></w:rPr>`
|
|
265
|
+
);
|
|
251
266
|
} else if (line.marker) {
|
|
252
267
|
const lineFormat = numberingService.detectNumberingFormat(line.marker);
|
|
253
268
|
const indentLevel = indentStep > 0 ? Math.floor(line.indentSize / indentStep) : 0;
|
|
@@ -259,20 +274,26 @@ function buildListEntry(
|
|
|
259
274
|
segmentText = stripListMarker(line.raw);
|
|
260
275
|
const numId = numberingService.getOrCreateNumId({ type: lineFormat.format }, numberingContext);
|
|
261
276
|
pPrXml = numberingService.buildListPPr(numId, ilvl);
|
|
262
|
-
} else {
|
|
263
|
-
segmentText = line.raw;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
277
|
+
} else {
|
|
278
|
+
segmentText = line.raw;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (generateRedlines) {
|
|
282
|
+
pPrXml = addParagraphMarkRevision(
|
|
283
|
+
pPrXml || '<w:pPr/>',
|
|
284
|
+
'ins',
|
|
285
|
+
author,
|
|
286
|
+
revisionIdAllocator
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const { cleanText, formatHints } = preprocessMarkdown(segmentText);
|
|
291
|
+
const runModel = [];
|
|
292
|
+
|
|
293
|
+
runModel.push({
|
|
294
|
+
kind: generateRedlines ? 'insertion' : 'run',
|
|
295
|
+
text: cleanText,
|
|
296
|
+
rPrXml: insertedRPrXml,
|
|
276
297
|
author,
|
|
277
298
|
startOffset: 0,
|
|
278
299
|
endOffset: cleanText.length
|
|
@@ -285,5 +306,79 @@ function buildListEntry(
|
|
|
285
306
|
font,
|
|
286
307
|
revisionIdAllocator
|
|
287
308
|
})
|
|
288
|
-
};
|
|
289
|
-
}
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function getSourceParagraphProperties(originalRunModel) {
|
|
313
|
+
const paragraphStart = (originalRunModel || []).find(run => run.kind === RunKind.PARAGRAPH_START);
|
|
314
|
+
return paragraphStart?.pPrElement || paragraphStart?.pPrXml || null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function extractInheritedTypographyRPrXml(
|
|
318
|
+
originalRunModel,
|
|
319
|
+
sourcePPr,
|
|
320
|
+
tagOrder = ['rFonts', 'kern', 'position', 'sz', 'szCs', 'rtl', 'cs', 'lang']
|
|
321
|
+
) {
|
|
322
|
+
const sources = (originalRunModel || [])
|
|
323
|
+
.filter(run => (run.kind === RunKind.TEXT || run.kind === 'text') && run.rPrXml)
|
|
324
|
+
.map(run => run.rPrXml);
|
|
325
|
+
|
|
326
|
+
if (sourcePPr) {
|
|
327
|
+
sources.push(typeof sourcePPr === 'string' ? sourcePPr : serializeXml(sourcePPr));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const inherited = [];
|
|
331
|
+
for (const tagName of tagOrder) {
|
|
332
|
+
let match = null;
|
|
333
|
+
for (const source of sources) {
|
|
334
|
+
match = String(source || '').match(new RegExp(`<w:${tagName}\\b[^>]*(?:\\/>|>[\\s\\S]*?<\\/w:${tagName}>)`));
|
|
335
|
+
if (match) break;
|
|
336
|
+
}
|
|
337
|
+
if (match) inherited.push(match[0]);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return inherited.length > 0 ? `<w:rPr>${inherited.join('')}</w:rPr>` : '';
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function mergeRunProperties(...sources) {
|
|
344
|
+
const properties = sources
|
|
345
|
+
.map(source => String(source || '').replace(/^\s*<w:rPr[^>]*>|<\/w:rPr>\s*$/g, ''))
|
|
346
|
+
.filter(Boolean)
|
|
347
|
+
.join('');
|
|
348
|
+
return properties ? `<w:rPr>${properties}</w:rPr>` : '';
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function addParagraphMarkRevision(xml, type, author, revisionIdAllocator) {
|
|
352
|
+
const metadata = createRevisionMetadata(author, revisionIdAllocator);
|
|
353
|
+
let pPr;
|
|
354
|
+
let ownerDoc;
|
|
355
|
+
|
|
356
|
+
if (xml?.nodeType === 1) {
|
|
357
|
+
pPr = xml.cloneNode(true);
|
|
358
|
+
ownerDoc = pPr.ownerDocument;
|
|
359
|
+
} else {
|
|
360
|
+
const pPrXml = typeof xml === 'string' && xml.trim() ? xml : '<w:pPr/>';
|
|
361
|
+
const parsed = parseOoxmlSafe(`<w:root xmlns:w="${NS_W}">${pPrXml}</w:root>`);
|
|
362
|
+
if (!parsed.doc) {
|
|
363
|
+
throw new Error(parsed.error?.message || 'Could not parse paragraph properties for list revision');
|
|
364
|
+
}
|
|
365
|
+
ownerDoc = parsed.doc;
|
|
366
|
+
pPr = Array.from(ownerDoc.documentElement.childNodes || []).find(node => (
|
|
367
|
+
node.nodeType === 1 && node.localName === 'pPr'
|
|
368
|
+
));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (!pPr || !ownerDoc) throw new Error('List paragraph properties are unavailable');
|
|
372
|
+
let rPr = Array.from(pPr.childNodes || []).find(node => node.nodeType === 1 && node.localName === 'rPr');
|
|
373
|
+
if (!rPr) {
|
|
374
|
+
rPr = createWordElement(ownerDoc, 'w:rPr');
|
|
375
|
+
pPr.appendChild(rPr);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const marker = createWordElement(ownerDoc, type === 'del' ? 'w:del' : 'w:ins');
|
|
379
|
+
marker.setAttribute('w:id', String(metadata.id));
|
|
380
|
+
marker.setAttribute('w:author', metadata.author);
|
|
381
|
+
marker.setAttribute('w:date', metadata.date);
|
|
382
|
+
rPr.appendChild(marker);
|
|
383
|
+
return serializeXml(pPr);
|
|
384
|
+
}
|
package/pipeline/list-markers.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Keeps marker parsing consistent across router, pipeline, and patching flows.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
const LIST_MARKER_CORE = String.raw`(?:\d+(?:\.\d+)*\.?|\((?:\d+|[a-zA-Z]|[ivxlcIVXLC]+)\)|[a-zA-Z]\.|\d+\.|[ivxlcIVXLC]+\.|[
|
|
7
|
+
const LIST_MARKER_CORE = String.raw`(?:\d+(?:\.\d+)*\.?|\((?:\d+|[a-zA-Z]|[ivxlcIVXLC]+)\)|[a-zA-Z]\.|\d+\.|[ivxlcIVXLC]+\.|[-*+\u2022])`;
|
|
8
8
|
|
|
9
9
|
const LINE_REGEX_STRICT = new RegExp(`^(\\s*)((?:${LIST_MARKER_CORE})\\s+)`);
|
|
10
10
|
const LINE_REGEX_LOOSE = new RegExp(`^(\\s*)((?:${LIST_MARKER_CORE})\\s*)`);
|
|
@@ -70,8 +70,75 @@ export function extractListMarker(line, options = {}) {
|
|
|
70
70
|
* @param {boolean} [options.allowZeroSpaceAfterMarker=false] - Allow zero spaces after marker
|
|
71
71
|
* @returns {string}
|
|
72
72
|
*/
|
|
73
|
-
export function stripListMarker(line, options = {}) {
|
|
73
|
+
export function stripListMarker(line, options = {}) {
|
|
74
74
|
const { allowZeroSpaceAfterMarker = false } = options;
|
|
75
75
|
const regex = allowZeroSpaceAfterMarker ? LINE_REGEX_LOOSE : LINE_REGEX_STRICT;
|
|
76
76
|
return line.replace(regex, '');
|
|
77
|
-
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Classifies a parsed marker using the shared list vocabulary.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} marker - Marker without trailing whitespace
|
|
83
|
+
* @returns {'bullet'|'numbered'}
|
|
84
|
+
*/
|
|
85
|
+
export function classifyListMarker(marker) {
|
|
86
|
+
return /^[-*+\u2022]$/.test(String(marker || '').trim()) ? 'bullet' : 'numbered';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Infers the Word numbering style represented by a numbered marker.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} marker - Marker text
|
|
93
|
+
* @returns {'bullet'|'decimal'|'lowerAlpha'|'upperAlpha'|'lowerRoman'|'upperRoman'}
|
|
94
|
+
*/
|
|
95
|
+
export function inferNumberingStyleFromMarker(marker) {
|
|
96
|
+
const value = String(marker || '').trim();
|
|
97
|
+
if (classifyListMarker(value) === 'bullet') return 'bullet';
|
|
98
|
+
if (/^\d+(?:\.\d+)*\.?$/.test(value) || /^\(\d+\)$/.test(value)) return 'decimal';
|
|
99
|
+
if (/^[ivxlcdm]+\.$/.test(value)) return 'lowerRoman';
|
|
100
|
+
if (/^[IVXLCDM]{2,}\.$/.test(value)) return 'upperRoman';
|
|
101
|
+
if (/^[a-z]\.$/.test(value)) return 'lowerAlpha';
|
|
102
|
+
if (/^[A-Z]\.$/.test(value)) return 'upperAlpha';
|
|
103
|
+
return 'decimal';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Returns an outline level encoded directly in a composite decimal marker.
|
|
108
|
+
* Indentation-derived levels remain the caller's responsibility.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} marker - Marker text
|
|
111
|
+
* @returns {number|null}
|
|
112
|
+
*/
|
|
113
|
+
export function parseOutlineLevelFromMarker(marker) {
|
|
114
|
+
const value = String(marker || '').trim();
|
|
115
|
+
if (!/^\d+(?:\.\d+)+\.?$/.test(value)) return null;
|
|
116
|
+
return Math.max(0, value.replace(/\.$/, '').split('.').length - 1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Parses one line into the common list-item representation.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} line - Input line
|
|
123
|
+
* @param {{allowZeroSpaceAfterMarker?: boolean, indentSpaces?: number}} [options]
|
|
124
|
+
* @returns {{line:string,text:string,marker:string,indent:number,level:number,markerType:'bullet'|'numbered',listType:'bullet'|'numbered',numberingStyle:string,outlineLevel:number|null}|null}
|
|
125
|
+
*/
|
|
126
|
+
export function parseListItem(line, options = {}) {
|
|
127
|
+
const match = matchListMarker(String(line || ''), options);
|
|
128
|
+
if (!match) return null;
|
|
129
|
+
const marker = match[2].trim();
|
|
130
|
+
const indent = (match[1] || '').length;
|
|
131
|
+
const indentSpaces = Math.max(1, Number(options.indentSpaces) || 2);
|
|
132
|
+
const markerType = classifyListMarker(marker);
|
|
133
|
+
return {
|
|
134
|
+
line: String(line || ''),
|
|
135
|
+
text: stripListMarker(String(line || ''), options),
|
|
136
|
+
marker,
|
|
137
|
+
indent,
|
|
138
|
+
level: Math.min(8, Math.floor(indent / indentSpaces)),
|
|
139
|
+
markerType,
|
|
140
|
+
listType: markerType,
|
|
141
|
+
numberingStyle: inferNumberingStyleFromMarker(marker),
|
|
142
|
+
outlineLevel: markerType === 'numbered' ? parseOutlineLevelFromMarker(marker) : null
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -232,7 +232,8 @@ function buildSimpleRun(text, rPrXml) {
|
|
|
232
232
|
function buildDeletionXml(item, options = {}) {
|
|
233
233
|
const metadata = createRevisionMetadata(
|
|
234
234
|
options.author ?? getDefaultAuthor(),
|
|
235
|
-
options.revisionIdAllocator
|
|
235
|
+
options.revisionIdAllocator,
|
|
236
|
+
'del'
|
|
236
237
|
);
|
|
237
238
|
const font = options.font ?? null;
|
|
238
239
|
let rPr = item.rPrXml ? stripNamespaceDeclarations(item.rPrXml) : '';
|
|
@@ -257,7 +258,8 @@ function buildDeletionXml(item, options = {}) {
|
|
|
257
258
|
function buildInsertionXml(item, formatHints, options = {}) {
|
|
258
259
|
const metadata = createRevisionMetadata(
|
|
259
260
|
options.author ?? getDefaultAuthor(),
|
|
260
|
-
options.revisionIdAllocator
|
|
261
|
+
options.revisionIdAllocator,
|
|
262
|
+
'ins'
|
|
261
263
|
);
|
|
262
264
|
const font = options.font ?? null;
|
|
263
265
|
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strict, dependency-light planning for mixed Markdown document insertions.
|
|
3
|
+
*
|
|
4
|
+
* Agents should run this before replacing one Word paragraph with content that
|
|
5
|
+
* contains headings, paragraphs, lists, or tables. The planner never guesses a
|
|
6
|
+
* malformed table: a Markdown separator row is required so literal pipe text
|
|
7
|
+
* cannot silently reach the document.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { matchListMarker } from './list-markers.js';
|
|
11
|
+
|
|
12
|
+
const TABLE_SEPARATOR = /^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/;
|
|
13
|
+
|
|
14
|
+
function isBlank(line) {
|
|
15
|
+
return !line || line.trim().length === 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isTableLine(line) {
|
|
19
|
+
const trimmed = String(line || '').trim();
|
|
20
|
+
return trimmed.startsWith('|') && trimmed.endsWith('|');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function tableCells(line) {
|
|
24
|
+
return String(line || '').trim().split('|').slice(1, -1).map(cell => cell.trim());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function classifyLine(line) {
|
|
28
|
+
if (isBlank(line)) return 'blank';
|
|
29
|
+
if (/^\s*#{1,9}\s+\S/.test(line)) return 'heading';
|
|
30
|
+
if (isTableLine(line)) return 'table';
|
|
31
|
+
if (matchListMarker(line)) return 'list';
|
|
32
|
+
return 'paragraph';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Decomposes mixed Markdown into explicit document blocks and validates the
|
|
37
|
+
* structural syntax required by the OOXML generator.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} markdown
|
|
40
|
+
* @returns {{ valid: boolean, normalizedMarkdown: string, blocks: Array<object>, issues: Array<object>, counts: Record<string, number>, requiresStructuredContent: boolean }}
|
|
41
|
+
*/
|
|
42
|
+
export function analyzeStructuredContent(markdown) {
|
|
43
|
+
const source = typeof markdown === 'string' ? markdown.replace(/\r\n?/g, '\n') : String(markdown ?? '');
|
|
44
|
+
const lines = source.split('\n');
|
|
45
|
+
const blocks = [];
|
|
46
|
+
const issues = [];
|
|
47
|
+
let index = 0;
|
|
48
|
+
|
|
49
|
+
while (index < lines.length) {
|
|
50
|
+
if (isBlank(lines[index])) {
|
|
51
|
+
index++;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const kind = classifyLine(lines[index]);
|
|
56
|
+
const startLine = index + 1;
|
|
57
|
+
if (kind === 'heading') {
|
|
58
|
+
const match = lines[index].match(/^\s*(#{1,9})\s+(.+?)\s*$/);
|
|
59
|
+
blocks.push({ type: 'heading', level: match[1].length, text: match[2], markdown: lines[index].trim() });
|
|
60
|
+
index++;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (kind === 'table') {
|
|
65
|
+
const tableLines = [];
|
|
66
|
+
while (index < lines.length && isTableLine(lines[index])) {
|
|
67
|
+
tableLines.push(lines[index].trim());
|
|
68
|
+
index++;
|
|
69
|
+
}
|
|
70
|
+
const separatorPresent = tableLines.length > 1 && TABLE_SEPARATOR.test(tableLines[1]);
|
|
71
|
+
const widths = tableLines.filter(line => !TABLE_SEPARATOR.test(line)).map(line => tableCells(line).length);
|
|
72
|
+
if (!separatorPresent) {
|
|
73
|
+
issues.push({
|
|
74
|
+
severity: 'error',
|
|
75
|
+
code: 'TABLE_SEPARATOR_REQUIRED',
|
|
76
|
+
line: startLine,
|
|
77
|
+
message: 'Markdown tables require a separator row immediately after the header (for example | --- | --- |).'
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
if (widths.length < 2) {
|
|
81
|
+
issues.push({
|
|
82
|
+
severity: 'error',
|
|
83
|
+
code: 'TABLE_DATA_ROW_REQUIRED',
|
|
84
|
+
line: startLine,
|
|
85
|
+
message: 'Markdown tables require a header and at least one data row.'
|
|
86
|
+
});
|
|
87
|
+
} else if (new Set(widths).size > 1) {
|
|
88
|
+
issues.push({
|
|
89
|
+
severity: 'error',
|
|
90
|
+
code: 'TABLE_COLUMN_COUNT_MISMATCH',
|
|
91
|
+
line: startLine,
|
|
92
|
+
message: `Markdown table rows have inconsistent column counts: ${widths.join(', ')}.`
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
blocks.push({
|
|
96
|
+
type: 'table',
|
|
97
|
+
columns: widths[0] || 0,
|
|
98
|
+
rows: Math.max(0, widths.length - 1),
|
|
99
|
+
hasHeader: separatorPresent,
|
|
100
|
+
markdown: tableLines.join('\n')
|
|
101
|
+
});
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (kind === 'list') {
|
|
106
|
+
const listLines = [];
|
|
107
|
+
while (index < lines.length && classifyLine(lines[index]) === 'list') {
|
|
108
|
+
listLines.push(lines[index].trimEnd());
|
|
109
|
+
index++;
|
|
110
|
+
}
|
|
111
|
+
blocks.push({ type: 'list', items: listLines.length, markdown: listLines.join('\n') });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const paragraphLines = [];
|
|
116
|
+
while (index < lines.length && classifyLine(lines[index]) === 'paragraph') {
|
|
117
|
+
paragraphLines.push(lines[index].trim());
|
|
118
|
+
index++;
|
|
119
|
+
}
|
|
120
|
+
const text = paragraphLines.join(' ').trim();
|
|
121
|
+
blocks.push({ type: 'paragraph', text, markdown: text });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const counts = { heading: 0, paragraph: 0, list: 0, table: 0 };
|
|
125
|
+
for (const block of blocks) counts[block.type] = (counts[block.type] || 0) + 1;
|
|
126
|
+
const normalizedMarkdown = blocks.map(block => block.markdown).join('\n\n');
|
|
127
|
+
return {
|
|
128
|
+
valid: issues.every(issue => issue.severity !== 'error'),
|
|
129
|
+
normalizedMarkdown,
|
|
130
|
+
blocks,
|
|
131
|
+
issues,
|
|
132
|
+
counts,
|
|
133
|
+
requiresStructuredContent: blocks.length > 1 || blocks.some(block => block.type === 'heading' || block.type === 'table' || (block.type === 'list' && block.items > 1))
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Builds one atomic full-document replacement operation from validated mixed
|
|
139
|
+
* Markdown. The operation remains a single target mutation; blocks are not
|
|
140
|
+
* emitted as a fragile sequence of operations that would invalidate the anchor.
|
|
141
|
+
*
|
|
142
|
+
* @param {string|object} target
|
|
143
|
+
* @param {string} markdown
|
|
144
|
+
* @param {{ author?: string, generateRedlines?: boolean, existingRevisions?: string }} [options]
|
|
145
|
+
*/
|
|
146
|
+
export function planStructuredReplacement(target, markdown, options = {}) {
|
|
147
|
+
const analysis = analyzeStructuredContent(markdown);
|
|
148
|
+
return {
|
|
149
|
+
...analysis,
|
|
150
|
+
operation: analysis.valid ? {
|
|
151
|
+
type: 'replace',
|
|
152
|
+
target,
|
|
153
|
+
modified: analysis.normalizedMarkdown,
|
|
154
|
+
structuredContent: true,
|
|
155
|
+
...(options.author ? { author: options.author } : {}),
|
|
156
|
+
...(typeof options.generateRedlines === 'boolean' ? { generateRedlines: options.generateRedlines } : {}),
|
|
157
|
+
...(options.existingRevisions ? { existingRevisions: options.existingRevisions } : {})
|
|
158
|
+
} : null
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Compatibility entrypoint for legacy docx-redline skill invocations.
|
|
4
|
+
// All targeting, transaction, validation, and output behavior is delegated to
|
|
5
|
+
// the supported CLI instead of being reimplemented here.
|
|
6
|
+
import { runCli } from '../node/cli.js';
|
|
7
|
+
|
|
8
|
+
const legacyArgs = process.argv.slice(2);
|
|
9
|
+
const input = legacyArgs.shift();
|
|
10
|
+
const operations = legacyArgs.shift();
|
|
11
|
+
const output = legacyArgs[0] && !legacyArgs[0].startsWith('--')
|
|
12
|
+
? legacyArgs.shift()
|
|
13
|
+
: null;
|
|
14
|
+
|
|
15
|
+
const delegatedArgs = ['apply'];
|
|
16
|
+
if (input) delegatedArgs.push(input);
|
|
17
|
+
if (operations) delegatedArgs.push('--operations', operations);
|
|
18
|
+
if (output) delegatedArgs.push('--output', output);
|
|
19
|
+
delegatedArgs.push(...legacyArgs);
|
|
20
|
+
if (!legacyArgs.some(argument => argument === '--author' || argument.startsWith('--author='))) {
|
|
21
|
+
delegatedArgs.push('--author', process.env.DOCX_REDLINE_AUTHOR || 'Agent');
|
|
22
|
+
}
|
|
23
|
+
if (!legacyArgs.some(argument => argument === '--atomic' || argument === '--no-atomic')) {
|
|
24
|
+
delegatedArgs.push('--atomic');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
process.exitCode = await runCli(delegatedArgs);
|