docrev 0.10.2 → 0.11.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 (87) hide show
  1. package/dist/lib/anchor-match.d.ts +3 -1
  2. package/dist/lib/anchor-match.d.ts.map +1 -1
  3. package/dist/lib/anchor-match.js +5 -7
  4. package/dist/lib/anchor-match.js.map +1 -1
  5. package/dist/lib/annotations.d.ts +0 -6
  6. package/dist/lib/annotations.d.ts.map +1 -1
  7. package/dist/lib/annotations.js +26 -8
  8. package/dist/lib/annotations.js.map +1 -1
  9. package/dist/lib/build.d.ts.map +1 -1
  10. package/dist/lib/build.js +9 -6
  11. package/dist/lib/build.js.map +1 -1
  12. package/dist/lib/commands/build.d.ts.map +1 -1
  13. package/dist/lib/commands/build.js +5 -0
  14. package/dist/lib/commands/build.js.map +1 -1
  15. package/dist/lib/commands/doi.d.ts.map +1 -1
  16. package/dist/lib/commands/doi.js +14 -2
  17. package/dist/lib/commands/doi.js.map +1 -1
  18. package/dist/lib/commands/sync.d.ts.map +1 -1
  19. package/dist/lib/commands/sync.js +24 -11
  20. package/dist/lib/commands/sync.js.map +1 -1
  21. package/dist/lib/comment-realign.d.ts +12 -21
  22. package/dist/lib/comment-realign.d.ts.map +1 -1
  23. package/dist/lib/comment-realign.js +40 -348
  24. package/dist/lib/comment-realign.js.map +1 -1
  25. package/dist/lib/dependencies.d.ts +10 -0
  26. package/dist/lib/dependencies.d.ts.map +1 -1
  27. package/dist/lib/dependencies.js +41 -9
  28. package/dist/lib/dependencies.js.map +1 -1
  29. package/dist/lib/doi.d.ts.map +1 -1
  30. package/dist/lib/doi.js +68 -80
  31. package/dist/lib/doi.js.map +1 -1
  32. package/dist/lib/errors.d.ts.map +1 -1
  33. package/dist/lib/errors.js +3 -1
  34. package/dist/lib/errors.js.map +1 -1
  35. package/dist/lib/import.d.ts +7 -4
  36. package/dist/lib/import.d.ts.map +1 -1
  37. package/dist/lib/import.js +58 -62
  38. package/dist/lib/import.js.map +1 -1
  39. package/dist/lib/ooxml.d.ts +207 -0
  40. package/dist/lib/ooxml.d.ts.map +1 -0
  41. package/dist/lib/ooxml.js +634 -0
  42. package/dist/lib/ooxml.js.map +1 -0
  43. package/dist/lib/rate-limiter.d.ts +8 -0
  44. package/dist/lib/rate-limiter.d.ts.map +1 -1
  45. package/dist/lib/rate-limiter.js +41 -4
  46. package/dist/lib/rate-limiter.js.map +1 -1
  47. package/dist/lib/types.d.ts +9 -1
  48. package/dist/lib/types.d.ts.map +1 -1
  49. package/dist/lib/word-extraction.d.ts.map +1 -1
  50. package/dist/lib/word-extraction.js +48 -307
  51. package/dist/lib/word-extraction.js.map +1 -1
  52. package/dist/lib/word.d.ts.map +1 -1
  53. package/dist/lib/word.js +80 -175
  54. package/dist/lib/word.js.map +1 -1
  55. package/dist/lib/wordcomments.d.ts.map +1 -1
  56. package/dist/lib/wordcomments.js +53 -92
  57. package/dist/lib/wordcomments.js.map +1 -1
  58. package/docs-src/build.py +113 -113
  59. package/docs-src/extra.css +208 -208
  60. package/docs-src/md-to-html.lua +6 -6
  61. package/docs-src/template.html +116 -116
  62. package/lib/anchor-match.ts +6 -7
  63. package/lib/annotations.ts +28 -8
  64. package/lib/build.ts +10 -6
  65. package/lib/commands/build.ts +5 -0
  66. package/lib/commands/doi.ts +17 -1
  67. package/lib/commands/sync.ts +26 -13
  68. package/lib/comment-realign.ts +46 -464
  69. package/lib/dependencies.ts +43 -9
  70. package/lib/doi.ts +76 -94
  71. package/lib/errors.ts +5 -1
  72. package/lib/import.ts +65 -70
  73. package/lib/ooxml.ts +768 -0
  74. package/lib/rate-limiter.ts +37 -4
  75. package/lib/types.ts +9 -1
  76. package/lib/word-extraction.ts +49 -330
  77. package/lib/word.ts +86 -203
  78. package/lib/wordcomments.ts +53 -104
  79. package/mkdocs.yml +64 -64
  80. package/package.json +1 -1
  81. package/issues.md +0 -180
  82. package/site/assets/extra.css +0 -208
  83. package/site/commands.html +0 -926
  84. package/site/configuration.html +0 -469
  85. package/site/index.html +0 -288
  86. package/site/troubleshooting.html +0 -461
  87. package/site/workflow.html +0 -518
