@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.
Files changed (64) hide show
  1. package/AGENTS.md +176 -0
  2. package/ARCHITECTURE.md +121 -0
  3. package/LICENSE +21 -0
  4. package/README.md +177 -0
  5. package/adapters/config.js +43 -0
  6. package/adapters/logger.js +89 -0
  7. package/adapters/xml-adapter.js +74 -0
  8. package/core/list-targeting.js +398 -0
  9. package/core/ooxml-identifiers.js +15 -0
  10. package/core/paragraph-offset-policy.js +50 -0
  11. package/core/paragraph-targeting.js +501 -0
  12. package/core/table-targeting.js +233 -0
  13. package/core/types.js +204 -0
  14. package/core/xml-query.js +99 -0
  15. package/dist/docx-redline-js.esm.js +8801 -0
  16. package/dist/docx-redline-js.esm.js.map +7 -0
  17. package/dist/docx-redline-js.esm.min.js +195 -0
  18. package/dist/docx-redline-js.esm.min.js.map +7 -0
  19. package/engine/format-application.js +358 -0
  20. package/engine/format-extraction.js +232 -0
  21. package/engine/format-paragraph-targeting.js +208 -0
  22. package/engine/format-span-application.js +178 -0
  23. package/engine/formatting-removal.js +330 -0
  24. package/engine/oxml-engine.js +279 -0
  25. package/engine/reconstruction-mapper.js +270 -0
  26. package/engine/reconstruction-mode.js +38 -0
  27. package/engine/reconstruction-writer.js +276 -0
  28. package/engine/rpr-helpers.js +194 -0
  29. package/engine/run-builders.js +235 -0
  30. package/engine/surgical-mode.js +520 -0
  31. package/engine/table-cell-context.js +151 -0
  32. package/engine/table-mode.js +172 -0
  33. package/index.js +308 -0
  34. package/orchestration/list-markdown.js +141 -0
  35. package/orchestration/list-parsing.js +73 -0
  36. package/orchestration/list-structural-fallback.js +530 -0
  37. package/orchestration/redline-operation-converter.js +141 -0
  38. package/orchestration/route-plan.js +160 -0
  39. package/package.json +76 -0
  40. package/pipeline/content-analysis.js +107 -0
  41. package/pipeline/diff-engine.js +204 -0
  42. package/pipeline/ingestion-export.js +255 -0
  43. package/pipeline/ingestion-paragraph.js +351 -0
  44. package/pipeline/ingestion-table.js +169 -0
  45. package/pipeline/ingestion-xml.js +39 -0
  46. package/pipeline/ingestion.js +8 -0
  47. package/pipeline/list-generation.js +280 -0
  48. package/pipeline/list-markers.js +77 -0
  49. package/pipeline/markdown-processor.js +160 -0
  50. package/pipeline/patching.js +408 -0
  51. package/pipeline/pipeline.js +326 -0
  52. package/pipeline/serialization.js +395 -0
  53. package/services/browser-demo-prompt-context.js +345 -0
  54. package/services/comment-builders.js +60 -0
  55. package/services/comment-engine.js +248 -0
  56. package/services/comment-locator.js +197 -0
  57. package/services/comment-package.js +113 -0
  58. package/services/numbering-helpers.js +416 -0
  59. package/services/numbering-service.js +290 -0
  60. package/services/package-builder.js +147 -0
  61. package/services/standalone-docx-plumbing.js +443 -0
  62. package/services/standalone-operation-runner.js +1169 -0
  63. package/services/table-reconciliation.js +344 -0
  64. 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
+