docrev 0.11.1 → 0.11.3

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 (144) hide show
  1. package/.gitattributes +1 -1
  2. package/CHANGELOG.md +207 -197
  3. package/PLAN-tables-and-postprocess.md +850 -850
  4. package/README.md +433 -433
  5. package/bin/rev.js +11 -11
  6. package/bin/rev.ts +145 -145
  7. package/completions/rev.bash +127 -127
  8. package/completions/rev.ps1 +210 -210
  9. package/completions/rev.zsh +207 -207
  10. package/dist/lib/annotations.d.ts.map +1 -1
  11. package/dist/lib/annotations.js +28 -0
  12. package/dist/lib/annotations.js.map +1 -1
  13. package/dist/lib/build.d.ts +28 -0
  14. package/dist/lib/build.d.ts.map +1 -1
  15. package/dist/lib/build.js +81 -4
  16. package/dist/lib/build.js.map +1 -1
  17. package/dist/lib/commands/comments.d.ts.map +1 -1
  18. package/dist/lib/commands/comments.js +58 -28
  19. package/dist/lib/commands/comments.js.map +1 -1
  20. package/dist/lib/commands/context.d.ts +1 -0
  21. package/dist/lib/commands/context.d.ts.map +1 -1
  22. package/dist/lib/commands/context.js +1 -0
  23. package/dist/lib/commands/context.js.map +1 -1
  24. package/dist/lib/commands/core.d.ts.map +1 -1
  25. package/dist/lib/commands/core.js +25 -8
  26. package/dist/lib/commands/core.js.map +1 -1
  27. package/dist/lib/commands/response.js +1 -1
  28. package/dist/lib/commands/response.js.map +1 -1
  29. package/dist/lib/commands/utilities.js +164 -164
  30. package/dist/lib/commands/word-tools.js +8 -8
  31. package/dist/lib/grammar.js +3 -3
  32. package/dist/lib/import.d.ts +14 -0
  33. package/dist/lib/import.d.ts.map +1 -1
  34. package/dist/lib/import.js +23 -0
  35. package/dist/lib/import.js.map +1 -1
  36. package/dist/lib/input.d.ts +67 -0
  37. package/dist/lib/input.d.ts.map +1 -0
  38. package/dist/lib/input.js +164 -0
  39. package/dist/lib/input.js.map +1 -0
  40. package/dist/lib/macro-filter.lua +201 -201
  41. package/dist/lib/pdf-comments.js +44 -44
  42. package/dist/lib/plugins.js +57 -57
  43. package/dist/lib/pptx-color-filter.lua +37 -37
  44. package/dist/lib/pptx-themes.js +115 -115
  45. package/dist/lib/response.d.ts +1 -1
  46. package/dist/lib/response.d.ts.map +1 -1
  47. package/dist/lib/response.js +5 -2
  48. package/dist/lib/response.js.map +1 -1
  49. package/dist/lib/schema.d.ts.map +1 -1
  50. package/dist/lib/schema.js +8 -2
  51. package/dist/lib/schema.js.map +1 -1
  52. package/dist/lib/spelling.js +2 -2
  53. package/dist/lib/templates.js +387 -387
  54. package/dist/lib/themes.js +51 -51
  55. package/eslint.config.js +27 -27
  56. package/lib/anchor-match.ts +307 -307
  57. package/lib/annotations.ts +695 -664
  58. package/lib/build.ts +2047 -1956
  59. package/lib/citations.ts +160 -160
  60. package/lib/commands/build.ts +885 -885
  61. package/lib/commands/citations.ts +515 -515
  62. package/lib/commands/comments.ts +1077 -1050
  63. package/lib/commands/context.ts +185 -176
  64. package/lib/commands/core.ts +328 -309
  65. package/lib/commands/doi.ts +451 -451
  66. package/lib/commands/file-ops.ts +372 -372
  67. package/lib/commands/history.ts +320 -320
  68. package/lib/commands/index.ts +87 -87
  69. package/lib/commands/init.ts +259 -259
  70. package/lib/commands/merge-resolve.ts +378 -378
  71. package/lib/commands/preview.ts +178 -178
  72. package/lib/commands/project-info.ts +244 -244
  73. package/lib/commands/quality.ts +517 -517
  74. package/lib/commands/response.ts +454 -454
  75. package/lib/commands/section-boundaries.ts +82 -82
  76. package/lib/commands/sections.ts +451 -451
  77. package/lib/commands/sync.ts +689 -689
  78. package/lib/commands/text-ops.ts +449 -449
  79. package/lib/commands/utilities.ts +448 -448
  80. package/lib/commands/verify-anchors.ts +272 -272
  81. package/lib/commands/word-tools.ts +340 -340
  82. package/lib/comment-realign.ts +99 -99
  83. package/lib/config.ts +84 -84
  84. package/lib/crossref.ts +781 -781
  85. package/lib/csl.ts +191 -191
  86. package/lib/dependencies.ts +132 -132
  87. package/lib/diff-engine.ts +465 -465
  88. package/lib/doi-cache.ts +115 -115
  89. package/lib/doi.ts +879 -879
  90. package/lib/equations.ts +506 -506
  91. package/lib/errors.ts +350 -350
  92. package/lib/format.ts +541 -541
  93. package/lib/git.ts +326 -326
  94. package/lib/grammar.ts +303 -303
  95. package/lib/image-registry.ts +180 -180
  96. package/lib/import.ts +932 -906
  97. package/lib/input.ts +174 -0
  98. package/lib/journals.ts +543 -543
  99. package/lib/macro-filter.lua +201 -201
  100. package/lib/macros.ts +273 -273
  101. package/lib/merge.ts +633 -633
  102. package/lib/ooxml.ts +768 -768
  103. package/lib/orcid.ts +144 -144
  104. package/lib/pdf-comments.ts +263 -263
  105. package/lib/pdf-import.ts +524 -524
  106. package/lib/plugins.ts +362 -362
  107. package/lib/postprocess.ts +188 -188
  108. package/lib/pptx-color-filter.lua +37 -37
  109. package/lib/pptx-template.ts +469 -469
  110. package/lib/pptx-themes.ts +483 -483
  111. package/lib/protect-restore.ts +520 -520
  112. package/lib/rate-limiter.ts +127 -127
  113. package/lib/response.ts +200 -197
  114. package/lib/restore-references.ts +240 -240
  115. package/lib/review.ts +327 -327
  116. package/lib/schema.ts +494 -488
  117. package/lib/scientific-words.ts +73 -73
  118. package/lib/sections.ts +428 -428
  119. package/lib/slides.ts +756 -756
  120. package/lib/spelling.ts +334 -334
  121. package/lib/templates.ts +526 -526
  122. package/lib/themes.ts +742 -742
  123. package/lib/trackchanges.ts +166 -166
  124. package/lib/tui.ts +450 -450
  125. package/lib/types.ts +546 -546
  126. package/lib/undo.ts +250 -250
  127. package/lib/utils.ts +69 -69
  128. package/lib/variables.ts +179 -179
  129. package/lib/word-extraction.ts +525 -525
  130. package/lib/word.ts +526 -526
  131. package/lib/wordcomments.ts +789 -789
  132. package/package.json +1 -1
  133. package/scripts/postbuild.js +47 -47
  134. package/skill/REFERENCE.md +550 -550
  135. package/skill/SKILL.md +302 -302
  136. package/tsconfig.json +26 -26
  137. package/types/index.d.ts +531 -531
  138. package/issues.md +0 -180
  139. package/site/assets/extra.css +0 -208
  140. package/site/commands.html +0 -926
  141. package/site/configuration.html +0 -469
  142. package/site/index.html +0 -288
  143. package/site/troubleshooting.html +0 -461
  144. package/site/workflow.html +0 -518