package/lib/word.ts CHANGED
@@ -6,11 +6,15 @@
6
6
  import * as fs from 'fs';
7
7
  import * as path from 'path';
8
8
  import AdmZip from 'adm-zip';
9
- import { parseString } from 'xml2js';
10
- import { promisify } from 'util';
11
9
  import type { WordComment, CommentAnchor, WordMetadata, TrackChangesResult } from './types.js';
12
-
13
- const parseXml = promisify(parseString);
10
+ import {
11
+ openDocx,
12
+ readPartText,
13
+ buildCommentAnchorModel,
14
+ extractComments,
15
+ walkBody,
16
+ type FlowItem,
17
+ } from './ooxml.js';
14
18
 
15
19
  // =============================================================================
16
20
  // Constants
@@ -41,52 +45,10 @@ export async function extractWordComments(docxPath: string): Promise<WordComment
41
45
  throw new Error(`File not found: ${docxPath}`);
42
46
  }
43
47
 
44
- const zip = new AdmZip(docxPath);
45
- const commentsEntry = zip.getEntry('word/comments.xml');
46
-
47
- if (!commentsEntry) {
48
- return []; // No comments in document
49
- }
50
-
51
- const commentsXml = zip.readAsText(commentsEntry);
52
- const parsed = await parseXml(commentsXml) as any;
53
-
54
- if (!parsed?.['w:comments'] || !parsed['w:comments']['w:comment']) {
55
- return [];
56
- }
57
-
58
- const comments: WordComment[] = [];
59
- const rawComments = parsed['w:comments']['w:comment'];
60
-
61
- for (const comment of rawComments) {
62
- const id = comment.$?.['w:id'];
63
- const author = comment.$?.['w:author'] || 'Unknown';
64
- const date = comment.$?.['w:date'];
65
-
66
- // Extract text from all paragraphs in comment
67
- let text = '';
68
- const paragraphs = comment['w:p'] || [];
69
- for (const para of paragraphs) {
70
- const runs = para['w:r'] || [];
71
- for (const run of runs) {
72
- const texts = run['w:t'] || [];
73
- for (const t of texts) {
74
- text += typeof t === 'string' ? t : (t._ || '');
75
- }
76
- }
77
- }
78
-
79
- if (id && text.trim()) {
80
- comments.push({
81
- id,
82
- author,
83
- date,
84
- text: text.trim(),
85
- });
86
- }
87
- }
88
-
89
- return comments;
48
+ const zip = openDocx(docxPath);
49
+ return extractComments(zip)
50
+ .filter((c) => c.id && c.text)
51
+ .map((c) => ({ id: c.id, author: c.author, date: c.date, text: c.text }));
90
52
  }
91
53
 
