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.
- package/dist/lib/anchor-match.d.ts +3 -1
- package/dist/lib/anchor-match.d.ts.map +1 -1
- package/dist/lib/anchor-match.js +5 -7
- package/dist/lib/anchor-match.js.map +1 -1
- package/dist/lib/annotations.d.ts +0 -6
- package/dist/lib/annotations.d.ts.map +1 -1
- package/dist/lib/annotations.js +26 -8
- package/dist/lib/annotations.js.map +1 -1
- package/dist/lib/build.d.ts.map +1 -1
- package/dist/lib/build.js +9 -6
- package/dist/lib/build.js.map +1 -1
- package/dist/lib/commands/build.d.ts.map +1 -1
- package/dist/lib/commands/build.js +5 -0
- package/dist/lib/commands/build.js.map +1 -1
- package/dist/lib/commands/doi.d.ts.map +1 -1
- package/dist/lib/commands/doi.js +14 -2
- package/dist/lib/commands/doi.js.map +1 -1
- package/dist/lib/commands/sync.d.ts.map +1 -1
- package/dist/lib/commands/sync.js +24 -11
- package/dist/lib/commands/sync.js.map +1 -1
- package/dist/lib/comment-realign.d.ts +12 -21
- package/dist/lib/comment-realign.d.ts.map +1 -1
- package/dist/lib/comment-realign.js +40 -348
- package/dist/lib/comment-realign.js.map +1 -1
- package/dist/lib/dependencies.d.ts +10 -0
- package/dist/lib/dependencies.d.ts.map +1 -1
- package/dist/lib/dependencies.js +41 -9
- package/dist/lib/dependencies.js.map +1 -1
- package/dist/lib/doi.d.ts.map +1 -1
- package/dist/lib/doi.js +68 -80
- package/dist/lib/doi.js.map +1 -1
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js +3 -1
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/import.d.ts +7 -4
- package/dist/lib/import.d.ts.map +1 -1
- package/dist/lib/import.js +58 -62
- package/dist/lib/import.js.map +1 -1
- package/dist/lib/ooxml.d.ts +207 -0
- package/dist/lib/ooxml.d.ts.map +1 -0
- package/dist/lib/ooxml.js +634 -0
- package/dist/lib/ooxml.js.map +1 -0
- package/dist/lib/rate-limiter.d.ts +8 -0
- package/dist/lib/rate-limiter.d.ts.map +1 -1
- package/dist/lib/rate-limiter.js +41 -4
- package/dist/lib/rate-limiter.js.map +1 -1
- package/dist/lib/types.d.ts +9 -1
- package/dist/lib/types.d.ts.map +1 -1
- package/dist/lib/word-extraction.d.ts.map +1 -1
- package/dist/lib/word-extraction.js +48 -307
- package/dist/lib/word-extraction.js.map +1 -1
- package/dist/lib/word.d.ts.map +1 -1
- package/dist/lib/word.js +80 -175
- package/dist/lib/word.js.map +1 -1
- package/dist/lib/wordcomments.d.ts.map +1 -1
- package/dist/lib/wordcomments.js +53 -92
- package/dist/lib/wordcomments.js.map +1 -1
- package/docs-src/build.py +113 -113
- package/docs-src/extra.css +208 -208
- package/docs-src/md-to-html.lua +6 -6
- package/docs-src/template.html +116 -116
- package/lib/anchor-match.ts +6 -7
- package/lib/annotations.ts +28 -8
- package/lib/build.ts +10 -6
- package/lib/commands/build.ts +5 -0
- package/lib/commands/doi.ts +17 -1
- package/lib/commands/sync.ts +26 -13
- package/lib/comment-realign.ts +46 -464
- package/lib/dependencies.ts +43 -9
- package/lib/doi.ts +76 -94
- package/lib/errors.ts +5 -1
- package/lib/import.ts +65 -70
- package/lib/ooxml.ts +768 -0
- package/lib/rate-limiter.ts +37 -4
- package/lib/types.ts +9 -1
- package/lib/word-extraction.ts +49 -330
- package/lib/word.ts +86 -203
- package/lib/wordcomments.ts +53 -104
- package/mkdocs.yml +64 -64
- package/package.json +1 -1
- package/issues.md +0 -180
- package/site/assets/extra.css +0 -208
- package/site/commands.html +0 -926
- package/site/configuration.html +0 -469
- package/site/index.html +0 -288
- package/site/troubleshooting.html +0 -461
- 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
|
-
|
|
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 =
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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 =
|
|
106
|
-
|
|
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
|
|
72
|
+
const { fullDocText, comments } = buildCommentAnchorModel(zip);
|
|
113
73
|
const anchors = new Map<string, CommentAnchor>();
|
|
114
74
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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 =
|
|
447
|
-
const
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
|
package/lib/wordcomments.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
|
503
|
-
const
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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>.
|
|
583
|
-
const
|
|
584
|
-
|
|
585
|
-
const
|
|
586
|
-
const endInTextRel =
|
|
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
|
-
|
|
589
|
-
let
|
|
590
|
-
let
|
|
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>${
|
|
557
|
+
replacement += `<w:r>${rPr}${tElement}${textBefore}</w:t></w:r>`;
|
|
610
558
|
}
|
|
611
559
|
emitRangeStarts();
|
|
612
560
|
if (anchorText) {
|
|
613
|
-
replacement += `<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>${
|
|
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
|
|
629
|
-
const
|
|
630
|
-
|
|
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 (
|
|
635
|
-
replacement += `<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 (
|
|
639
|
-
replacement += `<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 (
|
|
643
|
-
replacement += `<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 (
|
|
647
|
-
replacement += `<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);
|