@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
|
+
* Pure routing plan builder used by command-layer Word adapters.
|
|
3
|
+
*
|
|
4
|
+
* This module must remain Word-agnostic.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { parseTable } from '../pipeline/content-analysis.js';
|
|
8
|
+
import { parseMarkdownListContent } from './list-parsing.js';
|
|
9
|
+
|
|
10
|
+
export const RoutePlanKind = Object.freeze({
|
|
11
|
+
STRUCTURED_LIST_DIRECT: 'structured_list_direct',
|
|
12
|
+
EMPTY_FORMATTED_TEXT: 'empty_formatted_text',
|
|
13
|
+
EMPTY_HTML: 'empty_html',
|
|
14
|
+
BLOCK_HTML: 'block_html',
|
|
15
|
+
OOXML_ENGINE: 'ooxml_engine'
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Converts literal escape sequences (for example "\\n") into actual characters.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} content - Raw model content
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
export function normalizeContentEscapesForRouting(content) {
|
|
25
|
+
if (!content || typeof content !== 'string') return content || '';
|
|
26
|
+
return content
|
|
27
|
+
.replace(/\\n/g, '\n')
|
|
28
|
+
.replace(/\\t/g, '\t')
|
|
29
|
+
.replace(/\\r/g, '\r');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Builds a deterministic route plan for command-layer application.
|
|
34
|
+
*
|
|
35
|
+
* @param {Object} params - Plan input
|
|
36
|
+
* @param {string} params.originalText - Current paragraph text
|
|
37
|
+
* @param {string} params.newContent - Requested new content
|
|
38
|
+
* @returns {Object}
|
|
39
|
+
*/
|
|
40
|
+
export function buildReconciliationPlan(params = {}) {
|
|
41
|
+
const originalText = params.originalText || '';
|
|
42
|
+
const normalizedContent = normalizeContentEscapesForRouting(params.newContent || '');
|
|
43
|
+
|
|
44
|
+
const parsedListData = parseMarkdownListContent(normalizedContent) || { type: 'text', items: [] };
|
|
45
|
+
const hasStructuredListContent = normalizedContent.includes('\n') && parsedListData.type !== 'text';
|
|
46
|
+
if (hasStructuredListContent) {
|
|
47
|
+
return {
|
|
48
|
+
kind: RoutePlanKind.STRUCTURED_LIST_DIRECT,
|
|
49
|
+
normalizedContent,
|
|
50
|
+
parsedListData,
|
|
51
|
+
flags: {
|
|
52
|
+
hasStructuredListContent: true,
|
|
53
|
+
isOriginalEmpty: isEmpty(originalText),
|
|
54
|
+
hasInlineFormatting: hasInlineMarkdownFormatting(normalizedContent),
|
|
55
|
+
hasBlockElements: hasBlockElements(normalizedContent),
|
|
56
|
+
hasMarkdownTable: hasMarkdownTable(normalizedContent)
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const isOriginalEmpty = isEmpty(originalText);
|
|
62
|
+
if (isOriginalEmpty) {
|
|
63
|
+
const hasInlineFormatting = hasInlineMarkdownFormatting(normalizedContent);
|
|
64
|
+
if (hasInlineFormatting) {
|
|
65
|
+
return {
|
|
66
|
+
kind: RoutePlanKind.EMPTY_FORMATTED_TEXT,
|
|
67
|
+
normalizedContent,
|
|
68
|
+
parsedListData,
|
|
69
|
+
flags: {
|
|
70
|
+
hasStructuredListContent: false,
|
|
71
|
+
isOriginalEmpty: true,
|
|
72
|
+
hasInlineFormatting: true,
|
|
73
|
+
hasBlockElements: hasBlockElements(normalizedContent),
|
|
74
|
+
hasMarkdownTable: hasMarkdownTable(normalizedContent)
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
kind: RoutePlanKind.EMPTY_HTML,
|
|
81
|
+
normalizedContent,
|
|
82
|
+
parsedListData,
|
|
83
|
+
flags: {
|
|
84
|
+
hasStructuredListContent: false,
|
|
85
|
+
isOriginalEmpty: true,
|
|
86
|
+
hasInlineFormatting: false,
|
|
87
|
+
hasBlockElements: hasBlockElements(normalizedContent),
|
|
88
|
+
hasMarkdownTable: hasMarkdownTable(normalizedContent)
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const hasBlocks = hasBlockElements(normalizedContent);
|
|
94
|
+
if (hasBlocks) {
|
|
95
|
+
return {
|
|
96
|
+
kind: RoutePlanKind.BLOCK_HTML,
|
|
97
|
+
normalizedContent,
|
|
98
|
+
parsedListData,
|
|
99
|
+
flags: {
|
|
100
|
+
hasStructuredListContent: false,
|
|
101
|
+
isOriginalEmpty: false,
|
|
102
|
+
hasInlineFormatting: hasInlineMarkdownFormatting(normalizedContent),
|
|
103
|
+
hasBlockElements: true,
|
|
104
|
+
hasMarkdownTable: hasMarkdownTable(normalizedContent)
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
kind: RoutePlanKind.OOXML_ENGINE,
|
|
111
|
+
normalizedContent,
|
|
112
|
+
parsedListData,
|
|
113
|
+
flags: {
|
|
114
|
+
hasStructuredListContent: false,
|
|
115
|
+
isOriginalEmpty: false,
|
|
116
|
+
hasInlineFormatting: hasInlineMarkdownFormatting(normalizedContent),
|
|
117
|
+
hasBlockElements: false,
|
|
118
|
+
hasMarkdownTable: hasMarkdownTable(normalizedContent)
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isEmpty(text) {
|
|
124
|
+
return !text || text.trim().length === 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function hasMarkdownTable(content) {
|
|
128
|
+
if (!content || !content.includes('|')) return false;
|
|
129
|
+
const tableData = parseTable(content);
|
|
130
|
+
return tableData.rows.length > 0 || tableData.headers.length > 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function hasInlineMarkdownFormatting(text) {
|
|
134
|
+
if (!text) return false;
|
|
135
|
+
return /(\*\*.+?\*\*|\*.+?\*|__.+?__|_.+?_|`.+?`|~~.+?~~|\+\+.+?\+\+)/.test(text);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function hasBlockElements(content) {
|
|
139
|
+
if (!content) return false;
|
|
140
|
+
|
|
141
|
+
const hasUnorderedList = /^[\s]*[-*+]\s+/m.test(content);
|
|
142
|
+
const hasOrderedList = /^[\s]*\d+\.\s+/m.test(content);
|
|
143
|
+
const hasOutlineList = /^[\s]*\d+\.\d+(?:\.\d+)*\.?\s+/m.test(content);
|
|
144
|
+
const hasAlphaDotList = /^[\s]*[A-Za-z]\.\s+/m.test(content);
|
|
145
|
+
const hasRomanDotList = /^[\s]*[ivxlcIVXLC]+\.\s+/m.test(content);
|
|
146
|
+
const hasAlphaList = /^[\s]*\([a-z]\)\s+/m.test(content);
|
|
147
|
+
const hasTable = /\|.*\|.*\n/.test(content);
|
|
148
|
+
const hasHeading = /^#{1,9}\s/m.test(content);
|
|
149
|
+
const hasMultipleLineBreaks = content.includes('\n\n');
|
|
150
|
+
|
|
151
|
+
return hasUnorderedList
|
|
152
|
+
|| hasOrderedList
|
|
153
|
+
|| hasOutlineList
|
|
154
|
+
|| hasAlphaDotList
|
|
155
|
+
|| hasRomanDotList
|
|
156
|
+
|| hasAlphaList
|
|
157
|
+
|| hasTable
|
|
158
|
+
|| hasHeading
|
|
159
|
+
|| hasMultipleLineBreaks;
|
|
160
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ansonlai/docx-redline-js",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Host-independent OOXML reconciliation engine for .docx manipulation with track changes",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./index.js",
|
|
8
|
+
"module": "./index.js",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": "./index.js",
|
|
12
|
+
"default": "./index.js"
|
|
13
|
+
},
|
|
14
|
+
"./standalone": "./standalone.js",
|
|
15
|
+
"./adapters/*": "./adapters/*",
|
|
16
|
+
"./core/*": "./core/*",
|
|
17
|
+
"./engine/*": "./engine/*",
|
|
18
|
+
"./pipeline/*": "./pipeline/*",
|
|
19
|
+
"./services/*": "./services/*",
|
|
20
|
+
"./orchestration/*": "./orchestration/*"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"adapters/",
|
|
24
|
+
"core/",
|
|
25
|
+
"engine/",
|
|
26
|
+
"pipeline/",
|
|
27
|
+
"services/",
|
|
28
|
+
"orchestration/",
|
|
29
|
+
"index.js",
|
|
30
|
+
"standalone.js",
|
|
31
|
+
"dist/",
|
|
32
|
+
"ARCHITECTURE.md",
|
|
33
|
+
"AGENTS.md",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"diff-match-patch": "^1.0.5"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@xmldom/xmldom": ">=0.8.0"
|
|
42
|
+
},
|
|
43
|
+
"peerDependenciesMeta": {
|
|
44
|
+
"@xmldom/xmldom": {
|
|
45
|
+
"optional": true
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"esbuild": "^0.24.0",
|
|
50
|
+
"@xmldom/xmldom": "^0.9.0"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"build": "node scripts/build.mjs",
|
|
54
|
+
"test": "node scripts/run-tests.mjs",
|
|
55
|
+
"test:isolation": "node tests/no_word_api_standalone_check.mjs && node tests/core_dependency_graph_check.mjs",
|
|
56
|
+
"prepublishOnly": "npm run test:isolation && npm run build"
|
|
57
|
+
},
|
|
58
|
+
"keywords": [
|
|
59
|
+
"docx",
|
|
60
|
+
"ooxml",
|
|
61
|
+
"reconciliation",
|
|
62
|
+
"track-changes",
|
|
63
|
+
"redlines",
|
|
64
|
+
"word",
|
|
65
|
+
"office",
|
|
66
|
+
"document",
|
|
67
|
+
"xml"
|
|
68
|
+
],
|
|
69
|
+
"repository": {
|
|
70
|
+
"type": "git",
|
|
71
|
+
"url": "https://github.com/YOUR_ORG/docx-redline-js.git"
|
|
72
|
+
},
|
|
73
|
+
"engines": {
|
|
74
|
+
"node": ">=18.0.0"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content analysis helpers for paragraph/list/table classification.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { ContentType } from '../core/types.js';
|
|
6
|
+
import { matchListMarker, stripListMarker } from './list-markers.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parses table from markdown-style table text.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} text - Table text
|
|
12
|
+
* @returns {{ headers: string[], rows: string[][], hasHeader: boolean }}
|
|
13
|
+
*/
|
|
14
|
+
export function parseTable(text) {
|
|
15
|
+
const lines = text
|
|
16
|
+
.split('\n')
|
|
17
|
+
.map(line => line.trim())
|
|
18
|
+
.filter(line => line.startsWith('|'));
|
|
19
|
+
if (lines.length === 0) {
|
|
20
|
+
return { headers: [], rows: [], hasHeader: false };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const isSeparatorLine = line => {
|
|
24
|
+
const normalized = line.replace(/\s+/g, '');
|
|
25
|
+
return /^\|:?-{3,}:?(\|:?-{3,}:?)+\|?$/.test(normalized);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const hasHeader = lines.some(isSeparatorLine);
|
|
29
|
+
const dataLines = lines.filter(line => !isSeparatorLine(line));
|
|
30
|
+
const parsedRows = dataLines.map(line =>
|
|
31
|
+
line
|
|
32
|
+
.split('|')
|
|
33
|
+
.slice(1, -1)
|
|
34
|
+
.map(cell => cell.trim())
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
if (!hasHeader) {
|
|
38
|
+
return {
|
|
39
|
+
headers: [],
|
|
40
|
+
rows: parsedRows,
|
|
41
|
+
hasHeader: false
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
headers: parsedRows[0] || [],
|
|
47
|
+
rows: parsedRows.slice(1),
|
|
48
|
+
hasHeader: true
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Parses list items from markdown-style list text.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} text - List text
|
|
56
|
+
* @returns {Array<{ line: string, text: string, marker: string, indent: number, level: number, listType: 'bullet'|'numbered' }>}
|
|
57
|
+
*/
|
|
58
|
+
export function parseListItems(text) {
|
|
59
|
+
const lines = text.split('\n').filter(line => line.trim().length > 0);
|
|
60
|
+
const items = [];
|
|
61
|
+
|
|
62
|
+
lines.forEach(line => {
|
|
63
|
+
const markerMatch = matchListMarker(line, { allowZeroSpaceAfterMarker: true });
|
|
64
|
+
if (!markerMatch) return;
|
|
65
|
+
|
|
66
|
+
const marker = markerMatch[2].trim();
|
|
67
|
+
const indent = (line.match(/^(\s*)/)?.[1].length) || 0;
|
|
68
|
+
const listType = /^[-*+•]/.test(marker) ? 'bullet' : 'numbered';
|
|
69
|
+
const outlineDepth = (marker.match(/\./g) || []).length;
|
|
70
|
+
const level = outlineDepth > 1 ? Math.min(8, outlineDepth - 1) : Math.min(8, Math.floor(indent / 2));
|
|
71
|
+
|
|
72
|
+
items.push({
|
|
73
|
+
line,
|
|
74
|
+
text: stripListMarker(line, { allowZeroSpaceAfterMarker: true }),
|
|
75
|
+
marker,
|
|
76
|
+
indent,
|
|
77
|
+
level,
|
|
78
|
+
listType
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return items;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Detects content type from text.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} text - Text to classify
|
|
89
|
+
* @returns {ContentType}
|
|
90
|
+
*/
|
|
91
|
+
export function detectContentType(text) {
|
|
92
|
+
const normalized = (text || '').trim();
|
|
93
|
+
if (!normalized) return ContentType.PARAGRAPH;
|
|
94
|
+
|
|
95
|
+
const table = parseTable(normalized);
|
|
96
|
+
if (table.headers.length > 0 || table.rows.length > 0) {
|
|
97
|
+
return ContentType.TABLE;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const listItems = parseListItems(normalized);
|
|
101
|
+
if (listItems.length > 0) {
|
|
102
|
+
const hasBullet = listItems.some(item => item.listType === 'bullet');
|
|
103
|
+
return hasBullet ? ContentType.BULLET_LIST : ContentType.NUMBERED_LIST;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return ContentType.PARAGRAPH;
|
|
107
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OOXML Reconciliation Pipeline - Diff Engine
|
|
3
|
+
*
|
|
4
|
+
* Word-level diffing with offset tracking for precise run splitting.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { diff_match_patch } from 'diff-match-patch';
|
|
8
|
+
import { DiffOp } from '../core/types.js';
|
|
9
|
+
|
|
10
|
+
const DMP = new diff_match_patch();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Converts text into word tokens represented as unique characters.
|
|
14
|
+
* This allows DMP to diff at word-level instead of character-level.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} text1 - First text to tokenize
|
|
17
|
+
* @param {string} text2 - Second text to tokenize
|
|
18
|
+
* @returns {{ chars1: string, chars2: string, wordArray: string[] }}
|
|
19
|
+
*/
|
|
20
|
+
export function wordsToChars(text1, text2) {
|
|
21
|
+
const wordArray = [];
|
|
22
|
+
const wordHash = new Map();
|
|
23
|
+
|
|
24
|
+
function tokenize(text) {
|
|
25
|
+
const tokens = [];
|
|
26
|
+
const regex = /(\S+)(\s*)/g;
|
|
27
|
+
let match;
|
|
28
|
+
while ((match = regex.exec(text)) !== null) {
|
|
29
|
+
if (match[1]) tokens.push(match[1]);
|
|
30
|
+
if (match[2]) tokens.push(match[2]);
|
|
31
|
+
}
|
|
32
|
+
return tokens;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function mapTokensToChars(tokens) {
|
|
36
|
+
let chars = '';
|
|
37
|
+
for (const token of tokens) {
|
|
38
|
+
if (wordHash.has(token)) {
|
|
39
|
+
chars += String.fromCharCode(wordHash.get(token));
|
|
40
|
+
} else {
|
|
41
|
+
const charCode = wordArray.length;
|
|
42
|
+
wordArray.push(token);
|
|
43
|
+
wordHash.set(token, charCode);
|
|
44
|
+
chars += String.fromCharCode(charCode);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return chars;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const tokens1 = tokenize(text1);
|
|
51
|
+
const tokens2 = tokenize(text2);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
chars1: mapTokensToChars(tokens1),
|
|
55
|
+
chars2: mapTokensToChars(tokens2),
|
|
56
|
+
wordArray
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Converts character-encoded diffs back to actual word diffs.
|
|
62
|
+
*
|
|
63
|
+
* @param {Array} diffs - DMP diff array with character codes
|
|
64
|
+
* @param {string[]} wordArray - Array mapping char codes to words
|
|
65
|
+
* @returns {Array} DMP-style diff array with actual words
|
|
66
|
+
*/
|
|
67
|
+
export function charsToWords(diffs, wordArray) {
|
|
68
|
+
const wordDiffs = [];
|
|
69
|
+
|
|
70
|
+
for (const [op, chars] of diffs) {
|
|
71
|
+
const parts = [];
|
|
72
|
+
for (let i = 0; i < chars.length; i++) {
|
|
73
|
+
const charCode = chars.charCodeAt(i);
|
|
74
|
+
if (charCode < wordArray.length) {
|
|
75
|
+
parts.push(wordArray[charCode]);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
wordDiffs.push([op, parts.join('')]);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return wordDiffs;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Computes word-level diff tuples using a shared diff engine instance.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} originalText - Original text
|
|
88
|
+
* @param {string} newText - New text
|
|
89
|
+
* @param {{ cleanupSemantic?: boolean }} [options={}] - Diff options
|
|
90
|
+
* @returns {Array<[number, string]>}
|
|
91
|
+
*/
|
|
92
|
+
export function computeWordDiffs(originalText, newText, options = {}) {
|
|
93
|
+
if (originalText === newText) {
|
|
94
|
+
return [[0, originalText]];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!originalText) {
|
|
98
|
+
return [[1, newText]];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!newText) {
|
|
102
|
+
return [[-1, originalText]];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const { cleanupSemantic = true } = options;
|
|
106
|
+
|
|
107
|
+
const { chars1, chars2, wordArray } = wordsToChars(originalText, newText);
|
|
108
|
+
const charDiffs = DMP.diff_main(chars1, chars2);
|
|
109
|
+
if (cleanupSemantic) {
|
|
110
|
+
DMP.diff_cleanupSemantic(charDiffs);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return charsToWords(charDiffs, wordArray);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Computes word-level diff operations with offset tracking.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} originalText - Original text
|
|
120
|
+
* @param {string} newText - New text
|
|
121
|
+
* @param {{ cleanupSemantic?: boolean }} [options={}] - Diff options
|
|
122
|
+
* @returns {import('../core/types.js').DiffOperation[]}
|
|
123
|
+
*/
|
|
124
|
+
export function computeWordLevelDiffOps(originalText, newText, options = {}) {
|
|
125
|
+
// Handle edge cases
|
|
126
|
+
if (originalText === newText) {
|
|
127
|
+
return [{
|
|
128
|
+
type: DiffOp.EQUAL,
|
|
129
|
+
startOffset: 0,
|
|
130
|
+
endOffset: originalText.length,
|
|
131
|
+
text: originalText
|
|
132
|
+
}];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!originalText) {
|
|
136
|
+
return [{
|
|
137
|
+
type: DiffOp.INSERT,
|
|
138
|
+
startOffset: 0,
|
|
139
|
+
endOffset: 0,
|
|
140
|
+
text: newText
|
|
141
|
+
}];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!newText) {
|
|
145
|
+
return [{
|
|
146
|
+
type: DiffOp.DELETE,
|
|
147
|
+
startOffset: 0,
|
|
148
|
+
endOffset: originalText.length,
|
|
149
|
+
text: originalText
|
|
150
|
+
}];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const wordDiffs = computeWordDiffs(originalText, newText, options);
|
|
154
|
+
|
|
155
|
+
// Convert to operations with offsets
|
|
156
|
+
const operations = [];
|
|
157
|
+
let originalOffset = 0;
|
|
158
|
+
|
|
159
|
+
for (const [op, text] of wordDiffs) {
|
|
160
|
+
if (op === 0) { // EQUAL
|
|
161
|
+
operations.push({
|
|
162
|
+
type: DiffOp.EQUAL,
|
|
163
|
+
startOffset: originalOffset,
|
|
164
|
+
endOffset: originalOffset + text.length,
|
|
165
|
+
text
|
|
166
|
+
});
|
|
167
|
+
originalOffset += text.length;
|
|
168
|
+
} else if (op === -1) { // DELETE
|
|
169
|
+
operations.push({
|
|
170
|
+
type: DiffOp.DELETE,
|
|
171
|
+
startOffset: originalOffset,
|
|
172
|
+
endOffset: originalOffset + text.length,
|
|
173
|
+
text
|
|
174
|
+
});
|
|
175
|
+
originalOffset += text.length;
|
|
176
|
+
} else if (op === 1) { // INSERT
|
|
177
|
+
operations.push({
|
|
178
|
+
type: DiffOp.INSERT,
|
|
179
|
+
startOffset: originalOffset,
|
|
180
|
+
endOffset: originalOffset, // Insertions don't span original text
|
|
181
|
+
text
|
|
182
|
+
});
|
|
183
|
+
// Don't advance originalOffset for insertions
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return operations;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Collects all unique boundary offsets from diff operations.
|
|
192
|
+
*
|
|
193
|
+
* @param {import('../core/types.js').DiffOperation[]} diffOps - Diff operations
|
|
194
|
+
* @returns {Set<number>}
|
|
195
|
+
*/
|
|
196
|
+
export function collectDiffBoundaries(diffOps) {
|
|
197
|
+
const boundaries = new Set();
|
|
198
|
+
for (const op of diffOps) {
|
|
199
|
+
boundaries.add(op.startOffset);
|
|
200
|
+
boundaries.add(op.endOffset);
|
|
201
|
+
}
|
|
202
|
+
return boundaries;
|
|
203
|
+
}
|
|
204
|
+
|