92
54
  /**
@@ -102,63 +64,18 @@ export async function extractCommentAnchors(docxPath: string): Promise<Map<strin
102
64
  throw new TypeError(`docxPath must be a string, got ${typeof docxPath}`);
103
65
  }
104
66
 
105
- const zip = new AdmZip(docxPath);
106
- const documentEntry = zip.getEntry('word/document.xml');
107
-
108
- if (!documentEntry) {
67
+ const zip = openDocx(docxPath);
68
+ if (!zip.getEntry('word/document.xml')) {
109
69
  throw new Error('Invalid docx: no document.xml');
110
70
  }
111
71
 
112
- const documentXml = zip.readAsText(documentEntry);
72
+ const { fullDocText, comments } = buildCommentAnchorModel(zip);
113
73
  const anchors = new Map<string, CommentAnchor>();
114
74
 
115
- // Find commentRangeStart and commentRangeEnd pairs
116
- // The text between them is what the comment is anchored to
117
- const startPattern = /<w:commentRangeStart w:id="(\d+)"\/>/g;
118
- const endPattern = /<w:commentRangeEnd w:id="(\d+)"\/>/g;
119
-
120
- let match: RegExpExecArray | null;
121
- const starts = new Map<string, number>();
122
- const ends = new Map<string, number>();
123
-
124
- while ((match = startPattern.exec(documentXml)) !== null) {
125
- if (match[1]) {
126
- starts.set(match[1], match.index);
127
- }
128
- }
129
-
130
- while ((match = endPattern.exec(documentXml)) !== null) {
131
- if (match[1]) {
132
- ends.set(match[1], match.index);
133
- }
134
- }
135
-
136
- // For each comment, extract the text between start and end
137
- for (const [id, startPos] of starts) {
138
- const endPos = ends.get(id);
139
- if (!endPos) continue;
140
-
141
- const segment = documentXml.slice(startPos, endPos);
142
-
143
- // Extract all text content from the segment
144
- const textPattern = /<w:t[^>]*>([^<]*)<\/w:t>/g;
145
- let text = '';
146
- let textMatch: RegExpExecArray | null;
147
- while ((textMatch = textPattern.exec(segment)) !== null) {
148
- text += textMatch[1] ?? '';
149
- }
150
-
151
- // Get surrounding context (text before the anchor)
152
- const contextStart = Math.max(0, startPos - CONTEXT_BEFORE_SIZE);
153
- const contextSegment = documentXml.slice(contextStart, startPos);
154
- let context = '';
155
- while ((textMatch = textPattern.exec(contextSegment)) !== null) {
156
- context += textMatch[1] ?? '';
157
- }
158
-
159
- anchors.set(id, {
160
- text: text.trim(),
161
- context: context.slice(-ANCHOR_CONTEXT_SIZE),
75
+ for (const range of comments) {
76
+ anchors.set(range.id, {
77
+ text: range.anchor,
78
+ context: fullDocText.slice(Math.max(0, range.start - CONTEXT_BEFORE_SIZE), range.start).slice(-ANCHOR_CONTEXT_SIZE),
162
79
  });
163
80
  }
164
81
 
@@ -443,118 +360,84 @@ export async function extractPlainTextWithTrackChanges(docxPath: string): Promis
443
360
  throw new Error(`File not found: ${docxPath}`);
444
361
  }
445
362
 
446
- const zip = new AdmZip(docxPath);
447
- const docEntry = zip.getEntry('word/document.xml');
448
-
449
- if (!docEntry) {
363
+ const zip = openDocx(docxPath);
364
+ const docXml = readPartText(zip, 'word/document.xml');
365
+ if (docXml === null) {
450
366
  throw new Error('Invalid docx: no document.xml');
451
367
  }
452
368
 
453
- let xml = docEntry.getData().toString('utf8');
454
369
  let insertions = 0;
455
370
  let deletions = 0;
456
-
457
- // Use unique markers (null bytes) that won't appear in normal text
458
- const INS_S = '\x00IS\x00';
459
- const INS_E = '\x00IE\x00';
460
- const DEL_S = '\x00DS\x00';
461
- const DEL_E = '\x00DE\x00';
462
-
463
- // Step 1: Replace <w:ins> with marker-wrapped text injected as <w:t>
464
- // Whitespace-only insertions are kept as plain text (not markers) to preserve spacing.
465
- xml = xml.replace(/<w:ins\b[^>]*>([\s\S]*?)<\/w:ins>/g, (_match, content: string) => {
466
- const texts: string[] = [];
467
- const tPat = /<w:t[^>]*>([^<]*)<\/w:t>/g;
468
- let m: RegExpExecArray | null;
469
- while ((m = tPat.exec(content)) !== null) {
470
- texts.push(m[1] || '');
471
- }
472
- const text = texts.join('');
473
- if (text.trim()) {
474
- insertions++;
475
- return `<w:r><w:t>${INS_S}${text}${INS_E}</w:t></w:r>`;
476
- }
477
- // Whitespace-only: preserve as plain text for spacing
478
- if (text.length > 0) {
479
- return `<w:r><w:t>${text}</w:t></w:r>`;
480
- }
481
- return '';
482
- });
483
-
484
- // Step 2: Replace <w:del> similarly (uses w:delText inside)
485
- // Whitespace-only deletions are kept as plain text to preserve spacing.
486
- xml = xml.replace(/<w:del\b[^>]*>([\s\S]*?)<\/w:del>/g, (_match, content: string) => {
487
- const texts: string[] = [];
488
- const tPat = /<w:delText[^>]*>([^<]*)<\/w:delText>|<w:t[^>]*>([^<]*)<\/w:t>/g;
489
- let m: RegExpExecArray | null;
490
- while ((m = tPat.exec(content)) !== null) {
491
- texts.push(m[1] || m[2] || '');
492
- }
493
- const text = texts.join('');
494
- if (text.trim()) {
495
- deletions++;
496
- return `<w:r><w:t>${DEL_S}${text}${DEL_E}</w:t></w:r>`;
497
- }
498
- // Whitespace-only: preserve as plain text for spacing
499
- if (text.length > 0) {
500
- return `<w:r><w:t>${text}</w:t></w:r>`;
501
- }
502
- return '';
503
- });
504
-
505
- // Step 3: Extract text paragraph by paragraph
506
371
  const paragraphs: string[] = [];
507
- const paraPattern = /<w:p\b[^>]*>([\s\S]*?)<\/w:p>/g;
508
- let pm: RegExpExecArray | null;
509
-
510
- while ((pm = paraPattern.exec(xml)) !== null) {
511
- const paraXml = pm[1];
512
372
 
513
- // Detect heading level from paragraph style
514
- let headingLevel = 0;
515
- const styleMatch = paraXml.match(/<w:pStyle\s+w:val="Heading(\d)"/i);
516
- if (styleMatch && styleMatch[1]) {
517
- headingLevel = parseInt(styleMatch[1], 10);
373
+ // One ordered walk drives everything: paragraph and heading boundaries,
374
+ // run text (entities already decoded, tabs/breaks rendered), and the
375
+ // track-change spans that become CriticMarkup. Field codes (w:instrText)
376
+ // never reach the text because the walker only reads w:t / w:delText.
377
+ let paraOut = '';
378
+ let headingLevel = 0;
379
+ let mode: 'normal' | 'ins' | 'del' = 'normal';
380
+ let buffer = '';
381
+
382
+ const flushSpan = (open: string, close: string, isIns: boolean) => {
383
+ if (buffer.trim()) {
384
+ if (isIns) insertions++;
385
+ else deletions++;
386
+ paraOut += `${open}${buffer}${close}`;
387
+ } else if (buffer.length > 0) {
388
+ // Whitespace-only edits are kept as plain text to preserve spacing.
389
+ paraOut += buffer;
518
390
  }
391
+ buffer = '';
392
+ };
519
393
 
520
- // Extract all <w:t> text in order
521
- const texts: string[] = [];
522
- const tPat = /<w:t[^>]*>([^<]*)<\/w:t>/g;
523
- let tm: RegExpExecArray | null;
524
- while ((tm = tPat.exec(paraXml)) !== null) {
525
- texts.push(tm[1] || '');
394
+ const endParagraph = () => {
395
+ let text = mergeAdjacentMarkers(paraOut);
396
+ text = text.replace(/ {2,}/g, ' ');
397
+ if (text.trim()) {
398
+ paragraphs.push(
399
+ headingLevel >= 1 && headingLevel <= 6 ? '#'.repeat(headingLevel) + ' ' + text.trim() : text,
400
+ );
526
401
  }
402
+ paraOut = '';
403
+ headingLevel = 0;
404
+ mode = 'normal';
405
+ buffer = '';
406
+ };
527
407
 
528
- let paraText = texts.join('');
529
-
530
- // Decode XML entities
531
- paraText = paraText
532
- .replace(/&amp;/g, '&')
533
- .replace(/&lt;/g, '<')
534
- .replace(/&gt;/g, '>')
535
- .replace(/&quot;/g, '"')
536
- .replace(/&apos;/g, "'");
537
-
538
- // Convert markers to CriticMarkup
539
- paraText = paraText
540
- .split(INS_S).join('{++')
541
- .split(INS_E).join('++}')
542
- .split(DEL_S).join('{--')
543
- .split(DEL_E).join('--}');
544
-
545
- // Merge adjacent del+ins (or ins+del) into substitutions.
546
- // Uses a scanner instead of regex to avoid backtracking across marker boundaries.
547
- paraText = mergeAdjacentMarkers(paraText);
548
-
549
- // Collapse runs of multiple spaces into single space
550
- paraText = paraText.replace(/ {2,}/g, ' ');
551
-
552
- if (paraText.trim()) {
553
- if (headingLevel > 0 && headingLevel <= 6) {
554
- paragraphs.push('#'.repeat(headingLevel) + ' ' + paraText.trim());
555
- } else {
556
- paragraphs.push(paraText);
557
- }
408
+ for (const item of walkBody(docXml) as FlowItem[]) {
409
+ switch (item.kind) {
410
+ case 'paraStart':
411
+ paraOut = '';
412
+ headingLevel = item.level;
413
+ mode = 'normal';
414
+ buffer = '';
415
+ break;
416
+ case 'paraEnd':
417
+ endParagraph();
418
+ break;
419
+ case 'text':
420
+ if (mode === 'normal') paraOut += item.text;
421
+ else buffer += item.text;
422
+ break;
423
+ case 'insStart':
424
+ mode = 'ins';
425
+ buffer = '';
426
+ break;
427
+ case 'insEnd':
428
+ flushSpan('{++', '++}', true);
429
+ mode = 'normal';
430
+ break;
431
+ case 'delStart':
432
+ mode = 'del';
433
+ buffer = '';
434
+ break;
435
+ case 'delEnd':
436
+ flushSpan('{--', '--}', false);
437
+ mode = 'normal';
438
+ break;
439
+ default:
440
+ break;
558
441
  }
559
442
  }
560
443
 
@@ -13,6 +13,7 @@
13
13
  import * as fs from 'fs';
14
14
  import AdmZip from 'adm-zip';
15
15
  import { escapeXml } from './utils.js';
16
+ import { indexTextRuns, type TextRunSlot } from './ooxml.js';
16
17
 
17
18
  const MARKER_START_PREFIX = '⟦CMS:';
18
19
  const MARKER_END_PREFIX = '⟦CME:';
@@ -467,7 +468,25 @@ export async function injectCommentsAtMarkers(
467
468
 
468
469
  const injectedIds = new Set<string>();
469
470
 
470
- // Process only parent comments (non-replies) for document ranges
471
+ // Locate the enclosing run of each marker structurally, so placement never
472
+ // scans backwards for the nearest `<w:r`. Markers that pandoc duplicated
473
+ // into attributes (e.g. <wp:docPr descr="...">) are not inside a <w:t>, so
474
+ // they never appear in the index and cannot mislead the placement.
475
+ // See: https://github.com/gcol33/docrev/issues/4
476
+ //
477
+ // The index is rebuilt each iteration against the current document: two
478
+ // markers can share one run, so splicing one run invalidates offsets into
479
+ // it for the next marker.
480
+ const findMarkerSlot = (slots: TextRunSlot[], marker: string): { slot: TextRunSlot; rel: number } | null => {
481
+ for (const slot of slots) {
482
+ const rel = slot.content.indexOf(marker);
483
+ if (rel >= 0) return { slot, rel };
484
+ }
485
+ return null;
486
+ };
487
+
488
+ // Process only parent comments (non-replies) for document ranges, from the
489
+ // last document position backwards.
471
490
  const parentComments = commentsWithIds.filter(c => !c.isReply);
472
491
 
473
492
  for (let i = parentComments.length - 1; i >= 0; i--) {
@@ -478,85 +497,17 @@ export async function injectCommentsAtMarkers(
478
497
  const startMarker = `${MARKER_START_PREFIX}${idx}${MARKER_SUFFIX}`;
479
498
  const endMarker = `${MARKER_END_PREFIX}${idx}${MARKER_SUFFIX}`;
480
499
 
481
- // Pandoc duplicates inline image alt-text into <wp:docPr descr="...">
482
- // metadata attributes AND into the visible caption paragraph. A naive
483
- // indexOf hits the metadata-attribute occurrence first, where there is
484
- // no <w:t> element so dissectRun fails. Skip occurrences whose position
485
- // is inside an XML tag (last unbalanced '<' before position).
486
- // See: https://github.com/gcol33/docrev/issues/4
487
- function findInTextContent(haystack: string, needle: string, fromIdx = 0): number {
488
- let i = fromIdx;
489
- while (true) {
490
- const p = haystack.indexOf(needle, i);
491
- if (p < 0) return -1;
492
- const lastLt = haystack.lastIndexOf('<', p);
493
- const lastGt = haystack.lastIndexOf('>', p);
494
- if (lastLt > lastGt) {
495
- i = p + 1;
496
- continue;
497
- }
498
- return p;
499
- }
500
- }
500
+ const slots = indexTextRuns(documentXml);
501
+ const startHit = findMarkerSlot(slots, startMarker);
502
+ const endHit = startHit ? findMarkerSlot(slots, endMarker) : null;
503
+ if (!startHit || !endHit) continue;
501
504
 
502
- const startPos = findInTextContent(documentXml, startMarker);
503
- const endPos = startPos === -1
504
- ? -1
505
- : findInTextContent(documentXml, endMarker, startPos + startMarker.length);
506
-
507
- if (startPos === -1 || endPos === -1) continue;
508
-
509
- // Find the runs containing each marker. Pandoc may split a single
510
- // markdown anchor across multiple <w:r> blocks when it applies styling
511
- // mid-anchor (smart-quote substitution, *italic*, `code`, **bold**).
512
- // The same-run path (current happy path) collapses into the multi-run
513
- // path when start and end runs coincide.
514
- const startRunOpen = Math.max(
515
- documentXml.lastIndexOf('<w:r>', startPos),
516
- documentXml.lastIndexOf('<w:r ', startPos),
517
- );
518
- const startRunCloseIdx = documentXml.indexOf('</w:r>', startPos);
519
- const endRunOpen = Math.max(
520
- documentXml.lastIndexOf('<w:r>', endPos),
521
- documentXml.lastIndexOf('<w:r ', endPos),
522
- );
523
- const endRunCloseIdx = documentXml.indexOf('</w:r>', endPos);
524
-
525
- if (
526
- startRunOpen === -1 || startRunCloseIdx === -1 ||
527
- endRunOpen === -1 || endRunCloseIdx === -1
528
- ) continue;
529
-
530
- const startRunClose = startRunCloseIdx + '</w:r>'.length;
531
- const endRunClose = endRunCloseIdx + '</w:r>'.length;
532
-
533
- const startRunFull = documentXml.slice(startRunOpen, startRunClose);
534
- const endRunFull = documentXml.slice(endRunOpen, endRunClose);
535
-
536
- // Extract <w:rPr> and <w:t> element shape from each run. Both pieces
537
- // are needed verbatim so a textBefore split keeps its original styling
538
- // and so the post-anchor textAfter render keeps the end run's styling.
539
- function dissectRun(runXml: string, marker: string): {
540
- rPr: string;
541
- tElement: string;
542
- textBefore: string;
543
- textAfter: string;
544
- } | null {
545
- const rPrMatch = runXml.match(/<w:rPr>[\s\S]*?<\/w:rPr>/);
546
- const tMatch = runXml.match(/<w:t[^>]*>([\s\S]*?)<\/w:t>/);
547
- if (!tMatch) return null;
548
- const tOpenMatch = tMatch[0].match(/<w:t[^>]*>/);
549
- if (!tOpenMatch) return null;
550
- const tContent = tMatch[1] ?? '';
551
- const markerInT = tContent.indexOf(marker);
552
- if (markerInT === -1) return null;
553
- return {
554
- rPr: rPrMatch ? rPrMatch[0] : '',
555
- tElement: tOpenMatch[0],
556
- textBefore: tContent.slice(0, markerInT),
557
- textAfter: tContent.slice(markerInT + marker.length),
558
- };
559
- }
505
+ const startSlot = startHit.slot;
506
+ const endSlot = endHit.slot;
507
+ const startRunOpen = startSlot.runOpenStart;
508
+ const startRunClose = startSlot.runCloseEnd;
509
+ const endRunOpen = endSlot.runOpenStart;
510
+ const endRunClose = endSlot.runCloseEnd;
560
511
 
561
512
  let replacement = '';
562
513
  const replies = commentsWithIds.filter(c => c.isReply && c.parentIdx === comment?.commentIdx);
@@ -579,16 +530,15 @@ export async function injectCommentsAtMarkers(
579
530
  };
580
531
 
581
532
  if (startRunOpen === endRunOpen) {
582
- // Same-run path: both markers live inside one <w:t>. Original logic.
583
- const startInfo = dissectRun(startRunFull, startMarker);
584
- if (!startInfo) continue;
585
- const fullText = startInfo.textBefore + startMarker + startInfo.textAfter;
586
- const endInTextRel = startInfo.textAfter.indexOf(endMarker);
533
+ // Same-run path: both markers live inside one <w:t>.
534
+ const rPr = startSlot.rPr;
535
+ const tElement = startSlot.tOpenTag;
536
+ const afterStart = startSlot.content.slice(startHit.rel + startMarker.length);
537
+ const endInTextRel = afterStart.indexOf(endMarker);
587
538
  if (endInTextRel === -1) continue;
588
- const anchorTextSame = startInfo.textAfter.slice(0, endInTextRel);
589
- let textAfter = startInfo.textAfter.slice(endInTextRel + endMarker.length);
590
- let anchorText = anchorTextSame;
591
- let textBefore = startInfo.textBefore;
539
+ let textBefore = startSlot.content.slice(0, startHit.rel);
540
+ let anchorText = afterStart.slice(0, endInTextRel);
541
+ let textAfter = afterStart.slice(endInTextRel + endMarker.length);
592
542
 
593
543
  // Empty anchor: borrow the next word so the comment has something
594
544
  // to anchor on. Then normalize the trailing double space.
@@ -602,19 +552,17 @@ export async function injectCommentsAtMarkers(
602
552
  if (!anchorText && textBefore.endsWith(' ') && textAfter.startsWith(' ')) {
603
553
  textAfter = textAfter.slice(1);
604
554
  }
605
- // Suppress unused warning for pre-empty-anchor fullText var
606
- void fullText;
607
555
 
608
556
  if (textBefore) {
609
- replacement += `<w:r>${startInfo.rPr}${startInfo.tElement}${textBefore}</w:t></w:r>`;
557
+ replacement += `<w:r>${rPr}${tElement}${textBefore}</w:t></w:r>`;
610
558
  }
611
559
  emitRangeStarts();
612
560
  if (anchorText) {
613
- replacement += `<w:r>${startInfo.rPr}${startInfo.tElement}${anchorText}</w:t></w:r>`;
561
+ replacement += `<w:r>${rPr}${tElement}${anchorText}</w:t></w:r>`;
614
562
  }
615
563
  emitRangeEnds();
616
564
  if (textAfter) {
617
- replacement += `<w:r>${startInfo.rPr}${startInfo.tElement}${textAfter}</w:t></w:r>`;
565
+ replacement += `<w:r>${rPr}${tElement}${textAfter}</w:t></w:r>`;
618
566
  }
619
567
  documentXml = documentXml.slice(0, startRunOpen) + replacement + documentXml.slice(startRunClose);
620
568
  injectedIds.add(comment.id);
@@ -625,26 +573,27 @@ export async function injectCommentsAtMarkers(
625
573
  // applied mid-anchor styling. Split the start run at the start marker,
626
574
  // keep all middle runs verbatim (they carry the styled anchor portions),
627
575
  // split the end run at the end marker.
628
- const startInfo = dissectRun(startRunFull, startMarker);
629
- const endInfo = dissectRun(endRunFull, endMarker);
630
- if (!startInfo || !endInfo) continue;
576
+ const startTextBefore = startSlot.content.slice(0, startHit.rel);
577
+ const startTextAfter = startSlot.content.slice(startHit.rel + startMarker.length);
578
+ const endTextBefore = endSlot.content.slice(0, endHit.rel);
579
+ const endTextAfter = endSlot.content.slice(endHit.rel + endMarker.length);
631
580
 
632
581
  const middle = documentXml.slice(startRunClose, endRunOpen);
633
582
 
634
- if (startInfo.textBefore) {
635
- replacement += `<w:r>${startInfo.rPr}${startInfo.tElement}${startInfo.textBefore}</w:t></w:r>`;
583
+ if (startTextBefore) {
584
+ replacement += `<w:r>${startSlot.rPr}${startSlot.tOpenTag}${startTextBefore}</w:t></w:r>`;
636
585
  }
637
586
  emitRangeStarts();
638
- if (startInfo.textAfter) {
639
- replacement += `<w:r>${startInfo.rPr}${startInfo.tElement}${startInfo.textAfter}</w:t></w:r>`;
587
+ if (startTextAfter) {
588
+ replacement += `<w:r>${startSlot.rPr}${startSlot.tOpenTag}${startTextAfter}</w:t></w:r>`;
640
589
  }
641
590
  replacement += middle;
642
- if (endInfo.textBefore) {
643
- replacement += `<w:r>${endInfo.rPr}${endInfo.tElement}${endInfo.textBefore}</w:t></w:r>`;
591
+ if (endTextBefore) {
592
+ replacement += `<w:r>${endSlot.rPr}${endSlot.tOpenTag}${endTextBefore}</w:t></w:r>`;
644
593
  }
645
594
  emitRangeEnds();
646
- if (endInfo.textAfter) {
647
- replacement += `<w:r>${endInfo.rPr}${endInfo.tElement}${endInfo.textAfter}</w:t></w:r>`;
595
+ if (endTextAfter) {
596
+ replacement += `<w:r>${endSlot.rPr}${endSlot.tOpenTag}${endTextAfter}</w:t></w:r>`;
648
597
  }
649
598
 
650
599
  documentXml = documentXml.slice(0, startRunOpen) + replacement + documentXml.slice(endRunClose);