@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,255 @@
1
+ /**
2
+ * Word OOXML ingestion helpers for plain-text/markdown export.
3
+ *
4
+ * These helpers are intentionally conservative and only map obvious signals:
5
+ * - Paragraph structure
6
+ * - Heading styles
7
+ * - List paragraph properties
8
+ * - Run-level bold/italic formatting
9
+ */
10
+
11
+ import { createParser } from '../adapters/xml-adapter.js';
12
+ import { NS_W } from '../core/types.js';
13
+ import { getXmlParseError } from '../core/xml-query.js';
14
+
15
+ function hasParserError(doc) {
16
+ if (!doc || !doc.documentElement) return true;
17
+ if (doc.documentElement.localName === 'parsererror') return true;
18
+ return !!getXmlParseError(doc);
19
+ }
20
+
21
+ function parseWordOoxml(ooxml) {
22
+ const source = typeof ooxml === 'string' ? ooxml : String(ooxml || '');
23
+ if (!source.trim()) {
24
+ return null;
25
+ }
26
+
27
+ try {
28
+ const parser = createParser();
29
+ const doc = parser.parseFromString(source, 'application/xml');
30
+ if (hasParserError(doc)) {
31
+ return null;
32
+ }
33
+ return doc;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ function getWordParagraphs(doc) {
40
+ if (!doc) return [];
41
+ const namespaced = Array.from(doc.getElementsByTagNameNS(NS_W, 'p'));
42
+ if (namespaced.length > 0) return namespaced;
43
+ return Array.from(doc.getElementsByTagNameNS('*', 'p')).filter(node => node?.localName === 'p');
44
+ }
45
+
46
+ function getDirectWordChild(node, localName) {
47
+ const children = Array.from(node?.childNodes || []);
48
+ for (const child of children) {
49
+ if (child?.nodeType !== 1) continue;
50
+ if (child.namespaceURI !== NS_W) continue;
51
+ if (child.localName === localName) return child;
52
+ }
53
+ return null;
54
+ }
55
+
56
+ function getWordDescendants(node, localName) {
57
+ return Array.from(node?.getElementsByTagNameNS?.(NS_W, localName) || []);
58
+ }
59
+
60
+ function getWordAttribute(element, names) {
61
+ if (!element) return '';
62
+ for (const name of names) {
63
+ const value = element.getAttribute(name);
64
+ if (value != null && value !== '') return value;
65
+ }
66
+ return '';
67
+ }
68
+
69
+ function hasWordAncestorWithin(node, localName, boundary) {
70
+ let cursor = node?.parentNode || null;
71
+ while (cursor && cursor !== boundary) {
72
+ if (cursor.nodeType === 1 && cursor.namespaceURI === NS_W && cursor.localName === localName) {
73
+ return true;
74
+ }
75
+ cursor = cursor.parentNode;
76
+ }
77
+ return false;
78
+ }
79
+
80
+ function readRunText(run) {
81
+ let text = '';
82
+ for (const child of Array.from(run?.childNodes || [])) {
83
+ if (!child || child.nodeType !== 1 || child.namespaceURI !== NS_W) continue;
84
+ if (child.localName === 't') {
85
+ text += child.textContent || '';
86
+ } else if (child.localName === 'tab') {
87
+ text += '\t';
88
+ } else if (child.localName === 'br' || child.localName === 'cr') {
89
+ text += '\n';
90
+ } else if (child.localName === 'noBreakHyphen') {
91
+ text += '\u2011';
92
+ }
93
+ }
94
+ return text;
95
+ }
96
+
97
+ function getRunFormatting(run) {
98
+ const rPr = getDirectWordChild(run, 'rPr');
99
+ if (!rPr) return { bold: false, italic: false };
100
+ const rStyle = getWordDescendants(rPr, 'rStyle')[0] || null;
101
+ const rStyleValue = getWordAttribute(rStyle, ['w:val', 'val']).toLowerCase();
102
+ const styleImpliesBold = rStyleValue.includes('strong') || rStyleValue.includes('bold');
103
+ const styleImpliesItalic = rStyleValue.includes('italic') || rStyleValue.includes('emphasis');
104
+ return {
105
+ bold: getWordDescendants(rPr, 'b').length > 0 || styleImpliesBold,
106
+ italic: getWordDescendants(rPr, 'i').length > 0 || styleImpliesItalic
107
+ };
108
+ }
109
+
110
+ function collectParagraphSegments(paragraph) {
111
+ const segments = [];
112
+ const runs = Array.from(paragraph?.getElementsByTagNameNS?.(NS_W, 'r') || []);
113
+ for (const run of runs) {
114
+ if (hasWordAncestorWithin(run, 'del', paragraph)) continue;
115
+ const text = readRunText(run);
116
+ if (!text) continue;
117
+ segments.push({
118
+ text,
119
+ ...getRunFormatting(run)
120
+ });
121
+ }
122
+ return segments;
123
+ }
124
+
125
+ function normalizeInlineWhitespace(text) {
126
+ const normalizedLines = String(text || '')
127
+ .replace(/\r/g, '')
128
+ .split('\n')
129
+ .map(line => line.replace(/[ \t]+/g, ' ').trim());
130
+ return normalizedLines.join('\n').trim();
131
+ }
132
+
133
+ function wrapRunMarkdown(text, format) {
134
+ const raw = String(text || '');
135
+ if (!raw) return '';
136
+ if (!format?.bold && !format?.italic) return raw;
137
+
138
+ const match = raw.match(/^(\s*)([\s\S]*?)(\s*)$/);
139
+ if (!match) return raw;
140
+ const leading = match[1] || '';
141
+ const core = match[2] || '';
142
+ const trailing = match[3] || '';
143
+ if (!core.trim()) return raw;
144
+
145
+ let wrapped = core;
146
+ if (format.bold && format.italic) wrapped = `***${core}***`;
147
+ else if (format.bold) wrapped = `**${core}**`;
148
+ else if (format.italic) wrapped = `*${core}*`;
149
+ return `${leading}${wrapped}${trailing}`;
150
+ }
151
+
152
+ function parseHeadingLevel(paragraph) {
153
+ const pPr = getDirectWordChild(paragraph, 'pPr');
154
+ if (!pPr) return null;
155
+
156
+ const pStyle = getWordDescendants(pPr, 'pStyle')[0] || null;
157
+ const styleVal = getWordAttribute(pStyle, ['w:val', 'val']);
158
+ if (styleVal) {
159
+ const match = styleVal.match(/^heading\s*([1-9])$/i);
160
+ if (match) {
161
+ const level = Number.parseInt(match[1], 10);
162
+ if (Number.isInteger(level)) return Math.min(Math.max(level, 1), 6);
163
+ }
164
+ }
165
+
166
+ const outlineLvl = getWordDescendants(pPr, 'outlineLvl')[0] || null;
167
+ const outlineRaw = getWordAttribute(outlineLvl, ['w:val', 'val']);
168
+ const outline = Number.parseInt(outlineRaw, 10);
169
+ if (Number.isInteger(outline) && outline >= 0) {
170
+ return Math.min(outline + 1, 6);
171
+ }
172
+
173
+ return null;
174
+ }
175
+
176
+ function parseListInfo(paragraph) {
177
+ const pPr = getDirectWordChild(paragraph, 'pPr');
178
+ if (!pPr) return null;
179
+
180
+ const numPr = getWordDescendants(pPr, 'numPr')[0] || null;
181
+ if (!numPr) return null;
182
+
183
+ const ilvlEl = getWordDescendants(numPr, 'ilvl')[0] || null;
184
+ const numIdEl = getWordDescendants(numPr, 'numId')[0] || null;
185
+ const ilvl = Number.parseInt(getWordAttribute(ilvlEl, ['w:val', 'val']), 10);
186
+ const numId = getWordAttribute(numIdEl, ['w:val', 'val']);
187
+
188
+ const safeLevel = Number.isInteger(ilvl) && ilvl >= 0 ? ilvl : 0;
189
+ const marker = numId === '1' ? '-' : '1.';
190
+ return { level: safeLevel, marker };
191
+ }
192
+
193
+ function paragraphToPlainText(paragraph) {
194
+ const text = collectParagraphSegments(paragraph).map(segment => segment.text).join('');
195
+ return normalizeInlineWhitespace(text);
196
+ }
197
+
198
+ function paragraphToMarkdown(paragraph) {
199
+ const segments = collectParagraphSegments(paragraph);
200
+ const inline = segments.map(segment => wrapRunMarkdown(segment.text, segment)).join('');
201
+ const normalizedInline = normalizeInlineWhitespace(inline);
202
+ if (!normalizedInline) return '';
203
+
204
+ const headingLevel = parseHeadingLevel(paragraph);
205
+ if (headingLevel != null) {
206
+ return `${'#'.repeat(headingLevel)} ${normalizedInline}`;
207
+ }
208
+
209
+ const list = parseListInfo(paragraph);
210
+ if (list) {
211
+ const indent = ' '.repeat(list.level);
212
+ return `${indent}${list.marker} ${normalizedInline}`;
213
+ }
214
+
215
+ return normalizedInline;
216
+ }
217
+
218
+ /**
219
+ * Ingests Word OOXML and returns readable plain text.
220
+ *
221
+ * @param {string} ooxml
222
+ * @returns {string}
223
+ */
224
+ export function ingestWordOoxmlToPlainText(ooxml) {
225
+ const doc = parseWordOoxml(ooxml);
226
+ if (!doc) return '';
227
+
228
+ const paragraphs = getWordParagraphs(doc);
229
+ if (paragraphs.length === 0) {
230
+ const fallback = normalizeInlineWhitespace(doc.documentElement?.textContent || '');
231
+ return fallback;
232
+ }
233
+
234
+ const lines = paragraphs.map(paragraphToPlainText);
235
+ return lines.join('\n\n').trim();
236
+ }
237
+
238
+ /**
239
+ * Ingests Word OOXML and returns basic markdown.
240
+ *
241
+ * @param {string} ooxml
242
+ * @returns {string}
243
+ */
244
+ export function ingestWordOoxmlToMarkdown(ooxml) {
245
+ const doc = parseWordOoxml(ooxml);
246
+ if (!doc) return '';
247
+
248
+ const paragraphs = getWordParagraphs(doc);
249
+ if (paragraphs.length === 0) {
250
+ return '';
251
+ }
252
+
253
+ const lines = paragraphs.map(paragraphToMarkdown);
254
+ return lines.join('\n\n').trim();
255
+ }
@@ -0,0 +1,351 @@
1
+ /**
2
+ * OOXML Reconciliation Pipeline - Paragraph Ingestion
3
+ *
4
+ * Parses paragraph content into run models and accepted text offsets.
5
+ */
6
+
7
+ import { NS_W, RunKind, ContainerKind } from '../core/types.js';
8
+ import { appendParagraphBoundary, advanceOffsetForParagraphBoundary } from '../core/paragraph-offset-policy.js';
9
+ import { createParser, serializeXml } from '../adapters/xml-adapter.js';
10
+ import { warn, error as logError } from '../adapters/logger.js';
11
+ import {
12
+ getElementsByTagNS,
13
+ getFirstElementByTagNS,
14
+ getFirstElementByTagNSOrTag,
15
+ getXmlParseError
16
+ } from '../core/xml-query.js';
17
+ import { childNodesToArray, isNamespacedNode, serializeAttributes } from './ingestion-xml.js';
18
+
19
+ let containerIdCounter = 0;
20
+
21
+ /**
22
+ * Ingests OOXML paragraph content and builds a run-aware text model.
23
+ *
24
+ * @param {string} ooxmlString - OOXML input
25
+ * @param {{ xmlDoc?: Document|null }} [options={}] - Optional pre-parsed XML document
26
+ * @returns {import('../core/types.js').IngestionResult}
27
+ */
28
+ export function ingestOoxml(ooxmlString, options = {}) {
29
+ const runModel = [];
30
+ let acceptedText = '';
31
+ const preParsedDoc = options.xmlDoc || null;
32
+
33
+ if (!ooxmlString && !preParsedDoc) {
34
+ return { runModel, acceptedText, pPr: null };
35
+ }
36
+
37
+ try {
38
+ const doc = preParsedDoc || (() => {
39
+ const parser = createParser();
40
+ return parser.parseFromString(ooxmlString, 'application/xml');
41
+ })();
42
+
43
+ const parseError = getXmlParseError(doc);
44
+ if (parseError) {
45
+ logError('OOXML parse error:', parseError.textContent);
46
+ return { runModel, acceptedText, pPr: null };
47
+ }
48
+
49
+ const paragraphs = getElementsByTagNS(doc, NS_W, 'p');
50
+ if (paragraphs.length === 0) {
51
+ warn('No paragraphs found in OOXML');
52
+ return { runModel, acceptedText, pPr: null };
53
+ }
54
+
55
+ return ingestParagraphElements(paragraphs, { includeParagraphBoundaries: true });
56
+ } catch (error) {
57
+ logError('Error ingesting OOXML:', error);
58
+ return { runModel, acceptedText, pPr: null };
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Ingests a single paragraph element directly, avoiding serialize+reparse cycles.
64
+ *
65
+ * @param {Element} paragraphElement - Paragraph element
66
+ * @returns {import('../core/types.js').IngestionResult}
67
+ */
68
+ export function ingestParagraphElement(paragraphElement) {
69
+ if (!paragraphElement) {
70
+ return { runModel: [], acceptedText: '', pPr: null };
71
+ }
72
+ return ingestParagraphElements([paragraphElement], { includeParagraphBoundaries: false });
73
+ }
74
+
75
+ /**
76
+ * Extracts numbering metadata from a paragraph.
77
+ *
78
+ * @param {Element} pElement - w:p element
79
+ * @returns {Object|null}
80
+ */
81
+ export function detectNumberingContext(pElement) {
82
+ const pPr = getFirstElementByTagNS(pElement, NS_W, 'pPr');
83
+ if (!pPr) return null;
84
+
85
+ const numPr = getFirstElementByTagNS(pPr, NS_W, 'numPr');
86
+ if (!numPr) return null;
87
+
88
+ const numIdEl = getFirstElementByTagNS(numPr, NS_W, 'numId');
89
+ const ilvlEl = getFirstElementByTagNS(numPr, NS_W, 'ilvl');
90
+ if (!numIdEl) return null;
91
+
92
+ const numId = numIdEl.getAttribute('w:val');
93
+ const type = numId === '1' ? 'bullet' : (numId === '2' ? 'numbered' : 'unknown');
94
+
95
+ return {
96
+ numId,
97
+ ilvl: parseInt(ilvlEl?.getAttribute('w:val') || '0', 10),
98
+ type
99
+ };
100
+ }
101
+
102
+ function ingestParagraphElements(paragraphs, options = {}) {
103
+ const includeParagraphBoundaries = options.includeParagraphBoundaries ?? true;
104
+ const runModel = [];
105
+ let acceptedText = '';
106
+ let currentOffset = 0;
107
+ let firstPPr = null;
108
+
109
+ for (let pIndex = 0; pIndex < paragraphs.length; pIndex++) {
110
+ const pElement = paragraphs[pIndex];
111
+ const pPr = getFirstElementByTagNS(pElement, NS_W, 'pPr');
112
+
113
+ if (pIndex === 0) {
114
+ firstPPr = pPr;
115
+ }
116
+
117
+ runModel.push({
118
+ kind: RunKind.PARAGRAPH_START,
119
+ // Keep pPr as a DOM reference; serialize only when downstream actually needs string XML.
120
+ pPrElement: pPr || null,
121
+ startOffset: currentOffset,
122
+ endOffset: currentOffset,
123
+ text: ''
124
+ });
125
+
126
+ const result = processNodeRecursive(pElement, currentOffset, runModel);
127
+ acceptedText += result.text;
128
+ currentOffset = acceptedText.length;
129
+
130
+ if (includeParagraphBoundaries) {
131
+ acceptedText = appendParagraphBoundary(acceptedText, pIndex, paragraphs.length);
132
+ currentOffset = advanceOffsetForParagraphBoundary(currentOffset, pIndex, paragraphs.length);
133
+ }
134
+ }
135
+
136
+ return { runModel, acceptedText, pPr: firstPPr };
137
+ }
138
+
139
+ function processNodeRecursive(node, currentOffset, runModel) {
140
+ let localOffset = currentOffset;
141
+ let text = '';
142
+
143
+ const handlers = getNodeHandlers(runModel);
144
+ for (const child of childNodesToArray(node)) {
145
+ if (isNamespacedNode(child, NS_W, 'pPr') || isNamespacedNode(child, NS_W, 'proofErr')) {
146
+ continue;
147
+ }
148
+
149
+ const handler = handlers.get(child.localName);
150
+ if (!handler) continue;
151
+
152
+ const result = handler(child, localOffset);
153
+ localOffset = result.offset;
154
+ text += result.text;
155
+ }
156
+
157
+ return { offset: localOffset, text };
158
+ }
159
+
160
+ function getNodeHandlers(runModel) {
161
+ /** @type {Map<string, (child: Node, offset: number) => { offset: number, text: string }>} */
162
+ const handlers = new Map();
163
+
164
+ handlers.set('sdt', (child, offset) => {
165
+ const containerId = `sdt_${containerIdCounter++}`;
166
+ const sdtPr = getFirstElementByTagNS(child, NS_W, 'sdtPr');
167
+ const sdtContent = getFirstElementByTagNS(child, NS_W, 'sdtContent');
168
+
169
+ runModel.push({
170
+ kind: RunKind.CONTAINER_START,
171
+ containerKind: ContainerKind.SDT,
172
+ containerId,
173
+ propertiesXml: sdtPr ? serializeXml(sdtPr) : '',
174
+ startOffset: offset,
175
+ endOffset: offset,
176
+ text: ''
177
+ });
178
+
179
+ const contentResult = sdtContent
180
+ ? processNodeRecursive(sdtContent, offset, runModel)
181
+ : { offset, text: '' };
182
+
183
+ runModel.push({
184
+ kind: RunKind.CONTAINER_END,
185
+ containerKind: ContainerKind.SDT,
186
+ containerId,
187
+ startOffset: contentResult.offset,
188
+ endOffset: contentResult.offset,
189
+ text: ''
190
+ });
191
+
192
+ return contentResult;
193
+ });
194
+
195
+ handlers.set('smartTag', (child, offset) => {
196
+ const containerId = `smartTag_${containerIdCounter++}`;
197
+ runModel.push({
198
+ kind: RunKind.CONTAINER_START,
199
+ containerKind: ContainerKind.SMART_TAG,
200
+ containerId,
201
+ propertiesXml: serializeAttributes(child),
202
+ startOffset: offset,
203
+ endOffset: offset,
204
+ text: ''
205
+ });
206
+
207
+ const contentResult = processNodeRecursive(child, offset, runModel);
208
+ runModel.push({
209
+ kind: RunKind.CONTAINER_END,
210
+ containerKind: ContainerKind.SMART_TAG,
211
+ containerId,
212
+ startOffset: contentResult.offset,
213
+ endOffset: contentResult.offset,
214
+ text: ''
215
+ });
216
+
217
+ return contentResult;
218
+ });
219
+
220
+ handlers.set('del', (child, offset) => {
221
+ const deletionEntry = processDeletion(child, offset);
222
+ if (deletionEntry) {
223
+ runModel.push(deletionEntry);
224
+ }
225
+ return { offset, text: '' };
226
+ });
227
+
228
+ handlers.set('bookmarkStart', (child, offset) => {
229
+ runModel.push({
230
+ kind: RunKind.BOOKMARK,
231
+ nodeXml: serializeXml(child),
232
+ startOffset: offset,
233
+ endOffset: offset,
234
+ text: ''
235
+ });
236
+ return { offset, text: '' };
237
+ });
238
+
239
+ handlers.set('bookmarkEnd', (child, offset) => {
240
+ runModel.push({
241
+ kind: RunKind.BOOKMARK,
242
+ nodeXml: serializeXml(child),
243
+ startOffset: offset,
244
+ endOffset: offset,
245
+ text: ''
246
+ });
247
+ return { offset, text: '' };
248
+ });
249
+
250
+ handlers.set('ins', (child, offset) => processNodeRecursive(child, offset, runModel));
251
+
252
+ handlers.set('hyperlink', (child, offset) => {
253
+ const containerId = `hyperlink_${containerIdCounter++}`;
254
+ const rId = child.getAttribute('r:id') || '';
255
+ const anchor = child.getAttribute('w:anchor') || '';
256
+
257
+ runModel.push({
258
+ kind: RunKind.CONTAINER_START,
259
+ containerKind: 'hyperlink',
260
+ containerId,
261
+ propertiesXml: JSON.stringify({ rId, anchor }),
262
+ startOffset: offset,
263
+ endOffset: offset,
264
+ text: ''
265
+ });
266
+
267
+ const contentResult = processNodeRecursive(child, offset, runModel);
268
+ runModel.push({
269
+ kind: RunKind.CONTAINER_END,
270
+ containerKind: 'hyperlink',
271
+ containerId,
272
+ startOffset: contentResult.offset,
273
+ endOffset: contentResult.offset,
274
+ text: ''
275
+ });
276
+
277
+ return contentResult;
278
+ });
279
+
280
+ handlers.set('r', (child, offset) => {
281
+ const runEntry = processRun(child, offset);
282
+ if (!runEntry || !runEntry.text) {
283
+ return { offset, text: '' };
284
+ }
285
+
286
+ runModel.push(runEntry);
287
+ const nextOffset = offset + runEntry.text.length;
288
+ return { offset: nextOffset, text: runEntry.text };
289
+ });
290
+
291
+ return handlers;
292
+ }
293
+
294
+ function processRun(runElement, startOffset) {
295
+ const rPr = getFirstElementByTagNSOrTag(runElement, NS_W, 'rPr');
296
+ const rPrXml = rPr ? serializeXml(rPr) : '';
297
+
298
+ let text = '';
299
+ for (const child of childNodesToArray(runElement)) {
300
+ const nodeName = child.nodeName;
301
+ if (nodeName.endsWith(':t') || nodeName === 't') {
302
+ text += child.textContent || '';
303
+ } else if (nodeName.endsWith(':br') || nodeName === 'br' || nodeName.endsWith(':cr') || nodeName === 'cr') {
304
+ text += '\n';
305
+ } else if (nodeName.endsWith(':tab') || nodeName === 'tab') {
306
+ text += '\t';
307
+ } else if (nodeName.endsWith(':noBreakHyphen') || nodeName === 'noBreakHyphen') {
308
+ text += '\u2011';
309
+ }
310
+ }
311
+
312
+ if (!text) return null;
313
+
314
+ return {
315
+ kind: RunKind.TEXT,
316
+ text,
317
+ rPrXml,
318
+ startOffset,
319
+ endOffset: startOffset + text.length
320
+ };
321
+ }
322
+
323
+ function processDeletion(delElement, offset) {
324
+ const author = delElement.getAttribute('w:author') || '';
325
+
326
+ let text = '';
327
+ const delTexts = getElementsByTagNS(delElement, NS_W, 'delText');
328
+ for (const delText of delTexts) {
329
+ text += delText.textContent || '';
330
+ }
331
+
332
+ const runs = getElementsByTagNS(delElement, NS_W, 'r');
333
+ for (const run of runs) {
334
+ const innerDelTexts = getElementsByTagNS(run, NS_W, 'delText');
335
+ for (const delText of innerDelTexts) {
336
+ text += delText.textContent || '';
337
+ }
338
+ }
339
+
340
+ if (!text) return null;
341
+
342
+ return {
343
+ kind: RunKind.DELETION,
344
+ text,
345
+ rPrXml: '',
346
+ startOffset: offset,
347
+ endOffset: offset,
348
+ author,
349
+ nodeXml: serializeXml(delElement)
350
+ };
351
+ }