@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,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OOXML Reconciliation Pipeline - Table Ingestion
|
|
3
|
+
*
|
|
4
|
+
* Builds virtual-grid table models with merged-cell awareness.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { NS_W } from '../core/types.js';
|
|
8
|
+
import { serializeXml } from '../adapters/xml-adapter.js';
|
|
9
|
+
import {
|
|
10
|
+
getElementsByTagNS,
|
|
11
|
+
getElementsByTagNSOrTag,
|
|
12
|
+
getFirstElementByTag,
|
|
13
|
+
getFirstElementByTagNS
|
|
14
|
+
} from '../core/xml-query.js';
|
|
15
|
+
import { ingestParagraphElement } from './ingestion-paragraph.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Ingests a table into a virtual grid model to handle merged cells.
|
|
19
|
+
*
|
|
20
|
+
* @param {Element} tableNode - w:tbl element
|
|
21
|
+
* @returns {Object}
|
|
22
|
+
*/
|
|
23
|
+
export function ingestTableToVirtualGrid(tableNode) {
|
|
24
|
+
const tblGrid = getFirstElementByTagNS(tableNode, NS_W, 'tblGrid');
|
|
25
|
+
const gridCols = tblGrid ? getElementsByTagNS(tblGrid, NS_W, 'gridCol') : [];
|
|
26
|
+
const colCount = gridCols.length;
|
|
27
|
+
|
|
28
|
+
const trElements = getElementsByTagNS(tableNode, NS_W, 'tr');
|
|
29
|
+
const rowCount = trElements.length;
|
|
30
|
+
|
|
31
|
+
const grid = Array.from({ length: rowCount }, () =>
|
|
32
|
+
Array.from({ length: colCount }, () => null)
|
|
33
|
+
);
|
|
34
|
+
const cellMap = new Map();
|
|
35
|
+
const vMergeOrigins = new Map();
|
|
36
|
+
|
|
37
|
+
for (let rowIdx = 0; rowIdx < trElements.length; rowIdx++) {
|
|
38
|
+
const tr = trElements[rowIdx];
|
|
39
|
+
const tcElements = getElementsByTagNS(tr, NS_W, 'tc');
|
|
40
|
+
let gridCol = 0;
|
|
41
|
+
|
|
42
|
+
for (let tcIdx = 0; tcIdx < tcElements.length; tcIdx++) {
|
|
43
|
+
const tc = tcElements[tcIdx];
|
|
44
|
+
const tcPr = getFirstElementByTagNS(tc, NS_W, 'tcPr');
|
|
45
|
+
|
|
46
|
+
while (gridCol < colCount && grid[rowIdx][gridCol] !== null) {
|
|
47
|
+
gridCol++;
|
|
48
|
+
}
|
|
49
|
+
if (gridCol >= colCount) break;
|
|
50
|
+
|
|
51
|
+
const gridSpanEl = tcPr ? (getFirstElementByTagNS(tcPr, NS_W, 'gridSpan') || getFirstElementByTag(tcPr, 'w:gridSpan')) : null;
|
|
52
|
+
const colSpan = parseInt(gridSpanEl?.getAttribute('w:val') || '1', 10);
|
|
53
|
+
|
|
54
|
+
const vMergeEl = tcPr ? (getFirstElementByTagNS(tcPr, NS_W, 'vMerge') || getFirstElementByTag(tcPr, 'w:vMerge')) : null;
|
|
55
|
+
const vMergeVal = vMergeEl?.getAttribute('w:val');
|
|
56
|
+
const hasVMerge = vMergeEl !== null;
|
|
57
|
+
|
|
58
|
+
let cell;
|
|
59
|
+
if (hasVMerge && vMergeVal !== 'restart') {
|
|
60
|
+
const origin = vMergeOrigins.get(gridCol);
|
|
61
|
+
if (origin) {
|
|
62
|
+
origin.cell.rowSpan++;
|
|
63
|
+
cell = {
|
|
64
|
+
gridRow: rowIdx,
|
|
65
|
+
gridCol,
|
|
66
|
+
rowSpan: 0,
|
|
67
|
+
colSpan,
|
|
68
|
+
tcNode: tc,
|
|
69
|
+
blocks: [],
|
|
70
|
+
tcPrXml: serializeTcPr(tcPr),
|
|
71
|
+
isMergeOrigin: false,
|
|
72
|
+
isMergeContinuation: true,
|
|
73
|
+
mergeOrigin: origin.cell
|
|
74
|
+
};
|
|
75
|
+
} else {
|
|
76
|
+
cell = createRegularCell(rowIdx, gridCol, colSpan, tc, tcPr);
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
const blocks = parseCellBlocks(tc);
|
|
80
|
+
cell = {
|
|
81
|
+
gridRow: rowIdx,
|
|
82
|
+
gridCol,
|
|
83
|
+
rowSpan: 1,
|
|
84
|
+
colSpan,
|
|
85
|
+
tcNode: tc,
|
|
86
|
+
blocks,
|
|
87
|
+
tcPrXml: serializeTcPr(tcPr),
|
|
88
|
+
isMergeOrigin: hasVMerge && vMergeVal === 'restart',
|
|
89
|
+
isMergeContinuation: false,
|
|
90
|
+
getText: () => blocks.map(block => block.acceptedText).join('\n')
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
if (hasVMerge && vMergeVal === 'restart') {
|
|
94
|
+
for (let span = 0; span < colSpan; span++) {
|
|
95
|
+
vMergeOrigins.set(gridCol + span, { originRow: rowIdx, cell });
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
for (let span = 0; span < colSpan; span++) {
|
|
99
|
+
vMergeOrigins.delete(gridCol + span);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (let spanOffset = 0; spanOffset < colSpan; spanOffset++) {
|
|
105
|
+
const targetCol = gridCol + spanOffset;
|
|
106
|
+
if (targetCol < colCount) {
|
|
107
|
+
grid[rowIdx][targetCol] = cell;
|
|
108
|
+
cellMap.set(`${rowIdx},${targetCol}`, cell);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
gridCol += colSpan;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
rowCount,
|
|
118
|
+
colCount,
|
|
119
|
+
grid,
|
|
120
|
+
cellMap,
|
|
121
|
+
tblPrXml: extractTblPr(tableNode),
|
|
122
|
+
tblGridXml: extractTblGrid(tableNode),
|
|
123
|
+
trPrList: Array.from(trElements).map(tr => extractTrPr(tr))
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function createRegularCell(rowIdx, gridCol, colSpan, tc, tcPr) {
|
|
128
|
+
const blocks = parseCellBlocks(tc);
|
|
129
|
+
return {
|
|
130
|
+
gridRow: rowIdx,
|
|
131
|
+
gridCol,
|
|
132
|
+
rowSpan: 1,
|
|
133
|
+
colSpan,
|
|
134
|
+
tcNode: tc,
|
|
135
|
+
blocks,
|
|
136
|
+
tcPrXml: serializeTcPr(tcPr),
|
|
137
|
+
isMergeOrigin: false,
|
|
138
|
+
isMergeContinuation: false,
|
|
139
|
+
getText: () => blocks.map(block => block.acceptedText).join('\n')
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function parseCellBlocks(tcNode) {
|
|
144
|
+
const paragraphs = getElementsByTagNSOrTag(tcNode, NS_W, 'p');
|
|
145
|
+
return paragraphs.map(paragraph => {
|
|
146
|
+
const { runModel, acceptedText, pPr } = ingestParagraphElement(paragraph);
|
|
147
|
+
return { runModel, acceptedText, pPr };
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function serializeTcPr(tcPrNode) {
|
|
152
|
+
if (!tcPrNode) return '<w:tcPr/>';
|
|
153
|
+
return serializeXml(tcPrNode);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function extractTblPr(tableNode) {
|
|
157
|
+
const tblPr = getFirstElementByTagNS(tableNode, NS_W, 'tblPr');
|
|
158
|
+
return tblPr ? serializeXml(tblPr) : '<w:tblPr/>';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function extractTblGrid(tableNode) {
|
|
162
|
+
const tblGrid = getFirstElementByTagNS(tableNode, NS_W, 'tblGrid');
|
|
163
|
+
return tblGrid ? serializeXml(tblGrid) : '<w:tblGrid/>';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function extractTrPr(trNode) {
|
|
167
|
+
const trPr = getFirstElementByTagNS(trNode, NS_W, 'trPr');
|
|
168
|
+
return trPr ? serializeXml(trPr) : '<w:trPr/>';
|
|
169
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared XML helpers for ingestion flows.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns all children as an array.
|
|
7
|
+
*
|
|
8
|
+
* @param {Node} node - Parent node
|
|
9
|
+
* @returns {Node[]}
|
|
10
|
+
*/
|
|
11
|
+
export function childNodesToArray(node) {
|
|
12
|
+
return Array.from(node?.childNodes || []);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Serializes all attributes into a plain string.
|
|
17
|
+
*
|
|
18
|
+
* @param {Element} element - Element with attributes
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
export function serializeAttributes(element) {
|
|
22
|
+
return Array.from(element.attributes)
|
|
23
|
+
.map(attr => `${attr.name}="${attr.value}"`)
|
|
24
|
+
.join(' ');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns true when a node belongs to the target namespace and optional local name.
|
|
29
|
+
*
|
|
30
|
+
* @param {Node} node - Candidate node
|
|
31
|
+
* @param {string} namespaceUri - Namespace URI
|
|
32
|
+
* @param {string} [localName] - Optional local name
|
|
33
|
+
* @returns {boolean}
|
|
34
|
+
*/
|
|
35
|
+
export function isNamespacedNode(node, namespaceUri, localName = '') {
|
|
36
|
+
if (!node || node.namespaceURI !== namespaceUri) return false;
|
|
37
|
+
if (!localName) return true;
|
|
38
|
+
return node.localName === localName;
|
|
39
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OOXML Reconciliation Pipeline - Ingestion Facade
|
|
3
|
+
*
|
|
4
|
+
* Backward-compatible exports for paragraph and table ingestion.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { ingestOoxml, ingestParagraphElement, detectNumberingContext } from './ingestion-paragraph.js';
|
|
8
|
+
export { ingestTableToVirtualGrid } from './ingestion-table.js';
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List generation flow extracted from ReconciliationPipeline.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { preprocessMarkdown } from './markdown-processor.js';
|
|
6
|
+
import { matchListMarker, stripListMarker } from './list-markers.js';
|
|
7
|
+
import { serializeToOoxml } from './serialization.js';
|
|
8
|
+
import { generateTableOoxml } from '../services/table-reconciliation.js';
|
|
9
|
+
import { parseTable } from './content-analysis.js';
|
|
10
|
+
import { log } from '../adapters/logger.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Executes list generation when paragraph content expands into list/table blocks.
|
|
14
|
+
*
|
|
15
|
+
* @param {Object} options - Generation options
|
|
16
|
+
* @param {string} options.cleanText - Preprocessed target text
|
|
17
|
+
* @param {Object|null} options.numberingContext - Existing numbering context
|
|
18
|
+
* @param {Array} [options.originalRunModel=[]] - Original run model
|
|
19
|
+
* @param {string} [options.originalText=''] - Original text fallback
|
|
20
|
+
* @param {boolean} [options.generateRedlines=true] - Track-change toggle
|
|
21
|
+
* @param {string} [options.author='AI'] - Author metadata
|
|
22
|
+
* @param {string|null} [options.font=null] - Optional font
|
|
23
|
+
* @param {import('../services/numbering-service.js').NumberingService} options.numberingService - Numbering service
|
|
24
|
+
* @returns {Promise<import('../core/types.js').ReconciliationResult>}
|
|
25
|
+
*/
|
|
26
|
+
export async function executeListGeneration(options) {
|
|
27
|
+
const {
|
|
28
|
+
cleanText,
|
|
29
|
+
numberingContext,
|
|
30
|
+
originalRunModel = [],
|
|
31
|
+
originalText = '',
|
|
32
|
+
generateRedlines = true,
|
|
33
|
+
author = 'AI',
|
|
34
|
+
font = null,
|
|
35
|
+
numberingService
|
|
36
|
+
} = options;
|
|
37
|
+
|
|
38
|
+
const normalizedListText = normalizeCompositeListMarkers(cleanText);
|
|
39
|
+
const lineMetadata = buildLineMetadata(normalizedListText);
|
|
40
|
+
const rawLines = lineMetadata.map(line => line.raw);
|
|
41
|
+
const results = [];
|
|
42
|
+
|
|
43
|
+
let deletionRuns = [];
|
|
44
|
+
if (generateRedlines) {
|
|
45
|
+
if (originalRunModel && originalRunModel.length > 0) {
|
|
46
|
+
deletionRuns = originalRunModel
|
|
47
|
+
.filter(run => run.kind === 'text' || run.kind === 'run')
|
|
48
|
+
.map(run => ({ ...run, kind: 'deletion', author }));
|
|
49
|
+
} else if (originalText && originalText.trim().length > 0) {
|
|
50
|
+
const trimmed = originalText.trim();
|
|
51
|
+
deletionRuns = [{
|
|
52
|
+
kind: 'deletion',
|
|
53
|
+
text: trimmed,
|
|
54
|
+
author,
|
|
55
|
+
startOffset: 0,
|
|
56
|
+
endOffset: trimmed.length
|
|
57
|
+
}];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const indentStep = detectIndentationStep(rawLines);
|
|
62
|
+
log(`[ListGen] Detected indentation step: ${indentStep} spaces/chars`);
|
|
63
|
+
|
|
64
|
+
const firstMarker = lineMetadata.find(line => line.marker)?.marker || '';
|
|
65
|
+
const { format: defaultFormat } = numberingService.detectNumberingFormat(firstMarker);
|
|
66
|
+
log(`[ListGen] Detected primary marker: "${firstMarker}", format: ${defaultFormat}`);
|
|
67
|
+
|
|
68
|
+
for (let i = 0; i < lineMetadata.length; i++) {
|
|
69
|
+
const tableBlock = collectMarkdownTableBlock(lineMetadata, i);
|
|
70
|
+
if (tableBlock) {
|
|
71
|
+
const tableData = parseTable(tableBlock.tableText);
|
|
72
|
+
if (tableData.headers.length > 0 || tableData.rows.length > 0) {
|
|
73
|
+
if (generateRedlines && results.length === 0 && deletionRuns.length > 0) {
|
|
74
|
+
results.push(serializeToOoxml(deletionRuns, null, [], {
|
|
75
|
+
author,
|
|
76
|
+
generateRedlines,
|
|
77
|
+
font
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
results.push(generateTableOoxml(tableData, { generateRedlines, author }));
|
|
82
|
+
i = tableBlock.endIndex;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const line = lineMetadata[i];
|
|
88
|
+
const entry = buildListEntry(
|
|
89
|
+
line,
|
|
90
|
+
i,
|
|
91
|
+
indentStep,
|
|
92
|
+
numberingContext,
|
|
93
|
+
numberingService,
|
|
94
|
+
generateRedlines,
|
|
95
|
+
author,
|
|
96
|
+
font,
|
|
97
|
+
deletionRuns
|
|
98
|
+
);
|
|
99
|
+
results.push(entry.ooxml);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const numberingXml = numberingService.generateNumberingXml();
|
|
103
|
+
const finalOoxml = results.join('');
|
|
104
|
+
const blankParagraph = '<w:p><w:pPr></w:pPr></w:p>';
|
|
105
|
+
const oxmlWithSpacing = finalOoxml + blankParagraph;
|
|
106
|
+
|
|
107
|
+
log(`[ListGen] ✅ Generated OOXML for ${results.length} list items, total length: ${oxmlWithSpacing.length}`);
|
|
108
|
+
log(`[ListGen] First 200 chars: ${oxmlWithSpacing.substring(0, 200)}...`);
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
ooxml: oxmlWithSpacing,
|
|
112
|
+
isValid: true,
|
|
113
|
+
warnings: ['Paragraph expanded to list fragment'],
|
|
114
|
+
type: 'fragment',
|
|
115
|
+
includeNumbering: true,
|
|
116
|
+
numberingXml
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Heuristically detects indentation step (spaces/tabs per level).
|
|
122
|
+
*
|
|
123
|
+
* @param {string[]} lines - Raw lines
|
|
124
|
+
* @returns {number}
|
|
125
|
+
*/
|
|
126
|
+
export function detectIndentationStep(lines) {
|
|
127
|
+
const indentations = lines
|
|
128
|
+
.map(line => line.match(/^(\s*)/)[0].length)
|
|
129
|
+
.filter(length => length > 0)
|
|
130
|
+
.sort((a, b) => a - b);
|
|
131
|
+
|
|
132
|
+
if (indentations.length === 0) return 2;
|
|
133
|
+
|
|
134
|
+
let minJump = indentations[0];
|
|
135
|
+
for (let i = 1; i < indentations.length; i++) {
|
|
136
|
+
const jump = indentations[i] - indentations[i - 1];
|
|
137
|
+
if (jump > 0 && jump < minJump) {
|
|
138
|
+
minJump = jump;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return minJump || 2;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function buildLineMetadata(cleanText) {
|
|
146
|
+
return cleanText
|
|
147
|
+
.split('\n')
|
|
148
|
+
.filter(line => line.trim().length > 0)
|
|
149
|
+
.map(raw => {
|
|
150
|
+
const markerMatch = matchListMarker(raw);
|
|
151
|
+
const headerMatch = raw.match(/^\s*(#{1,9})\s+(.*)/);
|
|
152
|
+
return {
|
|
153
|
+
raw,
|
|
154
|
+
marker: markerMatch ? markerMatch[2].trim() : '',
|
|
155
|
+
headerMatch,
|
|
156
|
+
indentSize: (raw.match(/^(\s*)/)?.[1].length) || 0,
|
|
157
|
+
isTableLine: /^\s*\|/.test(raw),
|
|
158
|
+
isTableSeparator: /^\s*\|?[\s:-]*-[-\s|:]*\|?\s*$/.test(raw)
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function normalizeCompositeListMarkers(text) {
|
|
164
|
+
const lines = String(text || '').split('\n');
|
|
165
|
+
const nonEmptyIndexes = lines
|
|
166
|
+
.map((line, index) => ({ line, index }))
|
|
167
|
+
.filter(entry => entry.line.trim().length > 0);
|
|
168
|
+
if (nonEmptyIndexes.length < 2) return text;
|
|
169
|
+
|
|
170
|
+
const rewrites = [];
|
|
171
|
+
let rewrittenCount = 0;
|
|
172
|
+
|
|
173
|
+
for (const { line, index } of nonEmptyIndexes) {
|
|
174
|
+
const outerMarkerMatch = matchListMarker(line);
|
|
175
|
+
if (!outerMarkerMatch) return text;
|
|
176
|
+
|
|
177
|
+
const stripped = stripListMarker(line);
|
|
178
|
+
const innerMarkerMatch = matchListMarker(stripped);
|
|
179
|
+
if (!innerMarkerMatch) return text;
|
|
180
|
+
|
|
181
|
+
const outerMarker = (outerMarkerMatch[2] || '').trim();
|
|
182
|
+
const innerMarker = (innerMarkerMatch[2] || '').trim();
|
|
183
|
+
if (!outerMarker || !innerMarker || outerMarker === innerMarker) return text;
|
|
184
|
+
|
|
185
|
+
const indent = outerMarkerMatch[1] || '';
|
|
186
|
+
const rewritten = `${indent}${stripped.trimStart()}`;
|
|
187
|
+
rewrites.push({ index, rewritten });
|
|
188
|
+
rewrittenCount++;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (rewrittenCount < 2) return text;
|
|
192
|
+
|
|
193
|
+
const updated = lines.slice();
|
|
194
|
+
for (const rewrite of rewrites) {
|
|
195
|
+
updated[rewrite.index] = rewrite.rewritten;
|
|
196
|
+
}
|
|
197
|
+
log(`[ListGen] Normalized ${rewrittenCount} composite list markers (e.g., "- A." -> "A.").`);
|
|
198
|
+
return updated.join('\n');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function collectMarkdownTableBlock(lineMetadata, index) {
|
|
202
|
+
const current = lineMetadata[index];
|
|
203
|
+
const next = lineMetadata[index + 1];
|
|
204
|
+
|
|
205
|
+
if (!current?.isTableLine || !next?.isTableLine || !next?.isTableSeparator) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const tableLines = [];
|
|
210
|
+
let cursor = index;
|
|
211
|
+
while (cursor < lineMetadata.length && lineMetadata[cursor].isTableLine) {
|
|
212
|
+
tableLines.push(lineMetadata[cursor].raw);
|
|
213
|
+
cursor++;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
tableText: tableLines.join('\n'),
|
|
218
|
+
endIndex: cursor - 1
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function buildListEntry(
|
|
223
|
+
line,
|
|
224
|
+
lineIndex,
|
|
225
|
+
indentStep,
|
|
226
|
+
numberingContext,
|
|
227
|
+
numberingService,
|
|
228
|
+
generateRedlines,
|
|
229
|
+
author,
|
|
230
|
+
font,
|
|
231
|
+
deletionRuns
|
|
232
|
+
) {
|
|
233
|
+
let pPrXml = '';
|
|
234
|
+
let segmentText = '';
|
|
235
|
+
|
|
236
|
+
if (line.headerMatch) {
|
|
237
|
+
const level = Math.min(line.headerMatch[1].length, 9);
|
|
238
|
+
const outlineLevel = Math.min(level - 1, 8);
|
|
239
|
+
const headingSizes = [32, 28, 26, 24, 22, 20, 20, 20, 20];
|
|
240
|
+
const headingSize = headingSizes[level - 1] || headingSizes[headingSizes.length - 1];
|
|
241
|
+
segmentText = line.headerMatch[2].trim();
|
|
242
|
+
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>`;
|
|
243
|
+
} else if (line.marker) {
|
|
244
|
+
const lineFormat = numberingService.detectNumberingFormat(line.marker);
|
|
245
|
+
const indentLevel = indentStep > 0 ? Math.floor(line.indentSize / indentStep) : 0;
|
|
246
|
+
const contextLevel = numberingContext?.ilvl || 0;
|
|
247
|
+
const ilvl = lineFormat.format === 'outline'
|
|
248
|
+
? Math.min(8, lineFormat.depth)
|
|
249
|
+
: Math.min(8, indentLevel + contextLevel);
|
|
250
|
+
|
|
251
|
+
segmentText = stripListMarker(line.raw);
|
|
252
|
+
const numId = numberingService.getOrCreateNumId({ type: lineFormat.format }, numberingContext);
|
|
253
|
+
pPrXml = numberingService.buildListPPr(numId, ilvl);
|
|
254
|
+
} else {
|
|
255
|
+
segmentText = line.raw;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const { cleanText, formatHints } = preprocessMarkdown(segmentText);
|
|
259
|
+
const runModel = [];
|
|
260
|
+
|
|
261
|
+
if (lineIndex === 0 && deletionRuns.length > 0) {
|
|
262
|
+
runModel.push(...deletionRuns);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
runModel.push({
|
|
266
|
+
kind: generateRedlines ? 'insertion' : 'run',
|
|
267
|
+
text: cleanText,
|
|
268
|
+
author,
|
|
269
|
+
startOffset: 0,
|
|
270
|
+
endOffset: cleanText.length
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
ooxml: serializeToOoxml(runModel, pPrXml, formatHints, {
|
|
275
|
+
author,
|
|
276
|
+
generateRedlines,
|
|
277
|
+
font
|
|
278
|
+
})
|
|
279
|
+
};
|
|
280
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared list-marker detection/parsing helpers.
|
|
3
|
+
*
|
|
4
|
+
* Keeps marker parsing consistent across router, pipeline, and patching flows.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const LIST_MARKER_CORE = String.raw`(?:\d+(?:\.\d+)*\.?|\((?:\d+|[a-zA-Z]|[ivxlcIVXLC]+)\)|[a-zA-Z]\.|\d+\.|[ivxlcIVXLC]+\.|[-*\u2022])`;
|
|
8
|
+
|
|
9
|
+
const LINE_REGEX_STRICT = new RegExp(`^(\\s*)((?:${LIST_MARKER_CORE})\\s+)`);
|
|
10
|
+
const LINE_REGEX_LOOSE = new RegExp(`^(\\s*)((?:${LIST_MARKER_CORE})\\s*)`);
|
|
11
|
+
const MULTILINE_REGEX_STRICT = new RegExp(`^(\\s*)((?:${LIST_MARKER_CORE})\\s+)`, 'm');
|
|
12
|
+
const MULTILINE_REGEX_LOOSE = new RegExp(`^(\\s*)((?:${LIST_MARKER_CORE})\\s*)`, 'm');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Determines whether text should be treated as list-target content.
|
|
16
|
+
* Strict mode requires at least one whitespace after the marker.
|
|
17
|
+
*
|
|
18
|
+
* @param {string} text - Candidate text
|
|
19
|
+
* @returns {boolean}
|
|
20
|
+
*/
|
|
21
|
+
export function isListTargetStrict(text) {
|
|
22
|
+
if (typeof text !== 'string') return false;
|
|
23
|
+
return text.includes('\n') && MULTILINE_REGEX_STRICT.test(text);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Determines whether text should be treated as list-target content.
|
|
28
|
+
* Loose mode allows markers with optional trailing whitespace.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} text - Candidate text
|
|
31
|
+
* @returns {boolean}
|
|
32
|
+
*/
|
|
33
|
+
export function isListTargetLoose(text) {
|
|
34
|
+
if (typeof text !== 'string') return false;
|
|
35
|
+
return text.includes('\n') && MULTILINE_REGEX_LOOSE.test(text.trim());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Matches a list marker at the start of a line.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} line - Input line
|
|
42
|
+
* @param {Object} [options={}] - Match options
|
|
43
|
+
* @param {boolean} [options.allowZeroSpaceAfterMarker=false] - Allow zero spaces after marker
|
|
44
|
+
* @returns {RegExpMatchArray|null}
|
|
45
|
+
*/
|
|
46
|
+
export function matchListMarker(line, options = {}) {
|
|
47
|
+
const { allowZeroSpaceAfterMarker = false } = options;
|
|
48
|
+
const regex = allowZeroSpaceAfterMarker ? LINE_REGEX_LOOSE : LINE_REGEX_STRICT;
|
|
49
|
+
return line.match(regex);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Extracts the marker text from a line.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} line - Input line
|
|
56
|
+
* @param {Object} [options={}] - Match options
|
|
57
|
+
* @param {boolean} [options.allowZeroSpaceAfterMarker=false] - Allow zero spaces after marker
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
export function extractListMarker(line, options = {}) {
|
|
61
|
+
const match = matchListMarker(line, options);
|
|
62
|
+
return match ? match[2].trim() : '';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Strips the marker (and its immediate trailing spacing) from a line.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} line - Input line
|
|
69
|
+
* @param {Object} [options={}] - Strip options
|
|
70
|
+
* @param {boolean} [options.allowZeroSpaceAfterMarker=false] - Allow zero spaces after marker
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
export function stripListMarker(line, options = {}) {
|
|
74
|
+
const { allowZeroSpaceAfterMarker = false } = options;
|
|
75
|
+
const regex = allowZeroSpaceAfterMarker ? LINE_REGEX_LOOSE : LINE_REGEX_STRICT;
|
|
76
|
+
return line.replace(regex, '');
|
|
77
|
+
}
|