@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,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OOXML Reconciliation Pipeline - Markdown Processor
|
|
3
|
+
*
|
|
4
|
+
* Strips markdown syntax and captures format hints for later application.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const MARKDOWN_PATTERNS = [
|
|
8
|
+
// HTML Bold: <b>text</b>, <strong>text</strong>
|
|
9
|
+
{ regex: /<b>(.+?)<\/b>/i, format: { bold: true } },
|
|
10
|
+
{ regex: /<strong>(.+?)<\/strong>/i, format: { bold: true } },
|
|
11
|
+
// HTML Italic: <i>text</i>, <em>text</em>
|
|
12
|
+
{ regex: /<i>(.+?)<\/i>/i, format: { italic: true } },
|
|
13
|
+
{ regex: /<em>(.+?)<\/em>/i, format: { italic: true } },
|
|
14
|
+
// HTML Underline: <u>text</u>
|
|
15
|
+
{ regex: /<u>(.+?)<\/u>/i, format: { underline: true } },
|
|
16
|
+
// HTML Strikethrough: <s>text</s>, <strike>text</strike>, <del>text</del>
|
|
17
|
+
{ regex: /<s>(.+?)<\/s>/i, format: { strikethrough: true } },
|
|
18
|
+
{ regex: /<strike>(.+?)<\/strike>/i, format: { strikethrough: true } },
|
|
19
|
+
{ regex: /<del>(.+?)<\/del>/i, format: { strikethrough: true } },
|
|
20
|
+
|
|
21
|
+
// Escaped HTML Bold: <b>text</b>, <strong>text</strong>
|
|
22
|
+
{ regex: /<b>(.+?)<\/b>/i, format: { bold: true }, isEscaped: true },
|
|
23
|
+
{ regex: /<strong>(.+?)<\/strong>/i, format: { bold: true }, isEscaped: true },
|
|
24
|
+
// Escaped HTML Italic: <i>text</i>, <em>text</em>
|
|
25
|
+
{ regex: /<i>(.+?)<\/i>/i, format: { italic: true }, isEscaped: true },
|
|
26
|
+
{ regex: /<em>(.+?)<\/em>/i, format: { italic: true }, isEscaped: true },
|
|
27
|
+
// Escaped HTML Underline: <u>text</u>
|
|
28
|
+
{ regex: /<u>(.+?)<\/u>/i, format: { underline: true }, isEscaped: true },
|
|
29
|
+
// Escaped HTML Strikethrough: <s>text</s>
|
|
30
|
+
{ regex: /<s>(.+?)<\/s>/i, format: { strikethrough: true }, isEscaped: true },
|
|
31
|
+
|
|
32
|
+
// Bold + Italic: ***text***
|
|
33
|
+
{ regex: /\*\*\*(.+?)\*\*\*/, format: { bold: true, italic: true } },
|
|
34
|
+
// Bold + Underline: **++text++**
|
|
35
|
+
{ regex: /\*\*\+\+(.+?)\+\+\*\*/, format: { bold: true, underline: true } },
|
|
36
|
+
// Bold: **text** or __text__
|
|
37
|
+
{ regex: /\*\*(.+?)\*\*/, format: { bold: true } },
|
|
38
|
+
{ regex: /__(.+?)__/, format: { bold: true } },
|
|
39
|
+
// Underline: ++text++
|
|
40
|
+
{ regex: /\+\+(.+?)\+\+/, format: { underline: true } },
|
|
41
|
+
// Strikethrough: ~~text~~ or ~text~
|
|
42
|
+
{ regex: /~~(.+?)~~/, format: { strikethrough: true } },
|
|
43
|
+
{ regex: /~(.+?)~/, format: { strikethrough: true } },
|
|
44
|
+
// Italic: *text* or _text_ (using lookahead only for compatibility)
|
|
45
|
+
{ regex: /\*(?!\*)(.+?)\*(?!\*)/, format: { italic: true } },
|
|
46
|
+
{ regex: /_(?!_)(.+?)_(?!_)/, format: { italic: true } }
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Preprocesses markdown text by stripping formatting markers
|
|
51
|
+
* and capturing their positions as format hints.
|
|
52
|
+
*/
|
|
53
|
+
export function preprocessMarkdown(text) {
|
|
54
|
+
if (!text) {
|
|
55
|
+
return { cleanText: '', formatHints: [] };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const formatHints = [];
|
|
59
|
+
let cleanText = '';
|
|
60
|
+
|
|
61
|
+
// Find all matches for all patterns
|
|
62
|
+
const allMatches = [];
|
|
63
|
+
for (const pattern of MARKDOWN_PATTERNS) {
|
|
64
|
+
let match;
|
|
65
|
+
const source = pattern.regex.source || pattern.regex.toString().replace(/^\/|\/[gimuy]*$/g, '');
|
|
66
|
+
const flags = 'g' + (pattern.regex.ignoreCase ? 'i' : '');
|
|
67
|
+
const regex = new RegExp(source, flags);
|
|
68
|
+
|
|
69
|
+
while ((match = regex.exec(text)) !== null) {
|
|
70
|
+
allMatches.push({
|
|
71
|
+
start: match.index,
|
|
72
|
+
end: match.index + match[0].length,
|
|
73
|
+
fullMatch: match[0],
|
|
74
|
+
innerText: pattern.isEscaped ? decodeHtmlEntities(match[1]) : match[1],
|
|
75
|
+
format: pattern.format
|
|
76
|
+
});
|
|
77
|
+
if (match.index === regex.lastIndex) regex.lastIndex++;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Sort: earliest first, then longest first
|
|
82
|
+
allMatches.sort((a, b) => (a.start - b.start) || (b.end - a.end));
|
|
83
|
+
|
|
84
|
+
// Filter to keep only top-level matches
|
|
85
|
+
const topLevelMatches = [];
|
|
86
|
+
let lastEnd = 0;
|
|
87
|
+
for (const match of allMatches) {
|
|
88
|
+
if (match.start >= lastEnd) {
|
|
89
|
+
topLevelMatches.push(match);
|
|
90
|
+
lastEnd = match.end;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Recursive reconstruction
|
|
95
|
+
let lastIndex = 0;
|
|
96
|
+
for (const match of topLevelMatches) {
|
|
97
|
+
cleanText += text.slice(lastIndex, match.start);
|
|
98
|
+
|
|
99
|
+
const subResult = preprocessMarkdown(match.innerText);
|
|
100
|
+
|
|
101
|
+
const segmentStart = cleanText.length;
|
|
102
|
+
cleanText += subResult.cleanText;
|
|
103
|
+
const segmentEnd = cleanText.length;
|
|
104
|
+
|
|
105
|
+
formatHints.push({
|
|
106
|
+
start: segmentStart,
|
|
107
|
+
end: segmentEnd,
|
|
108
|
+
format: match.format
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
for (const subHint of subResult.formatHints) {
|
|
112
|
+
formatHints.push({
|
|
113
|
+
start: segmentStart + subHint.start,
|
|
114
|
+
end: segmentStart + subHint.end,
|
|
115
|
+
format: subHint.format
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
lastIndex = match.end;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
cleanText += text.slice(lastIndex);
|
|
123
|
+
return { cleanText, formatHints };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Checks if any format hints apply to a given offset range.
|
|
128
|
+
*/
|
|
129
|
+
export function getApplicableFormatHints(formatHints, startOffset, endOffset) {
|
|
130
|
+
return formatHints.filter(hint =>
|
|
131
|
+
hint.start < endOffset && hint.end > startOffset
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Merges format objects.
|
|
137
|
+
*/
|
|
138
|
+
export function mergeFormats(...formats) {
|
|
139
|
+
const result = {};
|
|
140
|
+
for (const format of formats) {
|
|
141
|
+
if (format) {
|
|
142
|
+
Object.assign(result, format);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Decodes HTML entities in text.
|
|
150
|
+
*/
|
|
151
|
+
function decodeHtmlEntities(text) {
|
|
152
|
+
if (!text) return '';
|
|
153
|
+
return text
|
|
154
|
+
.replace(/&/g, '&')
|
|
155
|
+
.replace(/</g, '<')
|
|
156
|
+
.replace(/>/g, '>')
|
|
157
|
+
.replace(/"/g, '"')
|
|
158
|
+
.replace(/'/g, "'");
|
|
159
|
+
}
|
|
160
|
+
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OOXML Reconciliation Pipeline - Patching
|
|
3
|
+
*
|
|
4
|
+
* Splits runs at diff boundaries and applies patch operations.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { DiffOp, RunKind } from '../core/types.js';
|
|
8
|
+
import { log } from '../adapters/logger.js';
|
|
9
|
+
import { matchListMarker, stripListMarker } from './list-markers.js';
|
|
10
|
+
import { serializeXml } from '../adapters/xml-adapter.js';
|
|
11
|
+
|
|
12
|
+
const XMLNS_ATTR_REGEX = /\s+xmlns:[^=]+="[^"]*"/g;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Splits runs at diff operation boundaries for precise patching.
|
|
16
|
+
*
|
|
17
|
+
* @param {import('../core/types.js').RunEntry[]} runModel - Original run model
|
|
18
|
+
* @param {import('../core/types.js').DiffOperation[]} diffOps - Diff operations
|
|
19
|
+
* @returns {import('../core/types.js').RunEntry[]} Split run model
|
|
20
|
+
*/
|
|
21
|
+
export function splitRunsAtDiffBoundaries(runModel, diffOps) {
|
|
22
|
+
const boundaries = buildSortedDiffBoundaries(diffOps);
|
|
23
|
+
const newModel = [];
|
|
24
|
+
let boundaryCursor = 0;
|
|
25
|
+
|
|
26
|
+
for (const run of runModel) {
|
|
27
|
+
if (run.kind !== RunKind.TEXT && run.kind !== RunKind.HYPERLINK) {
|
|
28
|
+
newModel.push(run);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
while (boundaryCursor < boundaries.length && boundaries[boundaryCursor] <= run.startOffset) {
|
|
33
|
+
boundaryCursor++;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let cursor = boundaryCursor;
|
|
37
|
+
let currentStart = run.startOffset;
|
|
38
|
+
let hasSplit = false;
|
|
39
|
+
|
|
40
|
+
while (cursor < boundaries.length) {
|
|
41
|
+
const boundary = boundaries[cursor];
|
|
42
|
+
if (boundary >= run.endOffset) break;
|
|
43
|
+
|
|
44
|
+
if (boundary > currentStart) {
|
|
45
|
+
hasSplit = true;
|
|
46
|
+
newModel.push({
|
|
47
|
+
...run,
|
|
48
|
+
text: run.text.slice(currentStart - run.startOffset, boundary - run.startOffset),
|
|
49
|
+
startOffset: currentStart,
|
|
50
|
+
endOffset: boundary
|
|
51
|
+
});
|
|
52
|
+
currentStart = boundary;
|
|
53
|
+
}
|
|
54
|
+
cursor++;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
boundaryCursor = cursor;
|
|
58
|
+
|
|
59
|
+
if (!hasSplit) {
|
|
60
|
+
newModel.push(run);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
newModel.push({
|
|
65
|
+
...run,
|
|
66
|
+
text: run.text.slice(currentStart - run.startOffset),
|
|
67
|
+
startOffset: currentStart,
|
|
68
|
+
endOffset: run.endOffset
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return newModel;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Applies diff operations to the split run model.
|
|
77
|
+
*
|
|
78
|
+
* @param {import('../core/types.js').RunEntry[]} splitModel - Pre-split run model
|
|
79
|
+
* @param {import('../core/types.js').DiffOperation[]} diffOps - Diff operations
|
|
80
|
+
* @param {Object} options - Patching options
|
|
81
|
+
* @param {boolean} options.generateRedlines - Whether to generate track changes
|
|
82
|
+
* @param {string} options.author - Author for track changes
|
|
83
|
+
* @param {import('../core/types.js').FormatHint[]} [options.formatHints] - Format hints
|
|
84
|
+
* @returns {import('../core/types.js').RunEntry[]}
|
|
85
|
+
*/
|
|
86
|
+
export function applyPatches(splitModel, diffOps, options) {
|
|
87
|
+
const { generateRedlines, author } = options;
|
|
88
|
+
const patchedModel = [];
|
|
89
|
+
const processedInsertions = new Set();
|
|
90
|
+
const diffLookup = buildPatchLookupIndex(diffOps);
|
|
91
|
+
const styleLookup = buildTextRunLookup(splitModel);
|
|
92
|
+
const getCoveringDiffOp = createRangeCursorLookup(diffLookup.nonInsertOps);
|
|
93
|
+
const state = {
|
|
94
|
+
containerStack: [],
|
|
95
|
+
lastParagraphStartIndex: -1,
|
|
96
|
+
currentParagraphPPrXml: '',
|
|
97
|
+
currentParagraphPPrElement: null
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
for (const run of splitModel) {
|
|
101
|
+
if (run.kind === RunKind.CONTAINER_START) {
|
|
102
|
+
state.containerStack.push(run.containerId);
|
|
103
|
+
patchedModel.push({ ...run });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (run.kind === RunKind.CONTAINER_END) {
|
|
108
|
+
state.containerStack.pop();
|
|
109
|
+
patchedModel.push({ ...run });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (run.kind === RunKind.PARAGRAPH_START) {
|
|
114
|
+
state.currentParagraphPPrXml = typeof run.pPrXml === 'string' ? run.pPrXml : '';
|
|
115
|
+
state.currentParagraphPPrElement = run.pPrElement || null;
|
|
116
|
+
patchedModel.push({ ...run });
|
|
117
|
+
state.lastParagraphStartIndex = patchedModel.length - 1;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (run.kind === RunKind.BOOKMARK || run.kind === RunKind.DELETION) {
|
|
122
|
+
patchedModel.push({ ...run });
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const op = getCoveringDiffOp(run.startOffset, run.endOffset);
|
|
127
|
+
const insertOps = diffLookup.insertOpsByStartOffset.get(run.startOffset) || [];
|
|
128
|
+
|
|
129
|
+
for (const insertOp of insertOps) {
|
|
130
|
+
if (processedInsertions.has(insertOp)) continue;
|
|
131
|
+
processedInsertions.add(insertOp);
|
|
132
|
+
processInsertionOperation({
|
|
133
|
+
insertOp,
|
|
134
|
+
splitModel,
|
|
135
|
+
styleLookup,
|
|
136
|
+
patchedModel,
|
|
137
|
+
state,
|
|
138
|
+
options,
|
|
139
|
+
generateRedlines,
|
|
140
|
+
author
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!op || op.type === DiffOp.EQUAL) {
|
|
145
|
+
patchedModel.push({
|
|
146
|
+
...run,
|
|
147
|
+
containerContext: state.containerStack.length > 0 ? state.containerStack[state.containerStack.length - 1] : null
|
|
148
|
+
});
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (op.type === DiffOp.DELETE && generateRedlines) {
|
|
153
|
+
patchedModel.push({
|
|
154
|
+
...run,
|
|
155
|
+
kind: RunKind.DELETION,
|
|
156
|
+
author,
|
|
157
|
+
containerContext: state.containerStack.length > 0 ? state.containerStack[state.containerStack.length - 1] : null
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const endOffset = splitModel.length > 0
|
|
163
|
+
? Math.max(...splitModel.map(run => run.endOffset))
|
|
164
|
+
: 0;
|
|
165
|
+
|
|
166
|
+
for (const insertOp of diffLookup.sortedInsertOps) {
|
|
167
|
+
if (insertOp.startOffset < endOffset || processedInsertions.has(insertOp)) continue;
|
|
168
|
+
|
|
169
|
+
const lastRun = splitModel[splitModel.length - 1];
|
|
170
|
+
patchedModel.push({
|
|
171
|
+
kind: generateRedlines ? RunKind.INSERTION : RunKind.TEXT,
|
|
172
|
+
text: insertOp.text,
|
|
173
|
+
rPrXml: lastRun?.rPrXml || '',
|
|
174
|
+
startOffset: insertOp.startOffset,
|
|
175
|
+
endOffset: insertOp.startOffset + insertOp.text.length,
|
|
176
|
+
author: generateRedlines ? author : undefined
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return patchedModel;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function processInsertionOperation(context) {
|
|
184
|
+
const {
|
|
185
|
+
insertOp,
|
|
186
|
+
splitModel,
|
|
187
|
+
styleLookup,
|
|
188
|
+
patchedModel,
|
|
189
|
+
state,
|
|
190
|
+
options,
|
|
191
|
+
generateRedlines,
|
|
192
|
+
author
|
|
193
|
+
} = context;
|
|
194
|
+
|
|
195
|
+
const lines = insertOp.text.split('\n');
|
|
196
|
+
const styleSource = chooseInsertionStyle(styleLookup, insertOp.startOffset, insertOp.text);
|
|
197
|
+
|
|
198
|
+
for (let index = 0; index < lines.length; index++) {
|
|
199
|
+
const parsed = parseInsertionLine(lines[index], options.numberingService, state);
|
|
200
|
+
const lineText = parsed.lineText;
|
|
201
|
+
|
|
202
|
+
if (index > 0) {
|
|
203
|
+
let newPPrXml = resolveCurrentParagraphPPrXml(state);
|
|
204
|
+
if (parsed.isListLine && parsed.numId) {
|
|
205
|
+
newPPrXml = options.numberingService.buildListPPr(parsed.numId, parsed.ilvl);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
patchedModel.push({
|
|
209
|
+
kind: RunKind.PARAGRAPH_START,
|
|
210
|
+
pPrXml: newPPrXml,
|
|
211
|
+
startOffset: insertOp.startOffset,
|
|
212
|
+
endOffset: insertOp.startOffset,
|
|
213
|
+
text: ''
|
|
214
|
+
});
|
|
215
|
+
state.currentParagraphPPrXml = newPPrXml;
|
|
216
|
+
state.currentParagraphPPrElement = null;
|
|
217
|
+
state.lastParagraphStartIndex = patchedModel.length - 1;
|
|
218
|
+
} else if (parsed.isListLine && parsed.numId && state.lastParagraphStartIndex >= 0) {
|
|
219
|
+
const newPPrXml = options.numberingService.buildListPPr(parsed.numId, parsed.ilvl);
|
|
220
|
+
patchedModel[state.lastParagraphStartIndex].pPrXml = newPPrXml;
|
|
221
|
+
patchedModel[state.lastParagraphStartIndex].pPrElement = null;
|
|
222
|
+
state.currentParagraphPPrXml = newPPrXml;
|
|
223
|
+
state.currentParagraphPPrElement = null;
|
|
224
|
+
log(`[Patching] Converted current paragraph to list item: numId=${parsed.numId}, ilvl=${parsed.ilvl}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (lineText.length > 0 || index > 0) {
|
|
228
|
+
patchedModel.push({
|
|
229
|
+
kind: generateRedlines ? RunKind.INSERTION : RunKind.TEXT,
|
|
230
|
+
text: lineText,
|
|
231
|
+
rPrXml: styleSource?.rPrXml || '',
|
|
232
|
+
startOffset: insertOp.startOffset,
|
|
233
|
+
endOffset: insertOp.startOffset + lineText.length,
|
|
234
|
+
author: generateRedlines ? author : undefined,
|
|
235
|
+
containerContext: state.containerStack.length > 0 ? state.containerStack[state.containerStack.length - 1] : null
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function parseInsertionLine(line, numberingService, state) {
|
|
242
|
+
if (!numberingService) {
|
|
243
|
+
return { lineText: line, isListLine: false, numId: null, ilvl: 0 };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const markerMatch = matchListMarker(line, { allowZeroSpaceAfterMarker: true });
|
|
247
|
+
if (!markerMatch) {
|
|
248
|
+
return { lineText: line, isListLine: false, numId: null, ilvl: 0 };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const marker = markerMatch[2].trim();
|
|
252
|
+
const lineFormat = numberingService.detectNumberingFormat(marker);
|
|
253
|
+
const indentMatch = line.match(/^(\s*)/);
|
|
254
|
+
const indentSize = indentMatch ? indentMatch[1].length : 0;
|
|
255
|
+
const indentStep = indentSize >= 4 ? 4 : 2;
|
|
256
|
+
const indentLevel = Math.floor(indentSize / indentStep);
|
|
257
|
+
|
|
258
|
+
const currentPPrXml = resolveCurrentParagraphPPrXml(state);
|
|
259
|
+
const lineText = stripListMarker(line, { allowZeroSpaceAfterMarker: true });
|
|
260
|
+
const numIdMatch = currentPPrXml.match(/w:numId w:val="(\d+)"/);
|
|
261
|
+
const ilvlMatch = currentPPrXml.match(/w:ilvl w:val="(\d+)"/);
|
|
262
|
+
const contextNumId = numIdMatch ? numIdMatch[1] : null;
|
|
263
|
+
const contextIlvl = ilvlMatch ? parseInt(ilvlMatch[1], 10) : 0;
|
|
264
|
+
|
|
265
|
+
const numId = numberingService.getOrCreateNumId(
|
|
266
|
+
{ type: lineFormat.format },
|
|
267
|
+
{ numId: contextNumId, type: 'unknown' }
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const ilvl = lineFormat.format === 'outline'
|
|
271
|
+
? Math.min(8, lineFormat.depth)
|
|
272
|
+
: Math.min(8, indentLevel + contextIlvl);
|
|
273
|
+
|
|
274
|
+
return { lineText, isListLine: true, numId, ilvl };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function resolveCurrentParagraphPPrXml(state) {
|
|
278
|
+
if (state.currentParagraphPPrXml) {
|
|
279
|
+
return state.currentParagraphPPrXml;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!state.currentParagraphPPrElement) {
|
|
283
|
+
return '';
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
state.currentParagraphPPrXml = serializeXml(state.currentParagraphPPrElement).replace(XMLNS_ATTR_REGEX, '');
|
|
287
|
+
return state.currentParagraphPPrXml;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function chooseInsertionStyle(styleLookup, offset, insertText) {
|
|
291
|
+
const prevRun = styleLookup.findRunBefore(offset);
|
|
292
|
+
const nextRun = styleLookup.findRunAfter(offset);
|
|
293
|
+
|
|
294
|
+
if (!prevRun && !nextRun) return null;
|
|
295
|
+
if (!prevRun) return nextRun;
|
|
296
|
+
if (!nextRun) return prevRun;
|
|
297
|
+
|
|
298
|
+
if (insertText && insertText.endsWith(' ')) return nextRun;
|
|
299
|
+
if (insertText && insertText.startsWith(' ')) return prevRun;
|
|
300
|
+
|
|
301
|
+
return prevRun;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function buildTextRunLookup(runModel) {
|
|
305
|
+
const textRuns = runModel.filter(run => run.kind === RunKind.TEXT);
|
|
306
|
+
const starts = textRuns.map(run => run.startOffset);
|
|
307
|
+
const ends = textRuns.map(run => run.endOffset);
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
findRunBefore(offset) {
|
|
311
|
+
let left = 0;
|
|
312
|
+
let right = ends.length - 1;
|
|
313
|
+
let answer = -1;
|
|
314
|
+
|
|
315
|
+
while (left <= right) {
|
|
316
|
+
const middle = (left + right) >> 1;
|
|
317
|
+
if (ends[middle] <= offset) {
|
|
318
|
+
answer = middle;
|
|
319
|
+
left = middle + 1;
|
|
320
|
+
} else {
|
|
321
|
+
right = middle - 1;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return answer >= 0 ? textRuns[answer] : null;
|
|
326
|
+
},
|
|
327
|
+
|
|
328
|
+
findRunAfter(offset) {
|
|
329
|
+
let left = 0;
|
|
330
|
+
let right = starts.length - 1;
|
|
331
|
+
let answer = -1;
|
|
332
|
+
|
|
333
|
+
while (left <= right) {
|
|
334
|
+
const middle = (left + right) >> 1;
|
|
335
|
+
if (starts[middle] >= offset) {
|
|
336
|
+
answer = middle;
|
|
337
|
+
right = middle - 1;
|
|
338
|
+
} else {
|
|
339
|
+
left = middle + 1;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return answer >= 0 ? textRuns[answer] : null;
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function createRangeCursorLookup(operations) {
|
|
349
|
+
let cursor = 0;
|
|
350
|
+
return (startOffset, endOffset) => {
|
|
351
|
+
while (cursor < operations.length && operations[cursor].endOffset <= startOffset) {
|
|
352
|
+
cursor++;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const operation = operations[cursor];
|
|
356
|
+
if (!operation) return null;
|
|
357
|
+
if (operation.startOffset <= startOffset && operation.endOffset >= endOffset) {
|
|
358
|
+
return operation;
|
|
359
|
+
}
|
|
360
|
+
return null;
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function buildSortedDiffBoundaries(diffOps) {
|
|
365
|
+
const unique = new Set();
|
|
366
|
+
for (const op of diffOps) {
|
|
367
|
+
unique.add(op.startOffset);
|
|
368
|
+
unique.add(op.endOffset);
|
|
369
|
+
}
|
|
370
|
+
return Array.from(unique).sort((a, b) => a - b);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Builds indexed diff lookups for patching hot paths.
|
|
375
|
+
*
|
|
376
|
+
* @param {import('../core/types.js').DiffOperation[]} diffOps - Diff operations
|
|
377
|
+
* @returns {{
|
|
378
|
+
* insertOpsByStartOffset: Map<number, import('../core/types.js').DiffOperation[]>,
|
|
379
|
+
* nonInsertOps: import('../core/types.js').DiffOperation[],
|
|
380
|
+
* sortedInsertOps: import('../core/types.js').DiffOperation[]
|
|
381
|
+
* }}
|
|
382
|
+
*/
|
|
383
|
+
function buildPatchLookupIndex(diffOps) {
|
|
384
|
+
const insertOpsByStartOffset = new Map();
|
|
385
|
+
const nonInsertOps = [];
|
|
386
|
+
const sortedInsertOps = [];
|
|
387
|
+
|
|
388
|
+
for (const op of diffOps) {
|
|
389
|
+
if (op.type === DiffOp.INSERT) {
|
|
390
|
+
if (!insertOpsByStartOffset.has(op.startOffset)) {
|
|
391
|
+
insertOpsByStartOffset.set(op.startOffset, []);
|
|
392
|
+
}
|
|
393
|
+
insertOpsByStartOffset.get(op.startOffset).push(op);
|
|
394
|
+
sortedInsertOps.push(op);
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
nonInsertOps.push(op);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
nonInsertOps.sort((a, b) => a.startOffset - b.startOffset || a.endOffset - b.endOffset);
|
|
401
|
+
sortedInsertOps.sort((a, b) => a.startOffset - b.startOffset || a.endOffset - b.endOffset);
|
|
402
|
+
|
|
403
|
+
return {
|
|
404
|
+
insertOpsByStartOffset,
|
|
405
|
+
nonInsertOps,
|
|
406
|
+
sortedInsertOps
|
|
407
|
+
};
|
|
408
|
+
}
|