@@ -1,789 +1,789 @@
1
- /**
2
- * Word comment injection with reply threading
3
- *
4
- * Flow:
5
- * 1. prepareMarkdownWithMarkers() - Parse comments, detect reply relationships
6
- * - First comment in a cluster = parent (gets markers: ⟦CMS:n⟧anchor⟦CME:n⟧)
7
- * - Subsequent adjacent comments = replies (no markers, attach to parent)
8
- * 2. Pandoc converts to DOCX
9
- * 3. injectCommentsAtMarkers() - Insert comment ranges for parents only
10
- * - Replies go in comments.xml with parent reference in commentsExtended.xml
11
- */
12
-
13
- import * as fs from 'fs';
14
- import AdmZip from 'adm-zip';
15
- import { escapeXml } from './utils.js';
16
- import { indexTextRuns, type TextRunSlot } from './ooxml.js';
17
-
18
- const MARKER_START_PREFIX = '⟦CMS:';
19
- const MARKER_END_PREFIX = '⟦CME:';
20
- const MARKER_SUFFIX = '⟧';
21
-
22
- interface ParsedComment {
23
- author: string;
24
- text: string;
25
- anchor: string | null;
26
- start: number;
27
- end: number;
28
- fullMatch: string;
29
- }
30
-
31
- interface PreparedComment extends ParsedComment {
32
- isReply: boolean;
33
- parentIdx: number | null;
34
- commentIdx: number;
35
- anchorFromReply?: boolean;
36
- placesParentMarkers?: boolean;
37
- }
38
-
39
- interface PrepareResult {
40
- markedMarkdown: string;
41
- comments: PreparedComment[];
42
- }
43
-
44
- interface CommentWithIds extends PreparedComment {
45
- id: string;
46
- paraId: string;
47
- paraId2: string;
48
- durableId: string;
49
- parentParaId?: string;
50
- }
51
-
52
- interface InjectionResult {
53
- success: boolean;
54
- commentCount: number;
55
- replyCount?: number;
56
- skippedComments: number;
57
- error?: string;
58
- }
59
-
60
- function generateParaId(commentIdx: number, paraNum: number): string {
61
- // Generate 8-character uppercase hex ID matching Word format
62
- // Word uses IDs like "3F25BC58", "0331C187"
63
- // Must be deterministic - same inputs always produce same output
64
- const id = 0x10000000 + (commentIdx * 0x00100000) + (paraNum * 0x00001000);
65
- return id.toString(16).toUpperCase().padStart(8, '0');
66
- }
67
-
68
- /**
69
- * Parse comments and create markers
70
- *
71
- * Returns:
72
- * - markedMarkdown: markdown with markers for parent comments only
73
- * - comments: array with author, text, isReply, parentIdx
74
- */
75
- export function prepareMarkdownWithMarkers(markdown: string): PrepareResult {
76
- // Match the comment block first; extend manually to capture an optional
77
- // trailing `[anchor]{.mark}` span. A regex `[^\]]+` for the anchor would
78
- // bail on the inner `]` of nested syntax (e.g. `[[0..9]]{.mark}` or
79
- // `[*phrase*]{.mark}` after pandoc-rewriting), so we walk the brackets
80
- // ourselves and verify a `{.mark}` suffix.
81
- const commentPattern = /\{>>([\s\S]+?)<<\}/g;
82
-
83
- function tryParseTrailingAnchor(
84
- text: string,
85
- fromIdx: number,
86
- ): { anchor: string; endIdx: number } | null {
87
- let i = fromIdx;
88
- while (i < text.length && /\s/.test(text[i] ?? '')) i++;
89
- if (text[i] !== '[') return null;
90
- let depth = 1;
91
- let j = i + 1;
92
- while (j < text.length) {
93
- const ch = text[j];
94
- if (ch === '[') depth++;
95
- else if (ch === ']') {
96
- depth--;
97
- if (depth === 0) break;
98
- }
99
- j++;
100
- }
101
- if (depth !== 0) return null;
102
- if (text.slice(j + 1, j + 8) !== '{.mark}') return null;
103
- return { anchor: text.slice(i + 1, j), endIdx: j + 8 };
104
- }
105
-
106
- const REPLY_PREFIX = '↪ ';
107
- const rawMatches: (ParsedComment & { explicitReply: boolean })[] = [];
108
- let match: RegExpExecArray | null;
109
- while ((match = commentPattern.exec(markdown)) !== null) {
110
- const content = match[1] ?? '';
111
- let author = 'Unknown';
112
- let text = content;
113
- const colonIdx = content.indexOf(':');
114
- if (colonIdx > 0 && colonIdx < 30) {
115
- author = content.slice(0, colonIdx).trim();
116
- text = content.slice(colonIdx + 1).trim();
117
- }
118
-
119
- // The `↪ ` prefix is the authoritative reply signal emitted by
120
- // `insertCommentsIntoMarkdown`. Strip it from the author before injection
121
- // so Word displays the real name.
122
- let explicitReply = false;
123
- if (author.startsWith(REPLY_PREFIX)) {
124
- explicitReply = true;
125
- author = author.slice(REPLY_PREFIX.length).trim();
126
- }
127
-
128
- const commentEnd = match.index + match[0].length;
129
- const trailing = tryParseTrailingAnchor(markdown, commentEnd);
130
-
131
- rawMatches.push({
132
- author,
133
- text,
134
- anchor: trailing ? trailing.anchor : null,
135
- start: match.index,
136
- end: trailing ? trailing.endIdx : commentEnd,
137
- fullMatch: markdown.slice(match.index, trailing ? trailing.endIdx : commentEnd),
138
- explicitReply,
139
- });
140
-
141
- // Advance regex lastIndex past the consumed anchor so the next iteration
142
- // doesn't re-scan inside it (e.g. `[*emphasis*]{.mark}` would otherwise
143
- // tempt the matcher to look for another `{>>...<<}` in the body of the
144
- // anchor span).
145
- if (trailing) {
146
- commentPattern.lastIndex = trailing.endIdx;
147
- }
148
- }
149
-
150
- if (rawMatches.length === 0) {
151
- return { markedMarkdown: markdown, comments: [] };
152
- }
153
-
154
- // Two-mode reply detection driven by the markdown itself:
155
- // - If any comment carries the `↪ ` author prefix, the markdown came
156
- // through `insertCommentsIntoMarkdown` and we use prefix-only mode.
157
- // Distinct clusters that happen to land at gap=0 (a real failure
158
- // mode on dense reviewer docs — 298-comment paper produced 9 such
159
- // collisions) are not misthreaded.
160
- // - If no comment carries the prefix, the markdown was hand-typed.
161
- // Fall back to gap < 10 adjacency for backward compat with users
162
- // who write CriticMarkup directly.
163
- const ADJACENT_THRESHOLD = 10;
164
- const useExplicitMode = rawMatches.some(m => m.explicitReply);
165
- const comments: PreparedComment[] = [];
166
- let clusterParentIdx = -1; // Index of first comment in current cluster
167
- let lastCommentEnd = -1;
168
-
169
- for (let i = 0; i < rawMatches.length; i++) {
170
- const m = rawMatches[i];
171
- if (!m) continue;
172
-
173
- const gap = lastCommentEnd >= 0 ? m.start - lastCommentEnd : Infinity;
174
- const isAdjacent = useExplicitMode
175
- ? m.explicitReply
176
- : gap < ADJACENT_THRESHOLD;
177
-
178
- // Reset cluster if there's a gap (comments not in same cluster)
179
- if (!isAdjacent) {
180
- clusterParentIdx = -1;
181
- }
182
-
183
- if (clusterParentIdx === -1) {
184
- // First comment in cluster = parent (regardless of author)
185
- comments.push({
186
- author: m.author,
187
- text: m.text,
188
- anchor: m.anchor,
189
- start: m.start,
190
- end: m.end,
191
- fullMatch: m.fullMatch,
192
- isReply: false,
193
- parentIdx: null,
194
- commentIdx: comments.length
195
- });
196
- clusterParentIdx = comments.length - 1;
197
- } else {
198
- // Subsequent comment in cluster = reply to first comment
199
- comments.push({
200
- author: m.author,
201
- text: m.text,
202
- anchor: m.anchor,
203
- start: m.start,
204
- end: m.end,
205
- fullMatch: m.fullMatch,
206
- isReply: true,
207
- parentIdx: clusterParentIdx,
208
- commentIdx: comments.length
209
- });
210
- }
211
-
212
- lastCommentEnd = m.end;
213
- }
214
-
215
- // Propagate anchors from replies to parents
216
- // If a reply has an anchor but its parent doesn't, move the anchor to the parent
217
- // Track flags for special handling during marker generation
218
- for (const c of comments) {
219
- if (c.isReply && c.anchor && c.parentIdx !== null) {
220
- const parent = comments[c.parentIdx];
221
- if (parent && !parent.anchor) {
222
- parent.anchor = c.anchor;
223
- parent.anchorFromReply = true; // Parent's anchor came from a reply (markers placed by reply)
224
- c.placesParentMarkers = true; // This reply should place the parent's markers
225
- c.anchor = null;
226
- }
227
- }
228
- }
229
-
230
- // Build marked markdown - only parent comments get markers
231
- // Process from end to start to preserve positions
232
- let markedMarkdown = markdown;
233
-
234
- for (let i = comments.length - 1; i >= 0; i--) {
235
- const c = comments[i];
236
- if (!c) continue;
237
-
238
- if (c.isReply) {
239
- // Reply: remove from document entirely (will be in comments.xml only)
240
- // Also consume one preceding whitespace char to avoid double spaces.
241
- // We deliberately consume at most one — walking arbitrarily backwards
242
- // would shift positions that lower-index comments still depend on.
243
- let removeStart = c.start;
244
- if (removeStart > 0 && /\s/.test(markedMarkdown[removeStart - 1] ?? '')) {
245
- removeStart--;
246
- }
247
-
248
- // If this reply places parent's markers (anchor was propagated)
249
- if (c.placesParentMarkers && c.parentIdx !== null) {
250
- // Extract anchor text from the original match
251
- const anchorMatch = c.fullMatch.match(/\[([^\]]+)\]\{\.mark\}$/);
252
- if (anchorMatch) {
253
- const anchorText = anchorMatch[1] ?? '';
254
- // Output markers with PARENT's index around the anchor text
255
- const parentIdx = c.parentIdx;
256
- const replacement = `${MARKER_START_PREFIX}${parentIdx}${MARKER_SUFFIX}${anchorText}${MARKER_END_PREFIX}${parentIdx}${MARKER_SUFFIX}`;
257
- markedMarkdown = markedMarkdown.slice(0, removeStart) + replacement + markedMarkdown.slice(c.end);
258
- } else {
259
- markedMarkdown = markedMarkdown.slice(0, removeStart) + markedMarkdown.slice(c.end);
260
- }
261
- } else {
262
- markedMarkdown = markedMarkdown.slice(0, removeStart) + markedMarkdown.slice(c.end);
263
- }
264
- } else {
265
- // Parent comment
266
- if (c.anchorFromReply) {
267
- // Anchor markers are placed by the reply, just remove this comment.
268
- // Consume one preceding whitespace char only (see reply branch above).
269
- let removeStart = c.start;
270
- if (removeStart > 0 && /\s/.test(markedMarkdown[removeStart - 1] ?? '')) {
271
- removeStart--;
272
- }
273
- markedMarkdown = markedMarkdown.slice(0, removeStart) + markedMarkdown.slice(c.end);
274
- } else {
275
- // Normal case: replace with markers
276
- const anchor = c.anchor || '';
277
- const replacement = `${MARKER_START_PREFIX}${i}${MARKER_SUFFIX}${anchor}${MARKER_END_PREFIX}${i}${MARKER_SUFFIX}`;
278
- markedMarkdown = markedMarkdown.slice(0, c.start) + replacement + markedMarkdown.slice(c.end);
279
- }
280
- }
281
- }
282
-
283
- return { markedMarkdown, comments };
284
- }
285
-
286
- function createCommentsXml(comments: CommentWithIds[]): string {
287
- // Word expects date without milliseconds: 2025-12-30T08:33:00Z
288
- const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
289
-
290
- let xml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
291
- // Minimal namespaces matching golden file structure
292
- xml += '<w:comments xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" mc:Ignorable="w14 w15">';
293
-
294
- // Use a consistent rsid (8-char hex) for all comments in this batch
295
- const rsid = '00' + (Date.now() % 0xFFFFFF).toString(16).toUpperCase().padStart(6, '0');
296
-
297
- for (const comment of comments) {
298
- xml += `<w:comment w:id="${comment.id}" w:author="${escapeXml(comment.author)}" w:date="${now}" w:initials="${comment.author.split(' ').map(n => n[0]).join('')}">`;
299
- // First paragraph: rsidRDefault="00000000", annotationRef without rStyle wrapper
300
- xml += `<w:p w14:paraId="${comment.paraId}" w14:textId="77777777" w:rsidR="${rsid}" w:rsidRDefault="00000000">`;
301
- xml += `<w:r><w:annotationRef/></w:r>`;
302
- xml += `<w:r><w:t>${escapeXml(comment.text)}</w:t></w:r>`;
303
- xml += `</w:p>`;
304
- if (comment.isReply) {
305
- // Second empty paragraph: rsidRDefault matches rsidR
306
- xml += `<w:p w14:paraId="${comment.paraId2}" w14:textId="77777777" w:rsidR="${rsid}" w:rsidRDefault="${rsid}"/>`;
307
- }
308
- xml += `</w:comment>`;
309
- }
310
-
311
- xml += '</w:comments>';
312
- return xml;
313
- }
314
-
315
- function createCommentsExtendedXml(comments: CommentWithIds[]): string {
316
- let xml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
317
- // Minimal namespaces matching golden file structure
318
- xml += '<w15:commentsEx xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" mc:Ignorable="w14 w15">';
319
-
320
- for (const comment of comments) {
321
- if (comment.isReply && comment.parentParaId) {
322
- // Reply: use paraId2 (the second/empty paragraph) and link to parent's paraId
323
- xml += `<w15:commentEx w15:paraId="${comment.paraId2}" w15:paraIdParent="${comment.parentParaId}" w15:done="0"/>`;
324
- } else {
325
- // Parent comment: use paraId (first paragraph)
326
- xml += `<w15:commentEx w15:paraId="${comment.paraId}" w15:done="0"/>`;
327
- }
328
- }
329
-
330
- xml += '</w15:commentsEx>';
331
- return xml;
332
- }
333
-
334
- function generateDurableId(index: number): string {
335
- // Generate unique 8-char hex ID for durableId
336
- // CRITICAL: Must stay within signed 32-bit range (< 0x7FFFFFFF = 2147483647)
337
- // Word interprets durableIds as signed 32-bit integers
338
- const base = 0x10000000 + (Date.now() % 0x40000000); // Base between 0x10000000 and 0x50000000
339
- const id = (base + index * 0x01000000) % 0x7FFFFFFF; // Keep under signed 32-bit max
340
- return id.toString(16).toUpperCase().padStart(8, '0');
341
- }
342
-
343
- function createCommentsIdsXml(comments: CommentWithIds[]): string {
344
- let xml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
345
- // Minimal namespaces matching golden file structure
346
- xml += '<w16cid:commentsIds ';
347
- xml += 'xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" ';
348
- xml += 'xmlns:w16cid="http://schemas.microsoft.com/office/word/2016/wordml/cid" ';
349
- xml += 'mc:Ignorable="w16cid">';
350
-
351
- for (const comment of comments) {
352
- // ONE entry per comment using the LAST paragraph's paraId:
353
- // - Parent comments (1 paragraph): use paraId
354
- // - Reply comments (2 paragraphs): use paraId2 (the second/empty paragraph)
355
- const useParaId = comment.isReply ? comment.paraId2 : comment.paraId;
356
- xml += `<w16cid:commentId w16cid:paraId="${useParaId}" w16cid:durableId="${comment.durableId}"/>`;
357
- }
358
-
359
- xml += '</w16cid:commentsIds>';
360
- return xml;
361
- }
362
-
363
- function createCommentsExtensibleXml(comments: CommentWithIds[]): string {
364
- const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
365
-
366
- let xml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
367
- // Minimal namespaces matching golden file structure
368
- xml += '<w16cex:commentsExtensible ';
369
- xml += 'xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" ';
370
- xml += 'xmlns:w16cex="http://schemas.microsoft.com/office/word/2018/wordml/cex" ';
371
- xml += 'mc:Ignorable="w16cex">';
372
-
373
- for (const comment of comments) {
374
- // ONE entry per comment using the durableId
375
- xml += `<w16cex:commentExtensible w16cex:durableId="${comment.durableId}" w16cex:dateUtc="${now}"/>`;
376
- }
377
-
378
- xml += '</w16cex:commentsExtensible>';
379
- return xml;
380
- }
381
-
382
- // Generate deterministic user IDs for authors (no hardcoded personal data)
383
-
384
- function createPeopleXml(comments: CommentWithIds[]): string {
385
- // Extract unique authors
386
- const authors = [...new Set(comments.map(c => c.author))];
387
-
388
- let xml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
389
- xml += '<w15:people ';
390
- xml += 'xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" ';
391
- xml += 'xmlns:x="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ';
392
- xml += 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ';
393
- xml += 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" ';
394
- xml += 'xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" ';
395
- xml += 'xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" ';
396
- xml += 'xmlns:w16cex="http://schemas.microsoft.com/office/word/2018/wordml/cex" ';
397
- xml += 'xmlns:w16cid="http://schemas.microsoft.com/office/word/2016/wordml/cid" ';
398
- xml += 'xmlns:w16="http://schemas.microsoft.com/office/word/2018/wordml" ';
399
- xml += 'xmlns:w16sdtdh="http://schemas.microsoft.com/office/word/2020/wordml/sdtdatahash" ';
400
- xml += 'xmlns:w16se="http://schemas.microsoft.com/office/word/2015/wordml/symex" ';
401
- xml += 'mc:Ignorable="w14 w15 w16se w16cid w16 w16cex w16sdtdh">';
402
-
403
- for (const author of authors) {
404
- const userId = generateUserId(author);
405
- xml += `<w15:person w15:author="${escapeXml(author)}">`;
406
- xml += `<w15:presenceInfo w15:providerId="Windows Live" w15:userId="${userId}"/>`;
407
- xml += `</w15:person>`;
408
- }
409
-
410
- xml += '</w15:people>';
411
- return xml;
412
- }
413
-
414
- function generateUserId(author: string): string {
415
- // Generate a deterministic 16-char hex ID from author name
416
- let hash = 0;
417
- for (let i = 0; i < author.length; i++) {
418
- hash = ((hash << 5) - hash) + author.charCodeAt(i);
419
- hash = hash & hash;
420
- }
421
- return Math.abs(hash).toString(16).padStart(16, '0').slice(0, 16);
422
- }
423
-
424
- /**
425
- * Inject comments at marker positions
426
- */
427
- export async function injectCommentsAtMarkers(
428
- docxPath: string,
429
- comments: PreparedComment[],
430
- outputPath: string
431
- ): Promise<InjectionResult> {
432
- try {
433
- if (!fs.existsSync(docxPath)) {
434
- return { success: false, commentCount: 0, skippedComments: 0, error: `File not found: ${docxPath}` };
435
- }
436
-
437
- if (comments.length === 0) {
438
- fs.copyFileSync(docxPath, outputPath);
439
- return { success: true, commentCount: 0, skippedComments: 0 };
440
- }
441
-
442
- const zip = new AdmZip(docxPath);
443
- const documentEntry = zip.getEntry('word/document.xml');
444
- if (!documentEntry) {
445
- return { success: false, commentCount: 0, skippedComments: 0, error: 'Invalid DOCX: no document.xml' };
446
- }
447
-
448
- let documentXml = zip.readAsText(documentEntry);
449
-
450
- // Assign IDs and paraIds (IDs start at 1, not 0 - Word convention)
451
- const commentsWithIds: CommentWithIds[] = comments.map((c, idx) => ({
452
- ...c,
453
- id: String(idx + 1),
454
- paraId: generateParaId(idx, 1), // First paragraph (e.g., 10000001)
455
- paraId2: generateParaId(idx, 2), // Second paragraph (e.g., 10000002)
456
- durableId: generateDurableId(idx), // Unique ID for commentsIds/commentsExtensible
457
- }));
458
-
459
- // Link replies to parent paraIds
460
- for (const c of commentsWithIds) {
461
- if (c.isReply && c.parentIdx !== null) {
462
- const parent = commentsWithIds[c.parentIdx];
463
- if (parent) {
464
- c.parentParaId = parent.paraId;
465
- }
466
- }
467
- }
468
-
469
- const injectedIds = new Set<string>();
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.
490
- const parentComments = commentsWithIds.filter(c => !c.isReply);
491
-
492
- for (let i = parentComments.length - 1; i >= 0; i--) {
493
- const comment = parentComments[i];
494
- if (!comment) continue;
495
- const idx = comment.commentIdx;
496
-
497
- const startMarker = `${MARKER_START_PREFIX}${idx}${MARKER_SUFFIX}`;
498
- const endMarker = `${MARKER_END_PREFIX}${idx}${MARKER_SUFFIX}`;
499
-
500
- const slots = indexTextRuns(documentXml);
501
- const startHit = findMarkerSlot(slots, startMarker);
502
- const endHit = startHit ? findMarkerSlot(slots, endMarker) : null;
503
- if (!startHit || !endHit) continue;
504
-
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;
511
-
512
- let replacement = '';
513
- const replies = commentsWithIds.filter(c => c.isReply && c.parentIdx === comment?.commentIdx);
514
-
515
- const emitRangeStarts = () => {
516
- replacement += `<w:commentRangeStart w:id="${comment.id}"/>`;
517
- for (const reply of replies) {
518
- replacement += `<w:commentRangeStart w:id="${reply.id}"/>`;
519
- }
520
- };
521
-
522
- const emitRangeEnds = () => {
523
- replacement += `<w:commentRangeEnd w:id="${comment.id}"/>`;
524
- replacement += `<w:r><w:commentReference w:id="${comment.id}"/></w:r>`;
525
- for (const reply of replies) {
526
- replacement += `<w:commentRangeEnd w:id="${reply.id}"/>`;
527
- replacement += `<w:r><w:commentReference w:id="${reply.id}"/></w:r>`;
528
- injectedIds.add(reply.id);
529
- }
530
- };
531
-
532
- if (startRunOpen === endRunOpen) {
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);
538
- if (endInTextRel === -1) continue;
539
- let textBefore = startSlot.content.slice(0, startHit.rel);
540
- let anchorText = afterStart.slice(0, endInTextRel);
541
- let textAfter = afterStart.slice(endInTextRel + endMarker.length);
542
-
543
- // Empty anchor: borrow the next word so the comment has something
544
- // to anchor on. Then normalize the trailing double space.
545
- if (!anchorText && textAfter) {
546
- const wordMatch = textAfter.match(/^\s*(\S+)/);
547
- if (wordMatch) {
548
- anchorText = wordMatch[1] ?? '';
549
- textAfter = textAfter.slice(wordMatch[0].length);
550
- }
551
- }
552
- if (!anchorText && textBefore.endsWith(' ') && textAfter.startsWith(' ')) {
553
- textAfter = textAfter.slice(1);
554
- }
555
-
556
- if (textBefore) {
557
- replacement += `<w:r>${rPr}${tElement}${textBefore}</w:t></w:r>`;
558
- }
559
- emitRangeStarts();
560
- if (anchorText) {
561
- replacement += `<w:r>${rPr}${tElement}${anchorText}</w:t></w:r>`;
562
- }
563
- emitRangeEnds();
564
- if (textAfter) {
565
- replacement += `<w:r>${rPr}${tElement}${textAfter}</w:t></w:r>`;
566
- }
567
- documentXml = documentXml.slice(0, startRunOpen) + replacement + documentXml.slice(startRunClose);
568
- injectedIds.add(comment.id);
569
- continue;
570
- }
571
-
572
- // Multi-run path: markers sit in different <w:r> blocks because pandoc
573
- // applied mid-anchor styling. Split the start run at the start marker,
574
- // keep all middle runs verbatim (they carry the styled anchor portions),
575
- // split the end run at the end marker.
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);
580
-
581
- const middle = documentXml.slice(startRunClose, endRunOpen);
582
-
583
- if (startTextBefore) {
584
- replacement += `<w:r>${startSlot.rPr}${startSlot.tOpenTag}${startTextBefore}</w:t></w:r>`;
585
- }
586
- emitRangeStarts();
587
- if (startTextAfter) {
588
- replacement += `<w:r>${startSlot.rPr}${startSlot.tOpenTag}${startTextAfter}</w:t></w:r>`;
589
- }
590
- replacement += middle;
591
- if (endTextBefore) {
592
- replacement += `<w:r>${endSlot.rPr}${endSlot.tOpenTag}${endTextBefore}</w:t></w:r>`;
593
- }
594
- emitRangeEnds();
595
- if (endTextAfter) {
596
- replacement += `<w:r>${endSlot.rPr}${endSlot.tOpenTag}${endTextAfter}</w:t></w:r>`;
597
- }
598
-
599
- documentXml = documentXml.slice(0, startRunOpen) + replacement + documentXml.slice(endRunClose);
600
- injectedIds.add(comment.id);
601
- }
602
-
603
- // Add required namespaces to document.xml for comment threading
604
- const requiredNs: Record<string, string> = {
605
- 'xmlns:w14': 'http://schemas.microsoft.com/office/word/2010/wordml',
606
- 'xmlns:w15': 'http://schemas.microsoft.com/office/word/2012/wordml',
607
- 'xmlns:w16cid': 'http://schemas.microsoft.com/office/word/2016/wordml/cid',
608
- 'xmlns:w16cex': 'http://schemas.microsoft.com/office/word/2018/wordml/cex',
609
- 'xmlns:mc': 'http://schemas.openxmlformats.org/markup-compatibility/2006',
610
- };
611
-
612
- // Find <w:document and add namespaces
613
- const docTagMatch = documentXml.match(/<w:document[^>]*>/);
614
- if (docTagMatch) {
615
- let docTag = docTagMatch[0];
616
- let modified = false;
617
- for (const [attr, val] of Object.entries(requiredNs)) {
618
- if (!docTag.includes(attr)) {
619
- docTag = docTag.replace('>', ` ${attr}="${val}">`);
620
- modified = true;
621
- }
622
- }
623
- // Add mc:Ignorable if mc namespace was added
624
- if (modified && !docTag.includes('mc:Ignorable')) {
625
- docTag = docTag.replace('>', ' mc:Ignorable="w14 w15 w16cid w16cex">');
626
- }
627
- documentXml = documentXml.replace(docTagMatch[0], docTag);
628
- }
629
-
630
- // Update document.xml
631
- zip.updateFile('word/document.xml', Buffer.from(documentXml, 'utf-8'));
632
-
633
- // All comments (parents + replies) go in comments.xml
634
- // But only include if parent was injected
635
- const includedComments = commentsWithIds.filter(c => {
636
- if (!c.isReply) {
637
- return injectedIds.has(c.id);
638
- } else {
639
- // Include reply if its parent was injected
640
- const parent = c.parentIdx !== null ? commentsWithIds[c.parentIdx] : undefined;
641
- return parent && injectedIds.has(parent.id);
642
- }
643
- });
644
-
645
- // Create comments.xml
646
- const commentsXml = createCommentsXml(includedComments);
647
- if (zip.getEntry('word/comments.xml')) {
648
- zip.updateFile('word/comments.xml', Buffer.from(commentsXml, 'utf-8'));
649
- } else {
650
- zip.addFile('word/comments.xml', Buffer.from(commentsXml, 'utf-8'));
651
- }
652
-
653
- // Create commentsExtended.xml with reply threading
654
- const commentsExtXml = createCommentsExtendedXml(includedComments);
655
- if (zip.getEntry('word/commentsExtended.xml')) {
656
- zip.updateFile('word/commentsExtended.xml', Buffer.from(commentsExtXml, 'utf-8'));
657
- } else {
658
- zip.addFile('word/commentsExtended.xml', Buffer.from(commentsExtXml, 'utf-8'));
659
- }
660
-
661
- // Create commentsIds.xml (Word 2016+)
662
- const commentsIdsXml = createCommentsIdsXml(includedComments);
663
- if (zip.getEntry('word/commentsIds.xml')) {
664
- zip.updateFile('word/commentsIds.xml', Buffer.from(commentsIdsXml, 'utf-8'));
665
- } else {
666
- zip.addFile('word/commentsIds.xml', Buffer.from(commentsIdsXml, 'utf-8'));
667
- }
668
-
669
- // Create commentsExtensible.xml (Word 2018+)
670
- const commentsExtensibleXml = createCommentsExtensibleXml(includedComments);
671
- if (zip.getEntry('word/commentsExtensible.xml')) {
672
- zip.updateFile('word/commentsExtensible.xml', Buffer.from(commentsExtensibleXml, 'utf-8'));
673
- } else {
674
- zip.addFile('word/commentsExtensible.xml', Buffer.from(commentsExtensibleXml, 'utf-8'));
675
- }
676
-
677
- // Create people.xml (author definitions with Windows Live IDs)
678
- const peopleXml = createPeopleXml(includedComments);
679
- if (zip.getEntry('word/people.xml')) {
680
- zip.updateFile('word/people.xml', Buffer.from(peopleXml, 'utf-8'));
681
- } else {
682
- zip.addFile('word/people.xml', Buffer.from(peopleXml, 'utf-8'));
683
- }
684
-
685
- // Update [Content_Types].xml
686
- const contentTypesEntry = zip.getEntry('[Content_Types].xml');
687
- if (contentTypesEntry) {
688
- let contentTypes = zip.readAsText(contentTypesEntry);
689
-
690
- if (!contentTypes.includes('comments.xml')) {
691
- const insertPoint = contentTypes.lastIndexOf('</Types>');
692
- contentTypes = contentTypes.slice(0, insertPoint) +
693
- '<Override PartName="/word/comments.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/>\n' +
694
- contentTypes.slice(insertPoint);
695
- }
696
-
697
- if (!contentTypes.includes('commentsExtended.xml')) {
698
- const insertPoint = contentTypes.lastIndexOf('</Types>');
699
- contentTypes = contentTypes.slice(0, insertPoint) +
700
- '<Override PartName="/word/commentsExtended.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml"/>\n' +
701
- contentTypes.slice(insertPoint);
702
- }
703
-
704
- if (!contentTypes.includes('commentsIds.xml')) {
705
- const insertPoint = contentTypes.lastIndexOf('</Types>');
706
- contentTypes = contentTypes.slice(0, insertPoint) +
707
- '<Override PartName="/word/commentsIds.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.commentsIds+xml"/>\n' +
708
- contentTypes.slice(insertPoint);
709
- }
710
-
711
- if (!contentTypes.includes('commentsExtensible.xml')) {
712
- const insertPoint = contentTypes.lastIndexOf('</Types>');
713
- contentTypes = contentTypes.slice(0, insertPoint) +
714
- '<Override PartName="/word/commentsExtensible.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtensible+xml"/>\n' +
715
- contentTypes.slice(insertPoint);
716
- }
717
-
718
- if (!contentTypes.includes('people.xml')) {
719
- const insertPoint = contentTypes.lastIndexOf('</Types>');
720
- contentTypes = contentTypes.slice(0, insertPoint) +
721
- '<Override PartName="/word/people.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.people+xml"/>\n' +
722
- contentTypes.slice(insertPoint);
723
- }
724
-
725
- zip.updateFile('[Content_Types].xml', Buffer.from(contentTypes, 'utf-8'));
726
- }
727
-
728
- // Update relationships
729
- const relsEntry = zip.getEntry('word/_rels/document.xml.rels');
730
- if (relsEntry) {
731
- let rels = zip.readAsText(relsEntry);
732
-
733
- const rIdMatches = rels.match(/rId(\d+)/g) || [];
734
- const maxId = rIdMatches.reduce((max, r) => Math.max(max, parseInt(r.replace('rId', ''))), 0);
735
-
736
- if (!rels.includes('comments.xml')) {
737
- const insertPoint = rels.lastIndexOf('</Relationships>');
738
- rels = rels.slice(0, insertPoint) +
739
- `<Relationship Id="rId${maxId + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/>\n` +
740
- rels.slice(insertPoint);
741
- }
742
-
743
- if (!rels.includes('commentsExtended.xml')) {
744
- const insertPoint = rels.lastIndexOf('</Relationships>');
745
- rels = rels.slice(0, insertPoint) +
746
- `<Relationship Id="rId${maxId + 2}" Type="http://schemas.microsoft.com/office/2011/relationships/commentsExtended" Target="commentsExtended.xml"/>\n` +
747
- rels.slice(insertPoint);
748
- }
749
-
750
- if (!rels.includes('commentsIds.xml')) {
751
- const insertPoint = rels.lastIndexOf('</Relationships>');
752
- rels = rels.slice(0, insertPoint) +
753
- `<Relationship Id="rId${maxId + 3}" Type="http://schemas.microsoft.com/office/2016/09/relationships/commentsIds" Target="commentsIds.xml"/>\n` +
754
- rels.slice(insertPoint);
755
- }
756
-
757
- if (!rels.includes('commentsExtensible.xml')) {
758
- const insertPoint = rels.lastIndexOf('</Relationships>');
759
- rels = rels.slice(0, insertPoint) +
760
- `<Relationship Id="rId${maxId + 4}" Type="http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible" Target="commentsExtensible.xml"/>\n` +
761
- rels.slice(insertPoint);
762
- }
763
-
764
- if (!rels.includes('people.xml')) {
765
- const insertPoint = rels.lastIndexOf('</Relationships>');
766
- rels = rels.slice(0, insertPoint) +
767
- `<Relationship Id="rId${maxId + 5}" Type="http://schemas.microsoft.com/office/2011/relationships/people" Target="people.xml"/>\n` +
768
- rels.slice(insertPoint);
769
- }
770
-
771
- zip.updateFile('word/_rels/document.xml.rels', Buffer.from(rels, 'utf-8'));
772
- }
773
-
774
- zip.writeZip(outputPath);
775
-
776
- const parentCount = includedComments.filter(c => !c.isReply).length;
777
- const replyCount = includedComments.filter(c => c.isReply).length;
778
-
779
- return {
780
- success: true,
781
- commentCount: parentCount,
782
- replyCount: replyCount,
783
- skippedComments: comments.length - includedComments.length,
784
- };
785
-
786
- } catch (err: any) {
787
- return { success: false, commentCount: 0, skippedComments: 0, error: err.message };
788
- }
789
- }
1
+ /**
2
+ * Word comment injection with reply threading
3
+ *
4
+ * Flow:
5
+ * 1. prepareMarkdownWithMarkers() - Parse comments, detect reply relationships
6
+ * - First comment in a cluster = parent (gets markers: ⟦CMS:n⟧anchor⟦CME:n⟧)
7
+ * - Subsequent adjacent comments = replies (no markers, attach to parent)
8
+ * 2. Pandoc converts to DOCX
9
+ * 3. injectCommentsAtMarkers() - Insert comment ranges for parents only
10
+ * - Replies go in comments.xml with parent reference in commentsExtended.xml
11
+ */
12
+
13
+ import * as fs from 'fs';
14
+ import AdmZip from 'adm-zip';
15
+ import { escapeXml } from './utils.js';
16
+ import { indexTextRuns, type TextRunSlot } from './ooxml.js';
17
+
18
+ const MARKER_START_PREFIX = '⟦CMS:';
19
+ const MARKER_END_PREFIX = '⟦CME:';
20
+ const MARKER_SUFFIX = '⟧';
21
+
22
+ interface ParsedComment {
23
+ author: string;
24
+ text: string;
25
+ anchor: string | null;
26
+ start: number;
27
+ end: number;
28
+ fullMatch: string;
29
+ }
30
+
31
+ interface PreparedComment extends ParsedComment {
32
+ isReply: boolean;
33
+ parentIdx: number | null;
34
+ commentIdx: number;
35
+ anchorFromReply?: boolean;
36
+ placesParentMarkers?: boolean;
37
+ }
38
+
39
+ interface PrepareResult {
40
+ markedMarkdown: string;
41
+ comments: PreparedComment[];
42
+ }
43
+
44
+ interface CommentWithIds extends PreparedComment {
45
+ id: string;
46
+ paraId: string;
47
+ paraId2: string;
48
+ durableId: string;
49
+ parentParaId?: string;
50
+ }
51
+
52
+ interface InjectionResult {
53
+ success: boolean;
54
+ commentCount: number;
55
+ replyCount?: number;
56
+ skippedComments: number;
57
+ error?: string;
58
+ }
59
+
60
+ function generateParaId(commentIdx: number, paraNum: number): string {
61
+ // Generate 8-character uppercase hex ID matching Word format
62
+ // Word uses IDs like "3F25BC58", "0331C187"
63
+ // Must be deterministic - same inputs always produce same output
64
+ const id = 0x10000000 + (commentIdx * 0x00100000) + (paraNum * 0x00001000);
65
+ return id.toString(16).toUpperCase().padStart(8, '0');
66
+ }
67
+
68
+ /**
69
+ * Parse comments and create markers
70
+ *
71
+ * Returns:
72
+ * - markedMarkdown: markdown with markers for parent comments only
73
+ * - comments: array with author, text, isReply, parentIdx
74
+ */
75
+ export function prepareMarkdownWithMarkers(markdown: string): PrepareResult {
76
+ // Match the comment block first; extend manually to capture an optional
77
+ // trailing `[anchor]{.mark}` span. A regex `[^\]]+` for the anchor would
78
+ // bail on the inner `]` of nested syntax (e.g. `[[0..9]]{.mark}` or
79
+ // `[*phrase*]{.mark}` after pandoc-rewriting), so we walk the brackets
80
+ // ourselves and verify a `{.mark}` suffix.
81
+ const commentPattern = /\{>>([\s\S]+?)<<\}/g;
82
+
83
+ function tryParseTrailingAnchor(
84
+ text: string,
85
+ fromIdx: number,
86
+ ): { anchor: string; endIdx: number } | null {
87
+ let i = fromIdx;
88
+ while (i < text.length && /\s/.test(text[i] ?? '')) i++;
89
+ if (text[i] !== '[') return null;
90
+ let depth = 1;
91
+ let j = i + 1;
92
+ while (j < text.length) {
93
+ const ch = text[j];
94
+ if (ch === '[') depth++;
95
+ else if (ch === ']') {
96
+ depth--;
97
+ if (depth === 0) break;
98
+ }
99
+ j++;
100
+ }
101
+ if (depth !== 0) return null;
102
+ if (text.slice(j + 1, j + 8) !== '{.mark}') return null;
103
+ return { anchor: text.slice(i + 1, j), endIdx: j + 8 };
104
+ }
105
+
106
+ const REPLY_PREFIX = '↪ ';
107
+ const rawMatches: (ParsedComment & { explicitReply: boolean })[] = [];
108
+ let match: RegExpExecArray | null;
109
+ while ((match = commentPattern.exec(markdown)) !== null) {
110
+ const content = match[1] ?? '';
111
+ let author = 'Unknown';
112
+ let text = content;
113
+ const colonIdx = content.indexOf(':');
114
+ if (colonIdx > 0 && colonIdx < 30) {
115
+ author = content.slice(0, colonIdx).trim();
116
+ text = content.slice(colonIdx + 1).trim();
117
+ }
118
+
119
+ // The `↪ ` prefix is the authoritative reply signal emitted by
120
+ // `insertCommentsIntoMarkdown`. Strip it from the author before injection
121
+ // so Word displays the real name.
122
+ let explicitReply = false;
123
+ if (author.startsWith(REPLY_PREFIX)) {
124
+ explicitReply = true;
125
+ author = author.slice(REPLY_PREFIX.length).trim();
126
+ }
127
+
128
+ const commentEnd = match.index + match[0].length;
129
+ const trailing = tryParseTrailingAnchor(markdown, commentEnd);
130
+
131
+ rawMatches.push({
132
+ author,
133
+ text,
134
+ anchor: trailing ? trailing.anchor : null,
135
+ start: match.index,
136
+ end: trailing ? trailing.endIdx : commentEnd,
137
+ fullMatch: markdown.slice(match.index, trailing ? trailing.endIdx : commentEnd),
138
+ explicitReply,
139
+ });
140
+
141
+ // Advance regex lastIndex past the consumed anchor so the next iteration
142
+ // doesn't re-scan inside it (e.g. `[*emphasis*]{.mark}` would otherwise
143
+ // tempt the matcher to look for another `{>>...<<}` in the body of the
144
+ // anchor span).
145
+ if (trailing) {
146
+ commentPattern.lastIndex = trailing.endIdx;
147
+ }
148
+ }
149
+
150
+ if (rawMatches.length === 0) {
151
+ return { markedMarkdown: markdown, comments: [] };
152
+ }
153
+
154
+ // Two-mode reply detection driven by the markdown itself:
155
+ // - If any comment carries the `↪ ` author prefix, the markdown came
156
+ // through `insertCommentsIntoMarkdown` and we use prefix-only mode.
157
+ // Distinct clusters that happen to land at gap=0 (a real failure
158
+ // mode on dense reviewer docs — 298-comment paper produced 9 such
159
+ // collisions) are not misthreaded.
160
+ // - If no comment carries the prefix, the markdown was hand-typed.
161
+ // Fall back to gap < 10 adjacency for backward compat with users
162
+ // who write CriticMarkup directly.
163
+ const ADJACENT_THRESHOLD = 10;
164
+ const useExplicitMode = rawMatches.some(m => m.explicitReply);
165
+ const comments: PreparedComment[] = [];
166
+ let clusterParentIdx = -1; // Index of first comment in current cluster
167
+ let lastCommentEnd = -1;
168
+
169
+ for (let i = 0; i < rawMatches.length; i++) {
170
+ const m = rawMatches[i];
171
+ if (!m) continue;
172
+
173
+ const gap = lastCommentEnd >= 0 ? m.start - lastCommentEnd : Infinity;
174
+ const isAdjacent = useExplicitMode
175
+ ? m.explicitReply
176
+ : gap < ADJACENT_THRESHOLD;
177
+
178
+ // Reset cluster if there's a gap (comments not in same cluster)
179
+ if (!isAdjacent) {
180
+ clusterParentIdx = -1;
181
+ }
182
+
183
+ if (clusterParentIdx === -1) {
184
+ // First comment in cluster = parent (regardless of author)
185
+ comments.push({
186
+ author: m.author,
187
+ text: m.text,
188
+ anchor: m.anchor,
189
+ start: m.start,
190
+ end: m.end,
191
+ fullMatch: m.fullMatch,
192
+ isReply: false,
193
+ parentIdx: null,
194
+ commentIdx: comments.length
195
+ });
196
+ clusterParentIdx = comments.length - 1;
197
+ } else {
198
+ // Subsequent comment in cluster = reply to first comment
199
+ comments.push({
200
+ author: m.author,
201
+ text: m.text,
202
+ anchor: m.anchor,
203
+ start: m.start,
204
+ end: m.end,
205
+ fullMatch: m.fullMatch,
206
+ isReply: true,
207
+ parentIdx: clusterParentIdx,
208
+ commentIdx: comments.length
209
+ });
210
+ }
211
+
212
+ lastCommentEnd = m.end;
213
+ }
214
+
215
+ // Propagate anchors from replies to parents
216
+ // If a reply has an anchor but its parent doesn't, move the anchor to the parent
217
+ // Track flags for special handling during marker generation
218
+ for (const c of comments) {
219
+ if (c.isReply && c.anchor && c.parentIdx !== null) {
220
+ const parent = comments[c.parentIdx];
221
+ if (parent && !parent.anchor) {
222
+ parent.anchor = c.anchor;
223
+ parent.anchorFromReply = true; // Parent's anchor came from a reply (markers placed by reply)
224
+ c.placesParentMarkers = true; // This reply should place the parent's markers
225
+ c.anchor = null;
226
+ }
227
+ }
228
+ }
229
+
230
+ // Build marked markdown - only parent comments get markers
231
+ // Process from end to start to preserve positions
232
+ let markedMarkdown = markdown;
233
+
234
+ for (let i = comments.length - 1; i >= 0; i--) {
235
+ const c = comments[i];
236
+ if (!c) continue;
237
+
238
+ if (c.isReply) {
239
+ // Reply: remove from document entirely (will be in comments.xml only)
240
+ // Also consume one preceding whitespace char to avoid double spaces.
241
+ // We deliberately consume at most one — walking arbitrarily backwards
242
+ // would shift positions that lower-index comments still depend on.
243
+ let removeStart = c.start;
244
+ if (removeStart > 0 && /\s/.test(markedMarkdown[removeStart - 1] ?? '')) {
245
+ removeStart--;
246
+ }
247
+
248
+ // If this reply places parent's markers (anchor was propagated)
249
+ if (c.placesParentMarkers && c.parentIdx !== null) {
250
+ // Extract anchor text from the original match
251
+ const anchorMatch = c.fullMatch.match(/\[([^\]]+)\]\{\.mark\}$/);
252
+ if (anchorMatch) {
253
+ const anchorText = anchorMatch[1] ?? '';
254
+ // Output markers with PARENT's index around the anchor text
255
+ const parentIdx = c.parentIdx;
256
+ const replacement = `${MARKER_START_PREFIX}${parentIdx}${MARKER_SUFFIX}${anchorText}${MARKER_END_PREFIX}${parentIdx}${MARKER_SUFFIX}`;
257
+ markedMarkdown = markedMarkdown.slice(0, removeStart) + replacement + markedMarkdown.slice(c.end);
258
+ } else {
259
+ markedMarkdown = markedMarkdown.slice(0, removeStart) + markedMarkdown.slice(c.end);
260
+ }
261
+ } else {
262
+ markedMarkdown = markedMarkdown.slice(0, removeStart) + markedMarkdown.slice(c.end);
263
+ }
264
+ } else {
265
+ // Parent comment
266
+ if (c.anchorFromReply) {
267
+ // Anchor markers are placed by the reply, just remove this comment.
268
+ // Consume one preceding whitespace char only (see reply branch above).
269
+ let removeStart = c.start;
270
+ if (removeStart > 0 && /\s/.test(markedMarkdown[removeStart - 1] ?? '')) {
271
+ removeStart--;
272
+ }
273
+ markedMarkdown = markedMarkdown.slice(0, removeStart) + markedMarkdown.slice(c.end);
274
+ } else {
275
+ // Normal case: replace with markers
276
+ const anchor = c.anchor || '';
277
+ const replacement = `${MARKER_START_PREFIX}${i}${MARKER_SUFFIX}${anchor}${MARKER_END_PREFIX}${i}${MARKER_SUFFIX}`;
278
+ markedMarkdown = markedMarkdown.slice(0, c.start) + replacement + markedMarkdown.slice(c.end);
279
+ }
280
+ }
281
+ }
282
+
283
+ return { markedMarkdown, comments };
284
+ }
285
+
286
+ function createCommentsXml(comments: CommentWithIds[]): string {
287
+ // Word expects date without milliseconds: 2025-12-30T08:33:00Z
288
+ const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
289
+
290
+ let xml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
291
+ // Minimal namespaces matching golden file structure
292
+ xml += '<w:comments xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" mc:Ignorable="w14 w15">';
293
+
294
+ // Use a consistent rsid (8-char hex) for all comments in this batch
295
+ const rsid = '00' + (Date.now() % 0xFFFFFF).toString(16).toUpperCase().padStart(6, '0');
296
+
297
+ for (const comment of comments) {
298
+ xml += `<w:comment w:id="${comment.id}" w:author="${escapeXml(comment.author)}" w:date="${now}" w:initials="${comment.author.split(' ').map(n => n[0]).join('')}">`;
299
+ // First paragraph: rsidRDefault="00000000", annotationRef without rStyle wrapper
300
+ xml += `<w:p w14:paraId="${comment.paraId}" w14:textId="77777777" w:rsidR="${rsid}" w:rsidRDefault="00000000">`;
301
+ xml += `<w:r><w:annotationRef/></w:r>`;
302
+ xml += `<w:r><w:t>${escapeXml(comment.text)}</w:t></w:r>`;
303
+ xml += `</w:p>`;
304
+ if (comment.isReply) {
305
+ // Second empty paragraph: rsidRDefault matches rsidR
306
+ xml += `<w:p w14:paraId="${comment.paraId2}" w14:textId="77777777" w:rsidR="${rsid}" w:rsidRDefault="${rsid}"/>`;
307
+ }
308
+ xml += `</w:comment>`;
309
+ }
310
+
311
+ xml += '</w:comments>';
312
+ return xml;
313
+ }
314
+
315
+ function createCommentsExtendedXml(comments: CommentWithIds[]): string {
316
+ let xml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
317
+ // Minimal namespaces matching golden file structure
318
+ xml += '<w15:commentsEx xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" mc:Ignorable="w14 w15">';
319
+
320
+ for (const comment of comments) {
321
+ if (comment.isReply && comment.parentParaId) {
322
+ // Reply: use paraId2 (the second/empty paragraph) and link to parent's paraId
323
+ xml += `<w15:commentEx w15:paraId="${comment.paraId2}" w15:paraIdParent="${comment.parentParaId}" w15:done="0"/>`;
324
+ } else {
325
+ // Parent comment: use paraId (first paragraph)
326
+ xml += `<w15:commentEx w15:paraId="${comment.paraId}" w15:done="0"/>`;
327
+ }
328
+ }
329
+
330
+ xml += '</w15:commentsEx>';
331
+ return xml;
332
+ }
333
+
334
+ function generateDurableId(index: number): string {
335
+ // Generate unique 8-char hex ID for durableId
336
+ // CRITICAL: Must stay within signed 32-bit range (< 0x7FFFFFFF = 2147483647)
337
+ // Word interprets durableIds as signed 32-bit integers
338
+ const base = 0x10000000 + (Date.now() % 0x40000000); // Base between 0x10000000 and 0x50000000
339
+ const id = (base + index * 0x01000000) % 0x7FFFFFFF; // Keep under signed 32-bit max
340
+ return id.toString(16).toUpperCase().padStart(8, '0');
341
+ }
342
+
343
+ function createCommentsIdsXml(comments: CommentWithIds[]): string {
344
+ let xml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
345
+ // Minimal namespaces matching golden file structure
346
+ xml += '<w16cid:commentsIds ';
347
+ xml += 'xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" ';
348
+ xml += 'xmlns:w16cid="http://schemas.microsoft.com/office/word/2016/wordml/cid" ';
349
+ xml += 'mc:Ignorable="w16cid">';
350
+
351
+ for (const comment of comments) {
352
+ // ONE entry per comment using the LAST paragraph's paraId:
353
+ // - Parent comments (1 paragraph): use paraId
354
+ // - Reply comments (2 paragraphs): use paraId2 (the second/empty paragraph)
355
+ const useParaId = comment.isReply ? comment.paraId2 : comment.paraId;
356
+ xml += `<w16cid:commentId w16cid:paraId="${useParaId}" w16cid:durableId="${comment.durableId}"/>`;
357
+ }
358
+
359
+ xml += '</w16cid:commentsIds>';
360
+ return xml;
361
+ }
362
+
363
+ function createCommentsExtensibleXml(comments: CommentWithIds[]): string {
364
+ const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
365
+
366
+ let xml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
367
+ // Minimal namespaces matching golden file structure
368
+ xml += '<w16cex:commentsExtensible ';
369
+ xml += 'xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" ';
370
+ xml += 'xmlns:w16cex="http://schemas.microsoft.com/office/word/2018/wordml/cex" ';
371
+ xml += 'mc:Ignorable="w16cex">';
372
+
373
+ for (const comment of comments) {
374
+ // ONE entry per comment using the durableId
375
+ xml += `<w16cex:commentExtensible w16cex:durableId="${comment.durableId}" w16cex:dateUtc="${now}"/>`;
376
+ }
377
+
378
+ xml += '</w16cex:commentsExtensible>';
379
+ return xml;
380
+ }
381
+
382
+ // Generate deterministic user IDs for authors (no hardcoded personal data)
383
+
384
+ function createPeopleXml(comments: CommentWithIds[]): string {
385
+ // Extract unique authors
386
+ const authors = [...new Set(comments.map(c => c.author))];
387
+
388
+ let xml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
389
+ xml += '<w15:people ';
390
+ xml += 'xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" ';
391
+ xml += 'xmlns:x="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ';
392
+ xml += 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ';
393
+ xml += 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" ';
394
+ xml += 'xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" ';
395
+ xml += 'xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" ';
396
+ xml += 'xmlns:w16cex="http://schemas.microsoft.com/office/word/2018/wordml/cex" ';
397
+ xml += 'xmlns:w16cid="http://schemas.microsoft.com/office/word/2016/wordml/cid" ';
398
+ xml += 'xmlns:w16="http://schemas.microsoft.com/office/word/2018/wordml" ';
399
+ xml += 'xmlns:w16sdtdh="http://schemas.microsoft.com/office/word/2020/wordml/sdtdatahash" ';
400
+ xml += 'xmlns:w16se="http://schemas.microsoft.com/office/word/2015/wordml/symex" ';
401
+ xml += 'mc:Ignorable="w14 w15 w16se w16cid w16 w16cex w16sdtdh">';
402
+
403
+ for (const author of authors) {
404
+ const userId = generateUserId(author);
405
+ xml += `<w15:person w15:author="${escapeXml(author)}">`;
406
+ xml += `<w15:presenceInfo w15:providerId="Windows Live" w15:userId="${userId}"/>`;
407
+ xml += `</w15:person>`;
408
+ }
409
+
410
+ xml += '</w15:people>';
411
+ return xml;
412
+ }
413
+
414
+ function generateUserId(author: string): string {
415
+ // Generate a deterministic 16-char hex ID from author name
416
+ let hash = 0;
417
+ for (let i = 0; i < author.length; i++) {
418
+ hash = ((hash << 5) - hash) + author.charCodeAt(i);
419
+ hash = hash & hash;
420
+ }
421
+ return Math.abs(hash).toString(16).padStart(16, '0').slice(0, 16);
422
+ }
423
+
424
+ /**
425
+ * Inject comments at marker positions
426
+ */
427
+ export async function injectCommentsAtMarkers(
428
+ docxPath: string,
429
+ comments: PreparedComment[],
430
+ outputPath: string
431
+ ): Promise<InjectionResult> {
432
+ try {
433
+ if (!fs.existsSync(docxPath)) {
434
+ return { success: false, commentCount: 0, skippedComments: 0, error: `File not found: ${docxPath}` };
435
+ }
436
+
437
+ if (comments.length === 0) {
438
+ fs.copyFileSync(docxPath, outputPath);
439
+ return { success: true, commentCount: 0, skippedComments: 0 };
440
+ }
441
+
442
+ const zip = new AdmZip(docxPath);
443
+ const documentEntry = zip.getEntry('word/document.xml');
444
+ if (!documentEntry) {
445
+ return { success: false, commentCount: 0, skippedComments: 0, error: 'Invalid DOCX: no document.xml' };
446
+ }
447
+
448
+ let documentXml = zip.readAsText(documentEntry);
449
+
450
+ // Assign IDs and paraIds (IDs start at 1, not 0 - Word convention)
451
+ const commentsWithIds: CommentWithIds[] = comments.map((c, idx) => ({
452
+ ...c,
453
+ id: String(idx + 1),
454
+ paraId: generateParaId(idx, 1), // First paragraph (e.g., 10000001)
455
+ paraId2: generateParaId(idx, 2), // Second paragraph (e.g., 10000002)
456
+ durableId: generateDurableId(idx), // Unique ID for commentsIds/commentsExtensible
457
+ }));
458
+
459
+ // Link replies to parent paraIds
460
+ for (const c of commentsWithIds) {
461
+ if (c.isReply && c.parentIdx !== null) {
462
+ const parent = commentsWithIds[c.parentIdx];
463
+ if (parent) {
464
+ c.parentParaId = parent.paraId;
465
+ }
466
+ }
467
+ }
468
+
469
+ const injectedIds = new Set<string>();
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.
490
+ const parentComments = commentsWithIds.filter(c => !c.isReply);
491
+
492
+ for (let i = parentComments.length - 1; i >= 0; i--) {
493
+ const comment = parentComments[i];
494
+ if (!comment) continue;
495
+ const idx = comment.commentIdx;
496
+
497
+ const startMarker = `${MARKER_START_PREFIX}${idx}${MARKER_SUFFIX}`;
498
+ const endMarker = `${MARKER_END_PREFIX}${idx}${MARKER_SUFFIX}`;
499
+
500
+ const slots = indexTextRuns(documentXml);
501
+ const startHit = findMarkerSlot(slots, startMarker);
502
+ const endHit = startHit ? findMarkerSlot(slots, endMarker) : null;
503
+ if (!startHit || !endHit) continue;
504
+
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;
511
+
512
+ let replacement = '';
513
+ const replies = commentsWithIds.filter(c => c.isReply && c.parentIdx === comment?.commentIdx);
514
+
515
+ const emitRangeStarts = () => {
516
+ replacement += `<w:commentRangeStart w:id="${comment.id}"/>`;
517
+ for (const reply of replies) {
518
+ replacement += `<w:commentRangeStart w:id="${reply.id}"/>`;
519
+ }
520
+ };
521
+
522
+ const emitRangeEnds = () => {
523
+ replacement += `<w:commentRangeEnd w:id="${comment.id}"/>`;
524
+ replacement += `<w:r><w:commentReference w:id="${comment.id}"/></w:r>`;
525
+ for (const reply of replies) {
526
+ replacement += `<w:commentRangeEnd w:id="${reply.id}"/>`;
527
+ replacement += `<w:r><w:commentReference w:id="${reply.id}"/></w:r>`;
528
+ injectedIds.add(reply.id);
529
+ }
530
+ };
531
+
532
+ if (startRunOpen === endRunOpen) {
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);
538
+ if (endInTextRel === -1) continue;
539
+ let textBefore = startSlot.content.slice(0, startHit.rel);
540
+ let anchorText = afterStart.slice(0, endInTextRel);
541
+ let textAfter = afterStart.slice(endInTextRel + endMarker.length);
542
+
543
+ // Empty anchor: borrow the next word so the comment has something
544
+ // to anchor on. Then normalize the trailing double space.
545
+ if (!anchorText && textAfter) {
546
+ const wordMatch = textAfter.match(/^\s*(\S+)/);
547
+ if (wordMatch) {
548
+ anchorText = wordMatch[1] ?? '';
549
+ textAfter = textAfter.slice(wordMatch[0].length);
550
+ }
551
+ }
552
+ if (!anchorText && textBefore.endsWith(' ') && textAfter.startsWith(' ')) {
553
+ textAfter = textAfter.slice(1);
554
+ }
555
+
556
+ if (textBefore) {
557
+ replacement += `<w:r>${rPr}${tElement}${textBefore}</w:t></w:r>`;
558
+ }
559
+ emitRangeStarts();
560
+ if (anchorText) {
561
+ replacement += `<w:r>${rPr}${tElement}${anchorText}</w:t></w:r>`;
562
+ }
563
+ emitRangeEnds();
564
+ if (textAfter) {
565
+ replacement += `<w:r>${rPr}${tElement}${textAfter}</w:t></w:r>`;
566
+ }
567
+ documentXml = documentXml.slice(0, startRunOpen) + replacement + documentXml.slice(startRunClose);
568
+ injectedIds.add(comment.id);
569
+ continue;
570
+ }
571
+
572
+ // Multi-run path: markers sit in different <w:r> blocks because pandoc
573
+ // applied mid-anchor styling. Split the start run at the start marker,
574
+ // keep all middle runs verbatim (they carry the styled anchor portions),
575
+ // split the end run at the end marker.
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);
580
+
581
+ const middle = documentXml.slice(startRunClose, endRunOpen);
582
+
583
+ if (startTextBefore) {
584
+ replacement += `<w:r>${startSlot.rPr}${startSlot.tOpenTag}${startTextBefore}</w:t></w:r>`;
585
+ }
586
+ emitRangeStarts();
587
+ if (startTextAfter) {
588
+ replacement += `<w:r>${startSlot.rPr}${startSlot.tOpenTag}${startTextAfter}</w:t></w:r>`;
589
+ }
590
+ replacement += middle;
591
+ if (endTextBefore) {
592
+ replacement += `<w:r>${endSlot.rPr}${endSlot.tOpenTag}${endTextBefore}</w:t></w:r>`;
593
+ }
594
+ emitRangeEnds();
595
+ if (endTextAfter) {
596
+ replacement += `<w:r>${endSlot.rPr}${endSlot.tOpenTag}${endTextAfter}</w:t></w:r>`;
597
+ }
598
+
599
+ documentXml = documentXml.slice(0, startRunOpen) + replacement + documentXml.slice(endRunClose);
600
+ injectedIds.add(comment.id);
601
+ }
602
+
603
+ // Add required namespaces to document.xml for comment threading
604
+ const requiredNs: Record<string, string> = {
605
+ 'xmlns:w14': 'http://schemas.microsoft.com/office/word/2010/wordml',
606
+ 'xmlns:w15': 'http://schemas.microsoft.com/office/word/2012/wordml',
607
+ 'xmlns:w16cid': 'http://schemas.microsoft.com/office/word/2016/wordml/cid',
608
+ 'xmlns:w16cex': 'http://schemas.microsoft.com/office/word/2018/wordml/cex',
609
+ 'xmlns:mc': 'http://schemas.openxmlformats.org/markup-compatibility/2006',
610
+ };
611
+
612
+ // Find <w:document and add namespaces
613
+ const docTagMatch = documentXml.match(/<w:document[^>]*>/);
614
+ if (docTagMatch) {
615
+ let docTag = docTagMatch[0];
616
+ let modified = false;
617
+ for (const [attr, val] of Object.entries(requiredNs)) {
618
+ if (!docTag.includes(attr)) {
619
+ docTag = docTag.replace('>', ` ${attr}="${val}">`);
620
+ modified = true;
621
+ }
622
+ }
623
+ // Add mc:Ignorable if mc namespace was added
624
+ if (modified && !docTag.includes('mc:Ignorable')) {
625
+ docTag = docTag.replace('>', ' mc:Ignorable="w14 w15 w16cid w16cex">');
626
+ }
627
+ documentXml = documentXml.replace(docTagMatch[0], docTag);
628
+ }
629
+
630
+ // Update document.xml
631
+ zip.updateFile('word/document.xml', Buffer.from(documentXml, 'utf-8'));
632
+
633
+ // All comments (parents + replies) go in comments.xml
634
+ // But only include if parent was injected
635
+ const includedComments = commentsWithIds.filter(c => {
636
+ if (!c.isReply) {
637
+ return injectedIds.has(c.id);
638
+ } else {
639
+ // Include reply if its parent was injected
640
+ const parent = c.parentIdx !== null ? commentsWithIds[c.parentIdx] : undefined;
641
+ return parent && injectedIds.has(parent.id);
642
+ }
643
+ });
644
+
645
+ // Create comments.xml
646
+ const commentsXml = createCommentsXml(includedComments);
647
+ if (zip.getEntry('word/comments.xml')) {
648
+ zip.updateFile('word/comments.xml', Buffer.from(commentsXml, 'utf-8'));
649
+ } else {
650
+ zip.addFile('word/comments.xml', Buffer.from(commentsXml, 'utf-8'));
651
+ }
652
+
653
+ // Create commentsExtended.xml with reply threading
654
+ const commentsExtXml = createCommentsExtendedXml(includedComments);
655
+ if (zip.getEntry('word/commentsExtended.xml')) {
656
+ zip.updateFile('word/commentsExtended.xml', Buffer.from(commentsExtXml, 'utf-8'));
657
+ } else {
658
+ zip.addFile('word/commentsExtended.xml', Buffer.from(commentsExtXml, 'utf-8'));
659
+ }
660
+
661
+ // Create commentsIds.xml (Word 2016+)
662
+ const commentsIdsXml = createCommentsIdsXml(includedComments);
663
+ if (zip.getEntry('word/commentsIds.xml')) {
664
+ zip.updateFile('word/commentsIds.xml', Buffer.from(commentsIdsXml, 'utf-8'));
665
+ } else {
666
+ zip.addFile('word/commentsIds.xml', Buffer.from(commentsIdsXml, 'utf-8'));
667
+ }
668
+
669
+ // Create commentsExtensible.xml (Word 2018+)
670
+ const commentsExtensibleXml = createCommentsExtensibleXml(includedComments);
671
+ if (zip.getEntry('word/commentsExtensible.xml')) {
672
+ zip.updateFile('word/commentsExtensible.xml', Buffer.from(commentsExtensibleXml, 'utf-8'));
673
+ } else {
674
+ zip.addFile('word/commentsExtensible.xml', Buffer.from(commentsExtensibleXml, 'utf-8'));
675
+ }
676
+
677
+ // Create people.xml (author definitions with Windows Live IDs)
678
+ const peopleXml = createPeopleXml(includedComments);
679
+ if (zip.getEntry('word/people.xml')) {
680
+ zip.updateFile('word/people.xml', Buffer.from(peopleXml, 'utf-8'));
681
+ } else {
682
+ zip.addFile('word/people.xml', Buffer.from(peopleXml, 'utf-8'));
683
+ }
684
+
685
+ // Update [Content_Types].xml
686
+ const contentTypesEntry = zip.getEntry('[Content_Types].xml');
687
+ if (contentTypesEntry) {
688
+ let contentTypes = zip.readAsText(contentTypesEntry);
689
+
690
+ if (!contentTypes.includes('comments.xml')) {
691
+ const insertPoint = contentTypes.lastIndexOf('</Types>');
692
+ contentTypes = contentTypes.slice(0, insertPoint) +
693
+ '<Override PartName="/word/comments.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/>\n' +
694
+ contentTypes.slice(insertPoint);
695
+ }
696
+
697
+ if (!contentTypes.includes('commentsExtended.xml')) {
698
+ const insertPoint = contentTypes.lastIndexOf('</Types>');
699
+ contentTypes = contentTypes.slice(0, insertPoint) +
700
+ '<Override PartName="/word/commentsExtended.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml"/>\n' +
701
+ contentTypes.slice(insertPoint);
702
+ }
703
+
704
+ if (!contentTypes.includes('commentsIds.xml')) {
705
+ const insertPoint = contentTypes.lastIndexOf('</Types>');
706
+ contentTypes = contentTypes.slice(0, insertPoint) +
707
+ '<Override PartName="/word/commentsIds.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.commentsIds+xml"/>\n' +
708
+ contentTypes.slice(insertPoint);
709
+ }
710
+
711
+ if (!contentTypes.includes('commentsExtensible.xml')) {
712
+ const insertPoint = contentTypes.lastIndexOf('</Types>');
713
+ contentTypes = contentTypes.slice(0, insertPoint) +
714
+ '<Override PartName="/word/commentsExtensible.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtensible+xml"/>\n' +
715
+ contentTypes.slice(insertPoint);
716
+ }
717
+
718
+ if (!contentTypes.includes('people.xml')) {
719
+ const insertPoint = contentTypes.lastIndexOf('</Types>');
720
+ contentTypes = contentTypes.slice(0, insertPoint) +
721
+ '<Override PartName="/word/people.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.people+xml"/>\n' +
722
+ contentTypes.slice(insertPoint);
723
+ }
724
+
725
+ zip.updateFile('[Content_Types].xml', Buffer.from(contentTypes, 'utf-8'));
726
+ }
727
+
728
+ // Update relationships
729
+ const relsEntry = zip.getEntry('word/_rels/document.xml.rels');
730
+ if (relsEntry) {
731
+ let rels = zip.readAsText(relsEntry);
732
+
733
+ const rIdMatches = rels.match(/rId(\d+)/g) || [];
734
+ const maxId = rIdMatches.reduce((max, r) => Math.max(max, parseInt(r.replace('rId', ''))), 0);
735
+
736
+ if (!rels.includes('comments.xml')) {
737
+ const insertPoint = rels.lastIndexOf('</Relationships>');
738
+ rels = rels.slice(0, insertPoint) +
739
+ `<Relationship Id="rId${maxId + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/>\n` +
740
+ rels.slice(insertPoint);
741
+ }
742
+
743
+ if (!rels.includes('commentsExtended.xml')) {
744
+ const insertPoint = rels.lastIndexOf('</Relationships>');
745
+ rels = rels.slice(0, insertPoint) +
746
+ `<Relationship Id="rId${maxId + 2}" Type="http://schemas.microsoft.com/office/2011/relationships/commentsExtended" Target="commentsExtended.xml"/>\n` +
747
+ rels.slice(insertPoint);
748
+ }
749
+
750
+ if (!rels.includes('commentsIds.xml')) {
751
+ const insertPoint = rels.lastIndexOf('</Relationships>');
752
+ rels = rels.slice(0, insertPoint) +
753
+ `<Relationship Id="rId${maxId + 3}" Type="http://schemas.microsoft.com/office/2016/09/relationships/commentsIds" Target="commentsIds.xml"/>\n` +
754
+ rels.slice(insertPoint);
755
+ }
756
+
757
+ if (!rels.includes('commentsExtensible.xml')) {
758
+ const insertPoint = rels.lastIndexOf('</Relationships>');
759
+ rels = rels.slice(0, insertPoint) +
760
+ `<Relationship Id="rId${maxId + 4}" Type="http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible" Target="commentsExtensible.xml"/>\n` +
761
+ rels.slice(insertPoint);
762
+ }
763
+
764
+ if (!rels.includes('people.xml')) {
765
+ const insertPoint = rels.lastIndexOf('</Relationships>');
766
+ rels = rels.slice(0, insertPoint) +
767
+ `<Relationship Id="rId${maxId + 5}" Type="http://schemas.microsoft.com/office/2011/relationships/people" Target="people.xml"/>\n` +
768
+ rels.slice(insertPoint);
769
+ }
770
+
771
+ zip.updateFile('word/_rels/document.xml.rels', Buffer.from(rels, 'utf-8'));
772
+ }
773
+
774
+ zip.writeZip(outputPath);
775
+
776
+ const parentCount = includedComments.filter(c => !c.isReply).length;
777
+ const replyCount = includedComments.filter(c => c.isReply).length;
778
+
779
+ return {
780
+ success: true,
781
+ commentCount: parentCount,
782
+ replyCount: replyCount,
783
+ skippedComments: comments.length - includedComments.length,
784
+ };
785
+
786
+ } catch (err: any) {
787
+ return { success: false, commentCount: 0, skippedComments: 0, error: err.message };
788
+ }
789
+ }