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
package/lib/import.ts CHANGED
@@ -1,906 +1,932 @@
1
- /**
2
- * Import functionality - convert Word docs to annotated Markdown
3
- *
4
- * Orchestration workflows + re-exports from extraction/diff/restore modules
5
- */
6
-
7
- import * as fs from 'fs';
8
- import * as path from 'path';
9
- import { stripAnnotations } from './annotations.js';
10
- import { readImageRegistry } from './image-registry.js';
11
- import { exec } from 'child_process';
12
- import { promisify } from 'util';
13
-
14
- // Import from split modules
15
- import {
16
- extractFromWord,
17
- extractWordComments,
18
- extractCommentAnchors,
19
- extractWordTables,
20
- } from './word-extraction.js';
21
- import type {
22
- WordComment,
23
- CommentAnchorData,
24
- WordTable,
25
- ExtractFromWordResult,
26
- } from './word-extraction.js';
27
- import {
28
- generateSmartDiff,
29
- generateAnnotatedDiff,
30
- cleanupAnnotations,
31
- fixCitationAnnotations,
32
- } from './diff-engine.js';
33
- import {
34
- restoreCrossrefFromWord,
35
- restoreImagesFromRegistry,
36
- parseVisibleComments,
37
- convertVisibleComments,
38
- } from './restore-references.js';
39
- import { findAnchorInText, findAllOccurrences } from './anchor-match.js';
40
-
41
- /**
42
- * Pick the best position from candidate `occurrences` given the
43
- * surrounding `before` / `after` context from the docx, while
44
- * respecting `usedPositions` to avoid stacking distinct comments at
45
- * the same anchor instance.
46
- *
47
- * Returns the chosen position, or -1 if every candidate is already used.
48
- */
49
- function pickBestOccurrence(
50
- occurrences: number[],
51
- result: string,
52
- before: string,
53
- after: string,
54
- anchorLen: number,
55
- usedPositions: Set<number>,
56
- nearPos?: number,
57
- ): number {
58
- if (occurrences.length === 0) return -1;
59
- if (occurrences.length === 1) {
60
- return usedPositions.has(occurrences[0]) ? -1 : occurrences[0];
61
- }
62
-
63
- let bestIdx = occurrences.find(p => !usedPositions.has(p)) ?? -1;
64
- if (bestIdx < 0) return -1;
65
- let bestScore = -1;
66
-
67
- // Tie-break by context score first; when scores tie, prefer the occurrence
68
- // nearest `nearPos` (a section-position estimate) if given, else the
69
- // leftmost. The estimate only orders equally-scored candidates — it is never
70
- // the returned position.
71
- const closer = (pos: number, incumbent: number) =>
72
- nearPos === undefined ? pos < incumbent : Math.abs(pos - nearPos) < Math.abs(incumbent - nearPos);
73
-
74
- for (const pos of occurrences) {
75
- if (usedPositions.has(pos)) continue;
76
- let score = 0;
77
-
78
- if (before) {
79
- const contextBefore = result.slice(Math.max(0, pos - before.length - 20), pos).toLowerCase();
80
- const beforeLower = before.toLowerCase();
81
- const beforeWords = beforeLower.split(/\s+/).filter(w => w.length > 3);
82
- for (const word of beforeWords) {
83
- if (contextBefore.includes(word)) score += 2;
84
- }
85
- if (contextBefore.includes(beforeLower.slice(-30))) score += 5;
86
- }
87
-
88
- if (after) {
89
- const contextAfter = result.slice(pos + anchorLen, pos + anchorLen + after.length + 20).toLowerCase();
90
- const afterLower = after.toLowerCase();
91
- const afterWords = afterLower.split(/\s+/).filter(w => w.length > 3);
92
- for (const word of afterWords) {
93
- if (contextAfter.includes(word)) score += 2;
94
- }
95
- if (contextAfter.includes(afterLower.slice(0, 30))) score += 5;
96
- }
97
-
98
- if (score > bestScore || (score === bestScore && closer(pos, bestIdx))) {
99
- bestScore = score;
100
- bestIdx = pos;
101
- }
102
- }
103
-
104
- return bestIdx;
105
- }
106
-
107
- // Re-export everything so existing imports from './import.js' still work
108
- export {
109
- extractFromWord,
110
- extractWordComments,
111
- extractCommentAnchors,
112
- extractHeadings,
113
- extractWordTables,
114
- } from './word-extraction.js';
115
- export type {
116
- WordComment,
117
- TextNode,
118
- CommentAnchorData,
119
- CommentAnchorsResult,
120
- DocxHeading,
121
- WordTable,
122
- ParsedRow,
123
- ExtractFromWordOptions,
124
- ExtractMessage,
125
- ExtractFromWordResult,
126
- } from './word-extraction.js';
127
-
128
- export {
129
- generateSmartDiff,
130
- generateAnnotatedDiff,
131
- cleanupAnnotations,
132
- fixCitationAnnotations,
133
- } from './diff-engine.js';
134
- export type {
135
- GenerateSmartDiffOptions,
136
- } from './diff-engine.js';
137
-
138
- export {
139
- restoreCrossrefFromWord,
140
- restoreImagesFromRegistry,
141
- parseVisibleComments,
142
- convertVisibleComments,
143
- } from './restore-references.js';
144
- export type {
145
- RestoreCrossrefResult,
146
- RestoreImagesResult,
147
- } from './restore-references.js';
148
-
149
- const execAsync = promisify(exec);
150
-
151
- // ============================================
152
- // Type Definitions (orchestration-specific)
153
- // ============================================
154
-
155
- export interface InsertCommentsOptions {
156
- quiet?: boolean;
157
- sectionBoundary?: { start: number; end: number } | null;
158
- /**
159
- * When true (default), comments wrap their anchor text in `[anchor]{.mark}`
160
- * so the rebuilt docx restores the original Word comment range. When false,
161
- * comments are inserted as standalone `{>>...<<}` blocks adjacent to the
162
- * anchor — the prose stays byte-identical except for the inserted blocks.
163
- *
164
- * Set to false from `sync --comments-only` so a draft revised after the
165
- * docx was sent for review keeps its prose intact, and so multiple
166
- * comments sharing one anchor don't produce nested broken markup.
167
- */
168
- wrapAnchor?: boolean;
169
- /**
170
- * Mutable output: when provided, the function fills in counters so callers
171
- * can distinguish placement outcomes in their summary. `placed` counts
172
- * insertions made at a matched anchor or context, `lowConfidence` counts
173
- * insertions made at an approximate (proportional / context-only) position
174
- * that should be reviewed with `rev verify-anchors`, `deduped` counts
175
- * comments already present at their anchor (skipped on re-sync), and
176
- * `unmatched` counts comments that could not be placed at all.
177
- */
178
- outStats?: { placed: number; deduped: number; unmatched: number; lowConfidence?: number };
179
- }
180
-
181
- /**
182
- * Whether a resolved placement landed on matched anchor/context text (high) or
183
- * on an approximate position the user should review (low). Low-confidence
184
- * strategies place the comment without matching the anchor word itself.
185
- */
186
- function placementConfidence(strategy: string | undefined): 'high' | 'low' {
187
- if (!strategy) return 'high';
188
- return /proportional|position-only|context/.test(strategy) ? 'low' : 'high';
189
- }
190
-
191
- export interface CommentWithPos {
192
- id: string;
193
- author: string;
194
- text: string;
195
- date: string;
196
- pos: number;
197
- anchorText: string | null;
198
- anchorEnd?: number;
199
- isEmpty?: boolean;
200
- strategy?: string;
201
- }
202
-
203
- export type { AnchorSearchResult } from './anchor-match.js';
204
-
205
- export interface MarkdownPrefixResult {
206
- prefix: string;
207
- content: string;
208
- }
209
-
210
- export interface ImportWordWithTrackChangesOptions {
211
- mediaDir?: string;
212
- projectDir?: string;
213
- }
214
-
215
- export interface ImportWordWithTrackChangesResult {
216
- text: string;
217
- stats: {
218
- insertions: number;
219
- deletions: number;
220
- substitutions: number;
221
- comments: number;
222
- total: number;
223
- hasTrackChanges: boolean;
224
- trackChangeStats: { insertions: number; deletions: number };
225
- };
226
- extractedMedia: string[];
227
- comments: WordComment[];
228
- }
229
-
230
- export interface ImportFromWordOptions {
231
- author?: string;
232
- sectionContent?: string;
233
- figuresDir?: string;
234
- wordTables?: WordTable[];
235
- }
236
-
237
- export interface ImportFromWordResult {
238
- annotated: string;
239
- stats: {
240
- insertions: number;
241
- deletions: number;
242
- substitutions: number;
243
- comments: number;
244
- total: number;
245
- };
246
- extractedMedia: string[];
247
- }
248
-
249
- export interface MovedFile {
250
- from: string;
251
- to: string;
252
- name: string;
253
- }
254
-
255
- export interface MoveExtractedMediaResult {
256
- moved: MovedFile[];
257
- errors: string[];
258
- }
259
-
260
- // ============================================
261
- // Functions
262
- // ============================================
263
-
264
- /**
265
- * If `pos` lands inside a section file's leading `# Heading` line (or the
266
- * blank line right after it), advance past the first paragraph break so
267
- * the comment stays inside the section. A comment authored at the very
268
- * start of a Word section maps to `pos === 0`, but inserting at column 0
269
- * of a markdown file that begins with `# Heading` puts the `{>>...<<}`
270
- * before the heading marker — Pandoc then treats the line as ordinary
271
- * paragraph text and the comment renders in the previous section.
272
- */
273
- function pushPastSectionHeading(text: string, pos: number): number {
274
- if (pos > 0) {
275
- const headingMatch = text.match(/^#{1,6}\s.+$/m);
276
- if (!headingMatch || headingMatch.index === undefined) return pos;
277
- const headingEnd = headingMatch.index + headingMatch[0].length;
278
- if (pos >= headingEnd) return pos;
279
- }
280
- // pos is at-or-before the first heading line. Advance to the first
281
- // non-blank position after the heading paragraph.
282
- const headingLine = text.match(/^#{1,6}\s.+(?:\n|$)/m);
283
- if (!headingLine || headingLine.index === undefined) return pos;
284
- let after = headingLine.index + headingLine[0].length;
285
- // Skip blank lines so we land at the start of the first body paragraph.
286
- while (after < text.length && (text[after] === '\n' || text[after] === '\r')) {
287
- after++;
288
- }
289
- return after;
290
- }
291
-
292
- /**
293
- * Snap a position to the nearest whitespace boundary within ±50 chars so a
294
- * proportional fallback insertion never lands mid-word.
295
- */
296
- function snapToWordBoundary(text: string, pos: number): number {
297
- if (pos <= 0) return 0;
298
- if (pos >= text.length) return text.length;
299
- if (/\s/.test(text[pos] ?? '')) return pos;
300
- for (let d = 1; d <= 50; d++) {
301
- if (pos + d < text.length && /\s/.test(text[pos + d] ?? '')) return pos + d;
302
- if (pos - d >= 0 && /\s/.test(text[pos - d] ?? '')) return pos - d;
303
- }
304
- return pos;
305
- }
306
-
307
- /**
308
- * Final-resort placement when every text-matching strategy failed. The docx
309
- * carries a real `<w:commentRangeStart w:id="N">` marker at a known offset
310
- * inside its body text — that's a structural anchor, even if the anchored
311
- * span itself is empty and the surrounding context drifted in the target.
312
- *
313
- * Map docPosition into the target markdown proportionally and snap to a word
314
- * boundary. This is approximate when the document was heavily restructured,
315
- * but it's strictly better than silently dropping a reviewer's comment: the
316
- * comment lands in roughly the right neighborhood and the reviewer can
317
- * relocate it during their next pass.
318
- */
319
- function proportionalFallback(
320
- anchorData: CommentAnchorData,
321
- target: string,
322
- ): number | null {
323
- if (anchorData.docLength <= 0) return null;
324
- const proportion = Math.min(anchorData.docPosition / anchorData.docLength, 1.0);
325
- const rawPos = Math.floor(proportion * target.length);
326
- return pushPastSectionHeading(target, snapToWordBoundary(target, rawPos));
327
- }
328
-
329
- /**
330
- * Insert comments into markdown text based on anchor texts with context
331
- */
332
- export function insertCommentsIntoMarkdown(
333
- markdown: string,
334
- comments: WordComment[],
335
- anchors: Map<string, CommentAnchorData | string>,
336
- options: InsertCommentsOptions = {}
337
- ): string {
338
- const { quiet = false, sectionBoundary = null, wrapAnchor = true, outStats } = options;
339
- let result = markdown;
340
- let unmatchedCount = 0;
341
- let placedCount = 0;
342
- let lowConfidenceCount = 0;
343
- const duplicateWarnings: string[] = [];
344
- const usedPositions = new Set<number>(); // For tie-breaking: track used positions
345
-
346
- // Resolve threading: replies share their parent's anchor in Word, so they
347
- // must inherit the parent's position and ride alongside it as one cluster.
348
- // Letting each reply run through anchor scoring scatters the cluster (the
349
- // same docPosition forces `usedPositions` to push later replies onto a
350
- // different occurrence), which on re-build looks like independent comments
351
- // and loses the paraIdParent threading. See gcol33/docrev issue #2.
352
- const inputById = new Map<string, WordComment>();
353
- for (const c of comments) inputById.set(c.id, c);
354
- function rootIdOf(c: WordComment): string {
355
- let cur: WordComment = c;
356
- const seen = new Set<string>();
357
- while (cur.parentId && !seen.has(cur.id)) {
358
- seen.add(cur.id);
359
- const parent = inputById.get(cur.parentId);
360
- if (!parent || parent === cur) break;
361
- cur = parent;
362
- }
363
- return cur.id;
364
- }
365
- const replyRootId = new Map<string, string>();
366
- for (const c of comments) {
367
- const root = rootIdOf(c);
368
- if (root !== c.id) replyRootId.set(c.id, root);
369
- }
370
-
371
- // Anchor matching primitives live in lib/anchor-match.ts so that
372
- // `rev verify-anchors` can use the same strategies for drift reporting.
373
-
374
- // Get all positions in order (for sequential tie-breaking).
375
- // Replies skip scoring entirely — they piggyback on their root's position
376
- // in the emit pass below.
377
- const commentsWithPositions = comments.map((c): CommentWithPos => {
378
- if (replyRootId.has(c.id)) {
379
- return { ...c, pos: -1, anchorText: null, strategy: 'reply' };
380
- }
381
- const anchorData = anchors.get(c.id);
382
- if (!anchorData) {
383
- unmatchedCount++;
384
- return { ...c, pos: -1, anchorText: null };
385
- }
386
-
387
- // Support both old format (string) and new format ({anchor, before, after})
388
- const anchor = typeof anchorData === 'string' ? anchorData : anchorData.anchor;
389
- const before = typeof anchorData === 'object' ? anchorData.before : '';
390
- const after = typeof anchorData === 'object' ? anchorData.after : '';
391
- const isEmpty = typeof anchorData === 'object' && anchorData.isEmpty;
392
- const docPosition = typeof anchorData === 'object' ? anchorData.docPosition : undefined;
393
-
394
- // Section-scoped insertion. The comment is known to belong to this
395
- // section (the caller already filtered by docPosition); place it by
396
- // matching its anchor text within the section, never by mapping a length
397
- // ratio across the docx and markdown coordinate systems. The section
398
- // estimate only seeds duplicate tie-breaking and the final fallback.
399
- if (sectionBoundary && docPosition !== undefined) {
400
- const sectionLength = sectionBoundary.end - sectionBoundary.start;
401
- if (sectionLength > 0) {
402
- const relativePos = docPosition < sectionBoundary.start ? 0 : docPosition - sectionBoundary.start;
403
- const proportion = Math.min(relativePos / sectionLength, 1.0);
404
- const estimatedPos = Math.floor(proportion * result.length);
405
-
406
- // Empty anchor: before/after context is the only text signal.
407
- if ((!anchor || isEmpty) && (before || after)) {
408
- const ctx = findAnchorInText('', result, before, after);
409
- if (ctx.occurrences.length > 0) {
410
- const pos = pushPastSectionHeading(result, ctx.occurrences[0]);
411
- return { ...c, pos, anchorText: null, isEmpty: true, strategy: `ctx:${ctx.strategy}` };
412
- }
413
- }
414
-
415
- // Non-empty anchor: locate it anywhere in the section text. Duplicates
416
- // are disambiguated by before/after context and, only as a tie-break,
417
- // by proximity to the section estimate.
418
- if (anchor && !isEmpty) {
419
- const { occurrences, matchedAnchor, strategy } = findAnchorInText(anchor, result, before, after);
420
- if (occurrences.length > 0) {
421
- const anchorLen = matchedAnchor ? matchedAnchor.length : 0;
422
- const chosen = pickBestOccurrence(occurrences, result, before, after, anchorLen, usedPositions, estimatedPos);
423
- const finalIdx = chosen >= 0 ? chosen : occurrences[0];
424
- if (occurrences.length > 1 && matchedAnchor) {
425
- duplicateWarnings.push(`"${matchedAnchor.slice(0, 40)}${matchedAnchor.length > 40 ? '...' : ''}" appears ${occurrences.length} times in section`);
426
- }
427
- usedPositions.add(finalIdx);
428
- if (matchedAnchor) {
429
- return { ...c, pos: finalIdx, anchorText: matchedAnchor, anchorEnd: finalIdx + anchorLen, strategy: `section:${strategy}` };
430
- }
431
- return { ...c, pos: finalIdx, anchorText: null, strategy: `section:${strategy}` };
432
- }
433
- }
434
-
435
- // Anchor text and context are both gone from the section. The docx
436
- // marker's offset is the only remaining signal: map it proportionally
437
- // and snap to a word boundary. This is an approximate placement, not a
438
- // match — flagged low-confidence so the summary surfaces it.
439
- const insertPos = pushPastSectionHeading(result, snapToWordBoundary(result, estimatedPos));
440
- return { ...c, pos: insertPos, anchorText: null, strategy: 'proportional-fallback' };
441
- }
442
- }
443
-
444
- // Handle empty anchors
445
- if (!anchor || isEmpty) {
446
- if (before || after) {
447
- const { occurrences } = findAnchorInText('', result, before, after);
448
- if (occurrences.length > 0) {
449
- return { ...c, pos: occurrences[0], anchorText: null, isEmpty: true };
450
- }
451
- }
452
- // Last resort: docx carried a structural marker at docPosition; map
453
- // it proportionally into the target so the comment isn't dropped.
454
- if (typeof anchorData === 'object') {
455
- const fallback = proportionalFallback(anchorData, result);
456
- if (fallback !== null) {
457
- return { ...c, pos: fallback, anchorText: null, isEmpty: true, strategy: 'proportional-fallback' };
458
- }
459
- }
460
- unmatchedCount++;
461
- return { ...c, pos: -1, anchorText: null, isEmpty: true };
462
- }
463
-
464
- // Text-based matching strategies
465
- const { occurrences, matchedAnchor, strategy, stripped } = findAnchorInText(anchor, result, before, after);
466
-
467
- if (occurrences.length === 0) {
468
- // Same last-resort as the empty-anchor path: anchor text is gone from
469
- // the target, but the marker's text-offset survived extraction.
470
- if (typeof anchorData === 'object') {
471
- const fallback = proportionalFallback(anchorData, result);
472
- if (fallback !== null) {
473
- return { ...c, pos: fallback, anchorText: null, strategy: 'proportional-fallback' };
474
- }
475
- }
476
- unmatchedCount++;
477
- return { ...c, pos: -1, anchorText: null };
478
- }
479
-
480
- const anchorLen = matchedAnchor ? matchedAnchor.length : 0;
481
-
482
- if (occurrences.length === 1) {
483
- if (matchedAnchor) {
484
- return { ...c, pos: occurrences[0], anchorText: matchedAnchor, anchorEnd: occurrences[0] + anchorLen };
485
- } else {
486
- return { ...c, pos: occurrences[0], anchorText: null };
487
- }
488
- }
489
-
490
- // Multiple occurrences - use context for disambiguation
491
- if (matchedAnchor) {
492
- duplicateWarnings.push(`"${matchedAnchor.slice(0, 40)}${matchedAnchor.length > 40 ? '...' : ''}" appears ${occurrences.length} times`);
493
- }
494
-
495
- const bestIdx = pickBestOccurrence(occurrences, result, before, after, anchorLen, usedPositions);
496
- const finalIdx = bestIdx >= 0 ? bestIdx : occurrences[0];
497
- usedPositions.add(finalIdx);
498
-
499
- if (matchedAnchor) {
500
- return { ...c, pos: finalIdx, anchorText: matchedAnchor, anchorEnd: finalIdx + anchorLen };
501
- } else {
502
- return { ...c, pos: finalIdx, anchorText: null };
503
- }
504
- });
505
-
506
- // Group comments into clusters (root + ordered replies). The root carries
507
- // the resolved position; replies inherit it and ride along in input order
508
- // so the rebuilt CriticMarkup looks like `{>>p<<}{>>r1<<}{>>r2<<}[anchor]`
509
- // and adjacency-based reply detection picks the cluster up again.
510
- const byId = new Map<string, CommentWithPos>();
511
- for (const cwp of commentsWithPositions) byId.set(cwp.id, cwp);
512
- const repliesByRoot = new Map<string, CommentWithPos[]>();
513
- for (const c of comments) {
514
- const rootId = replyRootId.get(c.id);
515
- if (!rootId) continue;
516
- const cwp = byId.get(c.id);
517
- if (!cwp) continue;
518
- const list = repliesByRoot.get(rootId);
519
- if (list) list.push(cwp);
520
- else repliesByRoot.set(rootId, [cwp]);
521
- }
522
-
523
- // Replies whose root never resolved (parent missing from the input slice or
524
- // parent unmatched) count as unmatched too — there's no position to attach
525
- // them to.
526
- for (const [rootId, replies] of repliesByRoot) {
527
- const root = byId.get(rootId);
528
- if (!root || root.pos < 0) {
529
- unmatchedCount += replies.length;
530
- }
531
- }
532
-
533
- // Roots only — replies attach during emission.
534
- const rootsWithPos = commentsWithPositions.filter(
535
- c => !replyRootId.has(c.id)
536
- );
537
-
538
- // Log any unmatched roots for debugging
539
- const unmatched = rootsWithPos.filter((c) => c.pos < 0);
540
- if (process.env.DEBUG) {
541
- console.log(`[DEBUG] insertComments: ${comments.length} input, ${rootsWithPos.length} roots, ${unmatched.length} unmatched roots, ${replyRootId.size} replies`);
542
- if (unmatched.length > 0) {
543
- unmatched.forEach(c => console.log(`[DEBUG] Unmatched ID=${c.id}: anchor="${(c.anchorText || 'none').slice(0,30)}"`));
544
- }
545
- }
546
-
547
- const matchedRoots = rootsWithPos.filter((c) => c.pos >= 0);
548
-
549
- // Sort by position descending (insert from end to avoid offset issues)
550
- matchedRoots.sort((a, b) => b.pos - a.pos);
551
-
552
- // Insert each cluster. With `wrapAnchor` (the default), the anchor text
553
- // gets wrapped in `[anchor]{.mark}` so the rebuilt docx restores the
554
- // original Word comment range. Without it, the comment block is inserted
555
- // adjacent to the anchor and prose stays untouched — required for
556
- // comments-only sync where multiple comments may share one anchor.
557
- // Skip insertion when the parent's CriticMarkup already lives near the
558
- // target — re-running sync against the same docx would otherwise stack
559
- // duplicates. A 200-char window catches both wrapped
560
- // (`{>>...<<}[anchor]{.mark}`) and bare (`{>>...<<}anchor`) forms while
561
- // ignoring incidental matches farther away.
562
- let dedupedCount = 0;
563
- for (const c of matchedRoots) {
564
- const parentBlock = `{>>${c.author}: ${c.text}<<}`;
565
- const replies = repliesByRoot.get(c.id) ?? [];
566
- const windowStart = Math.max(0, c.pos - 200);
567
- const windowEnd = Math.min(result.length, c.pos + 200);
568
- if (result.slice(windowStart, windowEnd).includes(parentBlock)) {
569
- // Cluster already synced; treat all members as deduped.
570
- dedupedCount += 1 + replies.length;
571
- continue;
572
- }
573
- // Replies carry an explicit `↪ ` author prefix so the round-trip does not
574
- // depend on positional adjacency in the markdown. On dense reviewer docs
575
- // distinct clusters frequently land at the same anchor position; without
576
- // the prefix the re-parse would misthread them. The injection side strips
577
- // `↪ ` back off the author so Word renders the original name.
578
- const replyBlocks = replies.map(r => `{>>↪ ${r.author}: ${r.text}<<}`);
579
- const combined = parentBlock + replyBlocks.join('');
580
- if (wrapAnchor && c.anchorText && c.anchorEnd) {
581
- const before = result.slice(0, c.pos);
582
- const anchor = result.slice(c.pos, c.anchorEnd);
583
- const after = result.slice(c.anchorEnd);
584
- result = before + combined + `[${anchor}]{.mark}` + after;
585
- } else {
586
- result = result.slice(0, c.pos) + combined + result.slice(c.pos);
587
- }
588
- // Replies inherit the root's confidence — they ride the same position.
589
- if (placementConfidence(c.strategy) === 'low') {
590
- lowConfidenceCount += 1 + replies.length;
591
- } else {
592
- placedCount += 1 + replies.length;
593
- }
594
- }
595
-
596
- if (outStats) {
597
- outStats.placed = placedCount;
598
- outStats.lowConfidence = lowConfidenceCount;
599
- outStats.deduped = dedupedCount;
600
- outStats.unmatched = unmatchedCount;
601
- }
602
-
603
- // Log warnings unless quiet mode
604
- if (!quiet) {
605
- if (unmatchedCount > 0) {
606
- console.warn(`Warning: ${unmatchedCount} comment(s) could not be matched to anchor text`);
607
- }
608
- if (lowConfidenceCount > 0) {
609
- console.warn(`Note: ${lowConfidenceCount} comment(s) placed approximately — run \`rev verify-anchors\` to review`);
610
- }
611
- if (dedupedCount > 0) {
612
- console.warn(`Note: ${dedupedCount} comment(s) already present at anchor — skipped to avoid duplication`);
613
- }
614
- if (duplicateWarnings.length > 0) {
615
- console.warn(`Warning: Duplicate anchor text found (using context & tie-breaks for placement):`);
616
- for (const w of duplicateWarnings) {
617
- console.warn(` - ${w}`);
618
- }
619
- }
620
- }
621
-
622
- return result;
623
- }
624
-
625
- /**
626
- * Import Word document with track changes directly as CriticMarkup
627
- */
628
- export async function importWordWithTrackChanges(
629
- docxPath: string,
630
- options: ImportWordWithTrackChangesOptions = {}
631
- ): Promise<ImportWordWithTrackChangesResult> {
632
- const { mediaDir, projectDir } = options;
633
- const docxDir = path.dirname(docxPath);
634
- const targetMediaDir = mediaDir || path.join(docxDir, 'media');
635
- const targetProjectDir = projectDir || docxDir;
636
-
637
- const registry = readImageRegistry(targetProjectDir);
638
- const hasRegistry = registry && registry.figures && registry.figures.length > 0;
639
-
640
- // First pass: count images
641
- const { stdout: rawText } = await execAsync(
642
- `pandoc "${docxPath}" -t markdown --wrap=none --track-changes=all`,
643
- { maxBuffer: 50 * 1024 * 1024 }
644
- );
645
-
646
- const wordImageCount = (rawText.match(/!\[[^\]]*\]\(media\/[^)]+\)/g) || []).length;
647
- const registryCount = hasRegistry ? registry.figures.length : 0;
648
-
649
- const needsMediaExtraction = wordImageCount > registryCount;
650
-
651
- if (hasRegistry) {
652
- console.log(`Registry has ${registryCount} figures, Word doc has ${wordImageCount} images`);
653
- if (needsMediaExtraction) {
654
- console.log(`Extracting media (${wordImageCount - registryCount} new image(s) detected)`);
655
- } else {
656
- console.log(`Using existing figures from registry`);
657
- }
658
- }
659
-
660
- // Extract from Word
661
- const extracted = await extractFromWord(docxPath, {
662
- mediaDir: targetMediaDir,
663
- skipMediaExtraction: !needsMediaExtraction,
664
- });
665
-
666
- let text = extracted.text;
667
- const extractedMedia = extracted.extractedMedia || [];
668
- const comments = extracted.comments || [];
669
- const anchors = extracted.anchors || new Map();
670
-
671
- // Log messages
672
- for (const msg of extracted.messages || []) {
673
- if (msg.type === 'info') {
674
- console.log(msg.message);
675
- } else if (msg.type === 'warning') {
676
- console.warn(`Warning: ${msg.message}`);
677
- }
678
- }
679
-
680
- // Restore crossref
681
- const crossrefResult = restoreCrossrefFromWord(text, targetProjectDir);
682
- text = crossrefResult.text;
683
- if (crossrefResult.restored > 0) {
684
- console.log(`Restored ${crossrefResult.restored} crossref reference(s)`);
685
- }
686
-
687
- // Restore images
688
- const imageRestoreResult = restoreImagesFromRegistry(text, targetProjectDir, crossrefResult.restoredLabels);
689
- text = imageRestoreResult.text;
690
- if (imageRestoreResult.restored > 0) {
691
- console.log(`Restored ${imageRestoreResult.restored} image(s) from registry`);
692
- }
693
-
694
- // Insert comments
695
- if (comments.length > 0) {
696
- text = insertCommentsIntoMarkdown(text, comments, anchors);
697
- console.log(`Inserted ${comments.length} comment(s)`);
698
- }
699
-
700
- // Clean up
701
- text = cleanupAnnotations(text);
702
-
703
- // Count final changes
704
- const insertions = (text.match(/\{\+\+/g) || []).length;
705
- const deletions = (text.match(/\{--/g) || []).length;
706
- const substitutions = (text.match(/\{~~/g) || []).length;
707
- const commentCount = (text.match(/\{>>/g) || []).length;
708
-
709
- return {
710
- text,
711
- stats: {
712
- insertions,
713
- deletions,
714
- substitutions,
715
- comments: commentCount,
716
- total: insertions + deletions + substitutions + commentCount,
717
- hasTrackChanges: extracted.hasTrackChanges,
718
- trackChangeStats: extracted.trackChangeStats,
719
- },
720
- extractedMedia,
721
- comments,
722
- };
723
- }
724
-
725
- /**
726
- * Legacy import function: Word doc annotated MD via diff
727
- */
728
- export async function importFromWord(
729
- docxPath: string,
730
- originalMdPath: string,
731
- options: ImportFromWordOptions = {}
732
- ): Promise<ImportFromWordResult> {
733
- const { author = 'Reviewer', sectionContent, figuresDir } = options;
734
- const projectDir = path.dirname(originalMdPath);
735
-
736
- let wordText: string;
737
- let extractedMedia: string[] = [];
738
- let wordTables: WordTable[] = options.wordTables || [];
739
- let hasTrackChanges = false;
740
-
741
- if (sectionContent !== undefined) {
742
- let annotated = cleanupAnnotations(sectionContent);
743
-
744
- const insertions = (annotated.match(/\{\+\+/g) || []).length;
745
- const deletions = (annotated.match(/\{--/g) || []).length;
746
- const substitutions = (annotated.match(/\{~~/g) || []).length;
747
- const commentCount = (annotated.match(/\{>>/g) || []).length;
748
-
749
- return {
750
- annotated,
751
- stats: {
752
- insertions,
753
- deletions,
754
- substitutions,
755
- comments: commentCount,
756
- total: insertions + deletions + substitutions + commentCount,
757
- },
758
- extractedMedia: [],
759
- };
760
- } else {
761
- const docxDir = path.dirname(docxPath);
762
- const mediaDir = figuresDir || docxDir;
763
-
764
- const extracted = await extractFromWord(docxPath, { mediaDir });
765
- wordText = extracted.text;
766
- extractedMedia = extracted.extractedMedia || [];
767
- wordTables = extracted.tables || [];
768
- hasTrackChanges = extracted.hasTrackChanges || false;
769
-
770
- for (const msg of extracted.messages || []) {
771
- if (msg.type === 'info') {
772
- console.log(msg.message);
773
- } else if (msg.type === 'warning') {
774
- console.warn(`Warning: ${msg.message}`);
775
- }
776
- }
777
-
778
- if (hasTrackChanges) {
779
- const crossrefResult = restoreCrossrefFromWord(wordText, projectDir);
780
- wordText = crossrefResult.text;
781
- if (crossrefResult.restored > 0) {
782
- console.log(`Restored ${crossrefResult.restored} crossref reference(s)`);
783
- }
784
-
785
- const imageRestoreResult = restoreImagesFromRegistry(wordText, projectDir, crossrefResult.restoredLabels);
786
- wordText = imageRestoreResult.text;
787
- if (imageRestoreResult.restored > 0) {
788
- console.log(`Restored ${imageRestoreResult.restored} image(s) from registry`);
789
- }
790
-
791
- const comments = extracted.comments || [];
792
- const anchors = extracted.anchors || new Map();
793
- if (comments.length > 0) {
794
- wordText = insertCommentsIntoMarkdown(wordText, comments, anchors);
795
- console.log(`Inserted ${comments.length} comment(s)`);
796
- }
797
-
798
- wordText = cleanupAnnotations(wordText);
799
-
800
- const insertions = (wordText.match(/\{\+\+/g) || []).length;
801
- const deletions = (wordText.match(/\{--/g) || []).length;
802
- const substitutions = (wordText.match(/\{~~/g) || []).length;
803
- const commentCount = (wordText.match(/\{>>/g) || []).length;
804
-
805
- return {
806
- annotated: wordText,
807
- stats: {
808
- insertions,
809
- deletions,
810
- substitutions,
811
- comments: commentCount,
812
- total: insertions + deletions + substitutions + commentCount,
813
- },
814
- extractedMedia,
815
- };
816
- }
817
-
818
- console.warn('Warning: No track changes detected in Word document.');
819
- console.warn(' For best results, reviewers should use Track Changes in Word.');
820
- console.warn(' Falling back to diff-based import (comparing against original MD).');
821
- console.warn(' This approach may produce less accurate change annotations.');
822
-
823
- const crossrefResult = restoreCrossrefFromWord(wordText, projectDir);
824
- wordText = crossrefResult.text;
825
- if (crossrefResult.restored > 0) {
826
- console.log(`Restored ${crossrefResult.restored} crossref reference(s)`);
827
- }
828
-
829
- const imageRestoreResult = restoreImagesFromRegistry(wordText, projectDir, crossrefResult.restoredLabels);
830
- wordText = imageRestoreResult.text;
831
- if (imageRestoreResult.restored > 0) {
832
- console.log(`Restored ${imageRestoreResult.restored} image(s) from registry`);
833
- }
834
- }
835
-
836
- // Read original markdown
837
- let originalMd = fs.readFileSync(originalMdPath, 'utf-8');
838
-
839
- // Strip existing annotations
840
- originalMd = stripAnnotations(originalMd, { keepComments: false });
841
-
842
- // Load image registry
843
- const imageRegistry = readImageRegistry(projectDir);
844
-
845
- // Generate diff
846
- let annotated = generateSmartDiff(originalMd, wordText, author, { wordTables, imageRegistry });
847
-
848
- // Clean up
849
- annotated = cleanupAnnotations(annotated);
850
-
851
- // Fix citation annotations
852
- annotated = fixCitationAnnotations(annotated, originalMd);
853
-
854
- // Convert visible comments
855
- annotated = convertVisibleComments(annotated);
856
-
857
- // Count changes
858
- const insertions = (annotated.match(/\{\+\+/g) || []).length;
859
- const deletions = (annotated.match(/\{--/g) || []).length;
860
- const substitutions = (annotated.match(/\{~~/g) || []).length;
861
- const comments = (annotated.match(/\{>>/g) || []).length;
862
-
863
- return {
864
- annotated,
865
- stats: {
866
- insertions,
867
- deletions,
868
- substitutions,
869
- comments,
870
- total: insertions + deletions + substitutions + comments,
871
- },
872
- extractedMedia,
873
- };
874
- }
875
-
876
- /**
877
- * Move extracted media files to a figures directory with better names
878
- */
879
- export function moveExtractedMedia(
880
- mediaFiles: string[],
881
- figuresDir: string,
882
- prefix: string = 'figure'
883
- ): MoveExtractedMediaResult {
884
- const moved: MovedFile[] = [];
885
- const errors: string[] = [];
886
-
887
- if (!fs.existsSync(figuresDir)) {
888
- fs.mkdirSync(figuresDir, { recursive: true });
889
- }
890
-
891
- for (let i = 0; i < mediaFiles.length; i++) {
892
- const src = mediaFiles[i];
893
- const ext = path.extname(src).toLowerCase();
894
- const newName = `${prefix}${i + 1}${ext}`;
895
- const dest = path.join(figuresDir, newName);
896
-
897
- try {
898
- fs.copyFileSync(src, dest);
899
- moved.push({ from: src, to: dest, name: newName });
900
- } catch (err: any) {
901
- errors.push(`Failed to copy ${src}: ${err.message}`);
902
- }
903
- }
904
-
905
- return { moved, errors };
906
- }
1
+ /**
2
+ * Import functionality - convert Word docs to annotated Markdown
3
+ *
4
+ * Orchestration workflows + re-exports from extraction/diff/restore modules
5
+ */
6
+
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
9
+ import { stripAnnotations } from './annotations.js';
10
+ import { readImageRegistry } from './image-registry.js';
11
+ import { exec } from 'child_process';
12
+ import { promisify } from 'util';
13
+
14
+ // Import from split modules
15
+ import {
16
+ extractFromWord,
17
+ extractWordComments,
18
+ extractCommentAnchors,
19
+ extractWordTables,
20
+ } from './word-extraction.js';
21
+ import type {
22
+ WordComment,
23
+ CommentAnchorData,
24
+ WordTable,
25
+ ExtractFromWordResult,
26
+ } from './word-extraction.js';
27
+ import {
28
+ generateSmartDiff,
29
+ generateAnnotatedDiff,
30
+ cleanupAnnotations,
31
+ fixCitationAnnotations,
32
+ } from './diff-engine.js';
33
+ import {
34
+ restoreCrossrefFromWord,
35
+ restoreImagesFromRegistry,
36
+ parseVisibleComments,
37
+ convertVisibleComments,
38
+ } from './restore-references.js';
39
+ import { findAnchorInText, findAllOccurrences } from './anchor-match.js';
40
+
41
+ /**
42
+ * Pick the best position from candidate `occurrences` given the
43
+ * surrounding `before` / `after` context from the docx, while
44
+ * respecting `usedPositions` to avoid stacking distinct comments at
45
+ * the same anchor instance.
46
+ *
47
+ * Returns the chosen position, or -1 if every candidate is already used.
48
+ */
49
+ function pickBestOccurrence(
50
+ occurrences: number[],
51
+ result: string,
52
+ before: string,
53
+ after: string,
54
+ anchorLen: number,
55
+ usedPositions: Set<number>,
56
+ nearPos?: number,
57
+ ): number {
58
+ if (occurrences.length === 0) return -1;
59
+ if (occurrences.length === 1) {
60
+ return usedPositions.has(occurrences[0]) ? -1 : occurrences[0];
61
+ }
62
+
63
+ let bestIdx = occurrences.find(p => !usedPositions.has(p)) ?? -1;
64
+ if (bestIdx < 0) return -1;
65
+ let bestScore = -1;
66
+
67
+ // Tie-break by context score first; when scores tie, prefer the occurrence
68
+ // nearest `nearPos` (a section-position estimate) if given, else the
69
+ // leftmost. The estimate only orders equally-scored candidates — it is never
70
+ // the returned position.
71
+ const closer = (pos: number, incumbent: number) =>
72
+ nearPos === undefined ? pos < incumbent : Math.abs(pos - nearPos) < Math.abs(incumbent - nearPos);
73
+
74
+ for (const pos of occurrences) {
75
+ if (usedPositions.has(pos)) continue;
76
+ let score = 0;
77
+
78
+ if (before) {
79
+ const contextBefore = result.slice(Math.max(0, pos - before.length - 20), pos).toLowerCase();
80
+ const beforeLower = before.toLowerCase();
81
+ const beforeWords = beforeLower.split(/\s+/).filter(w => w.length > 3);
82
+ for (const word of beforeWords) {
83
+ if (contextBefore.includes(word)) score += 2;
84
+ }
85
+ if (contextBefore.includes(beforeLower.slice(-30))) score += 5;
86
+ }
87
+
88
+ if (after) {
89
+ const contextAfter = result.slice(pos + anchorLen, pos + anchorLen + after.length + 20).toLowerCase();
90
+ const afterLower = after.toLowerCase();
91
+ const afterWords = afterLower.split(/\s+/).filter(w => w.length > 3);
92
+ for (const word of afterWords) {
93
+ if (contextAfter.includes(word)) score += 2;
94
+ }
95
+ if (contextAfter.includes(afterLower.slice(0, 30))) score += 5;
96
+ }
97
+
98
+ if (score > bestScore || (score === bestScore && closer(pos, bestIdx))) {
99
+ bestScore = score;
100
+ bestIdx = pos;
101
+ }
102
+ }
103
+
104
+ return bestIdx;
105
+ }
106
+
107
+ // Re-export everything so existing imports from './import.js' still work
108
+ export {
109
+ extractFromWord,
110
+ extractWordComments,
111
+ extractCommentAnchors,
112
+ extractHeadings,
113
+ extractWordTables,
114
+ } from './word-extraction.js';
115
+ export type {
116
+ WordComment,
117
+ TextNode,
118
+ CommentAnchorData,
119
+ CommentAnchorsResult,
120
+ DocxHeading,
121
+ WordTable,
122
+ ParsedRow,
123
+ ExtractFromWordOptions,
124
+ ExtractMessage,
125
+ ExtractFromWordResult,
126
+ } from './word-extraction.js';
127
+
128
+ export {
129
+ generateSmartDiff,
130
+ generateAnnotatedDiff,
131
+ cleanupAnnotations,
132
+ fixCitationAnnotations,
133
+ } from './diff-engine.js';
134
+ export type {
135
+ GenerateSmartDiffOptions,
136
+ } from './diff-engine.js';
137
+
138
+ export {
139
+ restoreCrossrefFromWord,
140
+ restoreImagesFromRegistry,
141
+ parseVisibleComments,
142
+ convertVisibleComments,
143
+ } from './restore-references.js';
144
+ export type {
145
+ RestoreCrossrefResult,
146
+ RestoreImagesResult,
147
+ } from './restore-references.js';
148
+
149
+ const execAsync = promisify(exec);
150
+
151
+ // ============================================
152
+ // Type Definitions (orchestration-specific)
153
+ // ============================================
154
+
155
+ export interface InsertCommentsOptions {
156
+ quiet?: boolean;
157
+ sectionBoundary?: { start: number; end: number } | null;
158
+ /**
159
+ * When true (default), comments wrap their anchor text in `[anchor]{.mark}`
160
+ * so the rebuilt docx restores the original Word comment range. When false,
161
+ * comments are inserted as standalone `{>>...<<}` blocks adjacent to the
162
+ * anchor — the prose stays byte-identical except for the inserted blocks.
163
+ *
164
+ * Set to false from `sync --comments-only` so a draft revised after the
165
+ * docx was sent for review keeps its prose intact, and so multiple
166
+ * comments sharing one anchor don't produce nested broken markup.
167
+ */
168
+ wrapAnchor?: boolean;
169
+ /**
170
+ * Mutable output: when provided, the function fills in counters so callers
171
+ * can distinguish placement outcomes in their summary. `placed` counts
172
+ * insertions made at a matched anchor or context, `lowConfidence` counts
173
+ * insertions made at an approximate (proportional / context-only) position
174
+ * that should be reviewed with `rev verify-anchors`, `deduped` counts
175
+ * comments already present at their anchor (skipped on re-sync), and
176
+ * `unmatched` counts comments that could not be placed at all.
177
+ */
178
+ outStats?: { placed: number; deduped: number; unmatched: number; lowConfidence?: number };
179
+ }
180
+
181
+ /**
182
+ * Whether a resolved placement landed on matched anchor/context text (high) or
183
+ * on an approximate position the user should review (low). Low-confidence
184
+ * strategies place the comment without matching the anchor word itself.
185
+ */
186
+ function placementConfidence(strategy: string | undefined): 'high' | 'low' {
187
+ if (!strategy) return 'high';
188
+ return /proportional|position-only|context/.test(strategy) ? 'low' : 'high';
189
+ }
190
+
191
+ export interface CommentWithPos {
192
+ id: string;
193
+ author: string;
194
+ text: string;
195
+ date: string;
196
+ pos: number;
197
+ anchorText: string | null;
198
+ anchorEnd?: number;
199
+ isEmpty?: boolean;
200
+ strategy?: string;
201
+ }
202
+
203
+ export type { AnchorSearchResult } from './anchor-match.js';
204
+
205
+ export interface MarkdownPrefixResult {
206
+ prefix: string;
207
+ content: string;
208
+ }
209
+
210
+ export interface ImportWordWithTrackChangesOptions {
211
+ mediaDir?: string;
212
+ projectDir?: string;
213
+ }
214
+
215
+ export interface ImportWordWithTrackChangesResult {
216
+ text: string;
217
+ stats: {
218
+ insertions: number;
219
+ deletions: number;
220
+ substitutions: number;
221
+ comments: number;
222
+ total: number;
223
+ hasTrackChanges: boolean;
224
+ trackChangeStats: { insertions: number; deletions: number };
225
+ };
226
+ extractedMedia: string[];
227
+ comments: WordComment[];
228
+ }
229
+
230
+ export interface ImportFromWordOptions {
231
+ author?: string;
232
+ sectionContent?: string;
233
+ figuresDir?: string;
234
+ wordTables?: WordTable[];
235
+ }
236
+
237
+ export interface ImportFromWordResult {
238
+ annotated: string;
239
+ stats: {
240
+ insertions: number;
241
+ deletions: number;
242
+ substitutions: number;
243
+ comments: number;
244
+ total: number;
245
+ };
246
+ extractedMedia: string[];
247
+ }
248
+
249
+ export interface MovedFile {
250
+ from: string;
251
+ to: string;
252
+ name: string;
253
+ }
254
+
255
+ export interface MoveExtractedMediaResult {
256
+ moved: MovedFile[];
257
+ errors: string[];
258
+ }
259
+
260
+ // ============================================
261
+ // Functions
262
+ // ============================================
263
+
264
+ /**
265
+ * If `pos` lands inside a section file's leading `# Heading` line (or the
266
+ * blank line right after it), advance past the first paragraph break so
267
+ * the comment stays inside the section. A comment authored at the very
268
+ * start of a Word section maps to `pos === 0`, but inserting at column 0
269
+ * of a markdown file that begins with `# Heading` puts the `{>>...<<}`
270
+ * before the heading marker — Pandoc then treats the line as ordinary
271
+ * paragraph text and the comment renders in the previous section.
272
+ */
273
+ function pushPastSectionHeading(text: string, pos: number): number {
274
+ if (pos > 0) {
275
+ const headingMatch = text.match(/^#{1,6}\s.+$/m);
276
+ if (!headingMatch || headingMatch.index === undefined) return pos;
277
+ const headingEnd = headingMatch.index + headingMatch[0].length;
278
+ if (pos >= headingEnd) return pos;
279
+ }
280
+ // pos is at-or-before the first heading line. Advance to the first
281
+ // non-blank position after the heading paragraph.
282
+ const headingLine = text.match(/^#{1,6}\s.+(?:\n|$)/m);
283
+ if (!headingLine || headingLine.index === undefined) return pos;
284
+ let after = headingLine.index + headingLine[0].length;
285
+ // Skip blank lines so we land at the start of the first body paragraph.
286
+ while (after < text.length && (text[after] === '\n' || text[after] === '\r')) {
287
+ after++;
288
+ }
289
+ return after;
290
+ }
291
+
292
+ /**
293
+ * Snap a position to the nearest whitespace boundary within ±50 chars so a
294
+ * proportional fallback insertion never lands mid-word.
295
+ */
296
+ function snapToWordBoundary(text: string, pos: number): number {
297
+ if (pos <= 0) return 0;
298
+ if (pos >= text.length) return text.length;
299
+ if (/\s/.test(text[pos] ?? '')) return pos;
300
+ for (let d = 1; d <= 50; d++) {
301
+ if (pos + d < text.length && /\s/.test(text[pos + d] ?? '')) return pos + d;
302
+ if (pos - d >= 0 && /\s/.test(text[pos - d] ?? '')) return pos - d;
303
+ }
304
+ return pos;
305
+ }
306
+
307
+ /**
308
+ * Final-resort placement when every text-matching strategy failed. The docx
309
+ * carries a real `<w:commentRangeStart w:id="N">` marker at a known offset
310
+ * inside its body text — that's a structural anchor, even if the anchored
311
+ * span itself is empty and the surrounding context drifted in the target.
312
+ *
313
+ * Map docPosition into the target markdown proportionally and snap to a word
314
+ * boundary. This is approximate when the document was heavily restructured,
315
+ * but it's strictly better than silently dropping a reviewer's comment: the
316
+ * comment lands in roughly the right neighborhood and the reviewer can
317
+ * relocate it during their next pass.
318
+ */
319
+ function proportionalFallback(
320
+ anchorData: CommentAnchorData,
321
+ target: string,
322
+ ): number | null {
323
+ if (anchorData.docLength <= 0) return null;
324
+ const proportion = Math.min(anchorData.docPosition / anchorData.docLength, 1.0);
325
+ const rawPos = Math.floor(proportion * target.length);
326
+ return pushPastSectionHeading(target, snapToWordBoundary(target, rawPos));
327
+ }
328
+
329
+ /**
330
+ * Insert comments into markdown text based on anchor texts with context
331
+ */
332
+ export function insertCommentsIntoMarkdown(
333
+ markdown: string,
334
+ comments: WordComment[],
335
+ anchors: Map<string, CommentAnchorData | string>,
336
+ options: InsertCommentsOptions = {}
337
+ ): string {
338
+ const { quiet = false, sectionBoundary = null, wrapAnchor = true, outStats } = options;
339
+ let result = markdown;
340
+ let unmatchedCount = 0;
341
+ let placedCount = 0;
342
+ let lowConfidenceCount = 0;
343
+ const duplicateWarnings: string[] = [];
344
+ const usedPositions = new Set<number>(); // For tie-breaking: track used positions
345
+
346
+ // Resolve threading: replies share their parent's anchor in Word, so they
347
+ // must inherit the parent's position and ride alongside it as one cluster.
348
+ // Letting each reply run through anchor scoring scatters the cluster (the
349
+ // same docPosition forces `usedPositions` to push later replies onto a
350
+ // different occurrence), which on re-build looks like independent comments
351
+ // and loses the paraIdParent threading. See gcol33/docrev issue #2.
352
+ const inputById = new Map<string, WordComment>();
353
+ for (const c of comments) inputById.set(c.id, c);
354
+ function rootIdOf(c: WordComment): string {
355
+ let cur: WordComment = c;
356
+ const seen = new Set<string>();
357
+ while (cur.parentId && !seen.has(cur.id)) {
358
+ seen.add(cur.id);
359
+ const parent = inputById.get(cur.parentId);
360
+ if (!parent || parent === cur) break;
361
+ cur = parent;
362
+ }
363
+ return cur.id;
364
+ }
365
+ const replyRootId = new Map<string, string>();
366
+ for (const c of comments) {
367
+ const root = rootIdOf(c);
368
+ if (root !== c.id) replyRootId.set(c.id, root);
369
+ }
370
+
371
+ // Anchor matching primitives live in lib/anchor-match.ts so that
372
+ // `rev verify-anchors` can use the same strategies for drift reporting.
373
+
374
+ // Get all positions in order (for sequential tie-breaking).
375
+ // Replies skip scoring entirely — they piggyback on their root's position
376
+ // in the emit pass below.
377
+ const commentsWithPositions = comments.map((c): CommentWithPos => {
378
+ if (replyRootId.has(c.id)) {
379
+ return { ...c, pos: -1, anchorText: null, strategy: 'reply' };
380
+ }
381
+ const anchorData = anchors.get(c.id);
382
+ if (!anchorData) {
383
+ unmatchedCount++;
384
+ return { ...c, pos: -1, anchorText: null };
385
+ }
386
+
387
+ // Support both old format (string) and new format ({anchor, before, after})
388
+ const anchor = typeof anchorData === 'string' ? anchorData : anchorData.anchor;
389
+ const before = typeof anchorData === 'object' ? anchorData.before : '';
390
+ const after = typeof anchorData === 'object' ? anchorData.after : '';
391
+ const isEmpty = typeof anchorData === 'object' && anchorData.isEmpty;
392
+ const docPosition = typeof anchorData === 'object' ? anchorData.docPosition : undefined;
393
+
394
+ // Section-scoped insertion. The comment is known to belong to this
395
+ // section (the caller already filtered by docPosition); place it by
396
+ // matching its anchor text within the section, never by mapping a length
397
+ // ratio across the docx and markdown coordinate systems. The section
398
+ // estimate only seeds duplicate tie-breaking and the final fallback.
399
+ if (sectionBoundary && docPosition !== undefined) {
400
+ const sectionLength = sectionBoundary.end - sectionBoundary.start;
401
+ if (sectionLength > 0) {
402
+ const relativePos = docPosition < sectionBoundary.start ? 0 : docPosition - sectionBoundary.start;
403
+ const proportion = Math.min(relativePos / sectionLength, 1.0);
404
+ const estimatedPos = Math.floor(proportion * result.length);
405
+
406
+ // Empty anchor: before/after context is the only text signal.
407
+ if ((!anchor || isEmpty) && (before || after)) {
408
+ const ctx = findAnchorInText('', result, before, after);
409
+ if (ctx.occurrences.length > 0) {
410
+ const pos = pushPastSectionHeading(result, ctx.occurrences[0]);
411
+ return { ...c, pos, anchorText: null, isEmpty: true, strategy: `ctx:${ctx.strategy}` };
412
+ }
413
+ }
414
+
415
+ // Non-empty anchor: locate it anywhere in the section text. Duplicates
416
+ // are disambiguated by before/after context and, only as a tie-break,
417
+ // by proximity to the section estimate.
418
+ if (anchor && !isEmpty) {
419
+ const { occurrences, matchedAnchor, strategy } = findAnchorInText(anchor, result, before, after);
420
+ if (occurrences.length > 0) {
421
+ const anchorLen = matchedAnchor ? matchedAnchor.length : 0;
422
+ const chosen = pickBestOccurrence(occurrences, result, before, after, anchorLen, usedPositions, estimatedPos);
423
+ const finalIdx = chosen >= 0 ? chosen : occurrences[0];
424
+ if (occurrences.length > 1 && matchedAnchor) {
425
+ duplicateWarnings.push(`"${matchedAnchor.slice(0, 40)}${matchedAnchor.length > 40 ? '...' : ''}" appears ${occurrences.length} times in section`);
426
+ }
427
+ usedPositions.add(finalIdx);
428
+ if (matchedAnchor) {
429
+ return { ...c, pos: finalIdx, anchorText: matchedAnchor, anchorEnd: finalIdx + anchorLen, strategy: `section:${strategy}` };
430
+ }
431
+ return { ...c, pos: finalIdx, anchorText: null, strategy: `section:${strategy}` };
432
+ }
433
+ }
434
+
435
+ // Anchor text and context are both gone from the section. The docx
436
+ // marker's offset is the only remaining signal: map it proportionally
437
+ // and snap to a word boundary. This is an approximate placement, not a
438
+ // match — flagged low-confidence so the summary surfaces it.
439
+ const insertPos = pushPastSectionHeading(result, snapToWordBoundary(result, estimatedPos));
440
+ return { ...c, pos: insertPos, anchorText: null, strategy: 'proportional-fallback' };
441
+ }
442
+ }
443
+
444
+ // Handle empty anchors
445
+ if (!anchor || isEmpty) {
446
+ if (before || after) {
447
+ const { occurrences } = findAnchorInText('', result, before, after);
448
+ if (occurrences.length > 0) {
449
+ return { ...c, pos: occurrences[0], anchorText: null, isEmpty: true };
450
+ }
451
+ }
452
+ // Last resort: docx carried a structural marker at docPosition; map
453
+ // it proportionally into the target so the comment isn't dropped.
454
+ if (typeof anchorData === 'object') {
455
+ const fallback = proportionalFallback(anchorData, result);
456
+ if (fallback !== null) {
457
+ return { ...c, pos: fallback, anchorText: null, isEmpty: true, strategy: 'proportional-fallback' };
458
+ }
459
+ }
460
+ unmatchedCount++;
461
+ return { ...c, pos: -1, anchorText: null, isEmpty: true };
462
+ }
463
+
464
+ // Text-based matching strategies
465
+ const { occurrences, matchedAnchor, strategy, stripped } = findAnchorInText(anchor, result, before, after);
466
+
467
+ if (occurrences.length === 0) {
468
+ // Same last-resort as the empty-anchor path: anchor text is gone from
469
+ // the target, but the marker's text-offset survived extraction.
470
+ if (typeof anchorData === 'object') {
471
+ const fallback = proportionalFallback(anchorData, result);
472
+ if (fallback !== null) {
473
+ return { ...c, pos: fallback, anchorText: null, strategy: 'proportional-fallback' };
474
+ }
475
+ }
476
+ unmatchedCount++;
477
+ return { ...c, pos: -1, anchorText: null };
478
+ }
479
+
480
+ const anchorLen = matchedAnchor ? matchedAnchor.length : 0;
481
+
482
+ if (occurrences.length === 1) {
483
+ if (matchedAnchor) {
484
+ return { ...c, pos: occurrences[0], anchorText: matchedAnchor, anchorEnd: occurrences[0] + anchorLen };
485
+ } else {
486
+ return { ...c, pos: occurrences[0], anchorText: null };
487
+ }
488
+ }
489
+
490
+ // Multiple occurrences - use context for disambiguation
491
+ if (matchedAnchor) {
492
+ duplicateWarnings.push(`"${matchedAnchor.slice(0, 40)}${matchedAnchor.length > 40 ? '...' : ''}" appears ${occurrences.length} times`);
493
+ }
494
+
495
+ const bestIdx = pickBestOccurrence(occurrences, result, before, after, anchorLen, usedPositions);
496
+ const finalIdx = bestIdx >= 0 ? bestIdx : occurrences[0];
497
+ usedPositions.add(finalIdx);
498
+
499
+ if (matchedAnchor) {
500
+ return { ...c, pos: finalIdx, anchorText: matchedAnchor, anchorEnd: finalIdx + anchorLen };
501
+ } else {
502
+ return { ...c, pos: finalIdx, anchorText: null };
503
+ }
504
+ });
505
+
506
+ // Group comments into clusters (root + ordered replies). The root carries
507
+ // the resolved position; replies inherit it and ride along in input order
508
+ // so the rebuilt CriticMarkup looks like `{>>p<<}{>>r1<<}{>>r2<<}[anchor]`
509
+ // and adjacency-based reply detection picks the cluster up again.
510
+ const byId = new Map<string, CommentWithPos>();
511
+ for (const cwp of commentsWithPositions) byId.set(cwp.id, cwp);
512
+ const repliesByRoot = new Map<string, CommentWithPos[]>();
513
+ for (const c of comments) {
514
+ const rootId = replyRootId.get(c.id);
515
+ if (!rootId) continue;
516
+ const cwp = byId.get(c.id);
517
+ if (!cwp) continue;
518
+ const list = repliesByRoot.get(rootId);
519
+ if (list) list.push(cwp);
520
+ else repliesByRoot.set(rootId, [cwp]);
521
+ }
522
+
523
+ // Replies whose root never resolved (parent missing from the input slice or
524
+ // parent unmatched) count as unmatched too — there's no position to attach
525
+ // them to.
526
+ for (const [rootId, replies] of repliesByRoot) {
527
+ const root = byId.get(rootId);
528
+ if (!root || root.pos < 0) {
529
+ unmatchedCount += replies.length;
530
+ }
531
+ }
532
+
533
+ // Roots only — replies attach during emission.
534
+ const rootsWithPos = commentsWithPositions.filter(
535
+ c => !replyRootId.has(c.id)
536
+ );
537
+
538
+ // Log any unmatched roots for debugging
539
+ const unmatched = rootsWithPos.filter((c) => c.pos < 0);
540
+ if (process.env.DEBUG) {
541
+ console.log(`[DEBUG] insertComments: ${comments.length} input, ${rootsWithPos.length} roots, ${unmatched.length} unmatched roots, ${replyRootId.size} replies`);
542
+ if (unmatched.length > 0) {
543
+ unmatched.forEach(c => console.log(`[DEBUG] Unmatched ID=${c.id}: anchor="${(c.anchorText || 'none').slice(0,30)}"`));
544
+ }
545
+ }
546
+
547
+ const matchedRoots = rootsWithPos.filter((c) => c.pos >= 0);
548
+
549
+ // Sort by position descending (insert from end to avoid offset issues)
550
+ matchedRoots.sort((a, b) => b.pos - a.pos);
551
+
552
+ // Insert each cluster. With `wrapAnchor` (the default), the anchor text
553
+ // gets wrapped in `[anchor]{.mark}` so the rebuilt docx restores the
554
+ // original Word comment range. Without it, the comment block is inserted
555
+ // adjacent to the anchor and prose stays untouched — required for
556
+ // comments-only sync where multiple comments may share one anchor.
557
+ // Skip insertion when the parent's CriticMarkup already lives near the
558
+ // target — re-running sync against the same docx would otherwise stack
559
+ // duplicates. A 200-char window catches both wrapped
560
+ // (`{>>...<<}[anchor]{.mark}`) and bare (`{>>...<<}anchor`) forms while
561
+ // ignoring incidental matches farther away.
562
+ let dedupedCount = 0;
563
+ for (const c of matchedRoots) {
564
+ const parentBlock = `{>>${c.author}: ${c.text}<<}`;
565
+ const replies = repliesByRoot.get(c.id) ?? [];
566
+ const windowStart = Math.max(0, c.pos - 200);
567
+ const windowEnd = Math.min(result.length, c.pos + 200);
568
+ if (result.slice(windowStart, windowEnd).includes(parentBlock)) {
569
+ // Cluster already synced; treat all members as deduped.
570
+ dedupedCount += 1 + replies.length;
571
+ continue;
572
+ }
573
+ // Replies carry an explicit `↪ ` author prefix so the round-trip does not
574
+ // depend on positional adjacency in the markdown. On dense reviewer docs
575
+ // distinct clusters frequently land at the same anchor position; without
576
+ // the prefix the re-parse would misthread them. The injection side strips
577
+ // `↪ ` back off the author so Word renders the original name.
578
+ const replyBlocks = replies.map(r => `{>>↪ ${r.author}: ${r.text}<<}`);
579
+ const combined = parentBlock + replyBlocks.join('');
580
+ if (wrapAnchor && c.anchorText && c.anchorEnd) {
581
+ const before = result.slice(0, c.pos);
582
+ const anchor = result.slice(c.pos, c.anchorEnd);
583
+ const after = result.slice(c.anchorEnd);
584
+ result = before + combined + `[${anchor}]{.mark}` + after;
585
+ } else {
586
+ result = result.slice(0, c.pos) + combined + result.slice(c.pos);
587
+ }
588
+ // Replies inherit the root's confidence — they ride the same position.
589
+ if (placementConfidence(c.strategy) === 'low') {
590
+ lowConfidenceCount += 1 + replies.length;
591
+ } else {
592
+ placedCount += 1 + replies.length;
593
+ }
594
+ }
595
+
596
+ if (outStats) {
597
+ outStats.placed = placedCount;
598
+ outStats.lowConfidence = lowConfidenceCount;
599
+ outStats.deduped = dedupedCount;
600
+ outStats.unmatched = unmatchedCount;
601
+ }
602
+
603
+ // Log warnings unless quiet mode
604
+ if (!quiet) {
605
+ if (unmatchedCount > 0) {
606
+ console.warn(`Warning: ${unmatchedCount} comment(s) could not be matched to anchor text`);
607
+ }
608
+ if (lowConfidenceCount > 0) {
609
+ console.warn(`Note: ${lowConfidenceCount} comment(s) placed approximately — run \`rev verify-anchors\` to review`);
610
+ }
611
+ if (dedupedCount > 0) {
612
+ console.warn(`Note: ${dedupedCount} comment(s) already present at anchor — skipped to avoid duplication`);
613
+ }
614
+ if (duplicateWarnings.length > 0) {
615
+ console.warn(`Warning: Duplicate anchor text found (using context & tie-breaks for placement):`);
616
+ for (const w of duplicateWarnings) {
617
+ console.warn(` - ${w}`);
618
+ }
619
+ }
620
+ }
621
+
622
+ return result;
623
+ }
624
+
625
+ /**
626
+ * Import Word document with track changes directly as CriticMarkup
627
+ */
628
+ export async function importWordWithTrackChanges(
629
+ docxPath: string,
630
+ options: ImportWordWithTrackChangesOptions = {}
631
+ ): Promise<ImportWordWithTrackChangesResult> {
632
+ const { mediaDir, projectDir } = options;
633
+ const docxDir = path.dirname(docxPath);
634
+ const targetMediaDir = mediaDir || path.join(docxDir, 'media');
635
+ const targetProjectDir = projectDir || docxDir;
636
+
637
+ const registry = readImageRegistry(targetProjectDir);
638
+ const hasRegistry = registry && registry.figures && registry.figures.length > 0;
639
+
640
+ // First pass: count images
641
+ const { stdout: rawText } = await execAsync(
642
+ `pandoc "${docxPath}" -t markdown --wrap=none --track-changes=all`,
643
+ { maxBuffer: 50 * 1024 * 1024 }
644
+ );
645
+
646
+ const wordImageCount = (rawText.match(/!\[[^\]]*\]\(media\/[^)]+\)/g) || []).length;
647
+ const registryCount = hasRegistry ? registry.figures.length : 0;
648
+
649
+ const needsMediaExtraction = wordImageCount > registryCount;
650
+
651
+ if (hasRegistry) {
652
+ console.log(`Registry has ${registryCount} figures, Word doc has ${wordImageCount} images`);
653
+ if (needsMediaExtraction) {
654
+ console.log(`Extracting media (${wordImageCount - registryCount} new image(s) detected)`);
655
+ } else {
656
+ console.log(`Using existing figures from registry`);
657
+ }
658
+ }
659
+
660
+ // Extract from Word
661
+ const extracted = await extractFromWord(docxPath, {
662
+ mediaDir: targetMediaDir,
663
+ skipMediaExtraction: !needsMediaExtraction,
664
+ });
665
+
666
+ let text = extracted.text;
667
+ const extractedMedia = extracted.extractedMedia || [];
668
+ const comments = extracted.comments || [];
669
+ const anchors = extracted.anchors || new Map();
670
+
671
+ // Log messages
672
+ for (const msg of extracted.messages || []) {
673
+ if (msg.type === 'info') {
674
+ console.log(msg.message);
675
+ } else if (msg.type === 'warning') {
676
+ console.warn(`Warning: ${msg.message}`);
677
+ }
678
+ }
679
+
680
+ // Restore crossref
681
+ const crossrefResult = restoreCrossrefFromWord(text, targetProjectDir);
682
+ text = crossrefResult.text;
683
+ if (crossrefResult.restored > 0) {
684
+ console.log(`Restored ${crossrefResult.restored} crossref reference(s)`);
685
+ }
686
+
687
+ // Restore images
688
+ const imageRestoreResult = restoreImagesFromRegistry(text, targetProjectDir, crossrefResult.restoredLabels);
689
+ text = imageRestoreResult.text;
690
+ if (imageRestoreResult.restored > 0) {
691
+ console.log(`Restored ${imageRestoreResult.restored} image(s) from registry`);
692
+ }
693
+
694
+ // Insert comments
695
+ if (comments.length > 0) {
696
+ text = insertCommentsIntoMarkdown(text, comments, anchors);
697
+ console.log(`Inserted ${comments.length} comment(s)`);
698
+ }
699
+
700
+ // Clean up
701
+ text = cleanupAnnotations(text);
702
+
703
+ // Count final changes
704
+ const insertions = (text.match(/\{\+\+/g) || []).length;
705
+ const deletions = (text.match(/\{--/g) || []).length;
706
+ const substitutions = (text.match(/\{~~/g) || []).length;
707
+ const commentCount = (text.match(/\{>>/g) || []).length;
708
+
709
+ return {
710
+ text,
711
+ stats: {
712
+ insertions,
713
+ deletions,
714
+ substitutions,
715
+ comments: commentCount,
716
+ total: insertions + deletions + substitutions + commentCount,
717
+ hasTrackChanges: extracted.hasTrackChanges,
718
+ trackChangeStats: extracted.trackChangeStats,
719
+ },
720
+ extractedMedia,
721
+ comments,
722
+ };
723
+ }
724
+
725
+ /**
726
+ * Read a `.docx` into the same CriticMarkup-annotated Markdown that
727
+ * `rev import` produces, for read-only inspection (`rev status`, `rev
728
+ * comments`, ...). Track changes become `{++..++}` / `{--..--}` / `{~~..~~}`
729
+ * and comments are placed at their anchors, so `countAnnotations` /
730
+ * `getComments` and the rest operate on it exactly as they do on imported
731
+ * Markdown — the numbers match a subsequent `rev import`.
732
+ *
733
+ * Side-effect free: no media is extracted, nothing is written to disk, and
734
+ * extraction/placement messages are discarded (the caller only wants the
735
+ * annotated text). Works with or without pandoc — the XML fallback emits the
736
+ * same CriticMarkup — so it never reads the binary ZIP as text.
737
+ */
738
+ export async function readDocxAsAnnotatedMarkdown(docxPath: string): Promise<string> {
739
+ const extracted = await extractFromWord(docxPath, { skipMediaExtraction: true });
740
+ let text = extracted.text;
741
+ const comments = extracted.comments || [];
742
+ const anchors = extracted.anchors || new Map();
743
+
744
+ if (comments.length > 0) {
745
+ text = insertCommentsIntoMarkdown(text, comments, anchors, { quiet: true });
746
+ }
747
+
748
+ return cleanupAnnotations(text);
749
+ }
750
+
751
+ /**
752
+ * Legacy import function: Word doc → annotated MD via diff
753
+ */
754
+ export async function importFromWord(
755
+ docxPath: string,
756
+ originalMdPath: string,
757
+ options: ImportFromWordOptions = {}
758
+ ): Promise<ImportFromWordResult> {
759
+ const { author = 'Reviewer', sectionContent, figuresDir } = options;
760
+ const projectDir = path.dirname(originalMdPath);
761
+
762
+ let wordText: string;
763
+ let extractedMedia: string[] = [];
764
+ let wordTables: WordTable[] = options.wordTables || [];
765
+ let hasTrackChanges = false;
766
+
767
+ if (sectionContent !== undefined) {
768
+ let annotated = cleanupAnnotations(sectionContent);
769
+
770
+ const insertions = (annotated.match(/\{\+\+/g) || []).length;
771
+ const deletions = (annotated.match(/\{--/g) || []).length;
772
+ const substitutions = (annotated.match(/\{~~/g) || []).length;
773
+ const commentCount = (annotated.match(/\{>>/g) || []).length;
774
+
775
+ return {
776
+ annotated,
777
+ stats: {
778
+ insertions,
779
+ deletions,
780
+ substitutions,
781
+ comments: commentCount,
782
+ total: insertions + deletions + substitutions + commentCount,
783
+ },
784
+ extractedMedia: [],
785
+ };
786
+ } else {
787
+ const docxDir = path.dirname(docxPath);
788
+ const mediaDir = figuresDir || docxDir;
789
+
790
+ const extracted = await extractFromWord(docxPath, { mediaDir });
791
+ wordText = extracted.text;
792
+ extractedMedia = extracted.extractedMedia || [];
793
+ wordTables = extracted.tables || [];
794
+ hasTrackChanges = extracted.hasTrackChanges || false;
795
+
796
+ for (const msg of extracted.messages || []) {
797
+ if (msg.type === 'info') {
798
+ console.log(msg.message);
799
+ } else if (msg.type === 'warning') {
800
+ console.warn(`Warning: ${msg.message}`);
801
+ }
802
+ }
803
+
804
+ if (hasTrackChanges) {
805
+ const crossrefResult = restoreCrossrefFromWord(wordText, projectDir);
806
+ wordText = crossrefResult.text;
807
+ if (crossrefResult.restored > 0) {
808
+ console.log(`Restored ${crossrefResult.restored} crossref reference(s)`);
809
+ }
810
+
811
+ const imageRestoreResult = restoreImagesFromRegistry(wordText, projectDir, crossrefResult.restoredLabels);
812
+ wordText = imageRestoreResult.text;
813
+ if (imageRestoreResult.restored > 0) {
814
+ console.log(`Restored ${imageRestoreResult.restored} image(s) from registry`);
815
+ }
816
+
817
+ const comments = extracted.comments || [];
818
+ const anchors = extracted.anchors || new Map();
819
+ if (comments.length > 0) {
820
+ wordText = insertCommentsIntoMarkdown(wordText, comments, anchors);
821
+ console.log(`Inserted ${comments.length} comment(s)`);
822
+ }
823
+
824
+ wordText = cleanupAnnotations(wordText);
825
+
826
+ const insertions = (wordText.match(/\{\+\+/g) || []).length;
827
+ const deletions = (wordText.match(/\{--/g) || []).length;
828
+ const substitutions = (wordText.match(/\{~~/g) || []).length;
829
+ const commentCount = (wordText.match(/\{>>/g) || []).length;
830
+
831
+ return {
832
+ annotated: wordText,
833
+ stats: {
834
+ insertions,
835
+ deletions,
836
+ substitutions,
837
+ comments: commentCount,
838
+ total: insertions + deletions + substitutions + commentCount,
839
+ },
840
+ extractedMedia,
841
+ };
842
+ }
843
+
844
+ console.warn('Warning: No track changes detected in Word document.');
845
+ console.warn(' For best results, reviewers should use Track Changes in Word.');
846
+ console.warn(' Falling back to diff-based import (comparing against original MD).');
847
+ console.warn(' This approach may produce less accurate change annotations.');
848
+
849
+ const crossrefResult = restoreCrossrefFromWord(wordText, projectDir);
850
+ wordText = crossrefResult.text;
851
+ if (crossrefResult.restored > 0) {
852
+ console.log(`Restored ${crossrefResult.restored} crossref reference(s)`);
853
+ }
854
+
855
+ const imageRestoreResult = restoreImagesFromRegistry(wordText, projectDir, crossrefResult.restoredLabels);
856
+ wordText = imageRestoreResult.text;
857
+ if (imageRestoreResult.restored > 0) {
858
+ console.log(`Restored ${imageRestoreResult.restored} image(s) from registry`);
859
+ }
860
+ }
861
+
862
+ // Read original markdown
863
+ let originalMd = fs.readFileSync(originalMdPath, 'utf-8');
864
+
865
+ // Strip existing annotations
866
+ originalMd = stripAnnotations(originalMd, { keepComments: false });
867
+
868
+ // Load image registry
869
+ const imageRegistry = readImageRegistry(projectDir);
870
+
871
+ // Generate diff
872
+ let annotated = generateSmartDiff(originalMd, wordText, author, { wordTables, imageRegistry });
873
+
874
+ // Clean up
875
+ annotated = cleanupAnnotations(annotated);
876
+
877
+ // Fix citation annotations
878
+ annotated = fixCitationAnnotations(annotated, originalMd);
879
+
880
+ // Convert visible comments
881
+ annotated = convertVisibleComments(annotated);
882
+
883
+ // Count changes
884
+ const insertions = (annotated.match(/\{\+\+/g) || []).length;
885
+ const deletions = (annotated.match(/\{--/g) || []).length;
886
+ const substitutions = (annotated.match(/\{~~/g) || []).length;
887
+ const comments = (annotated.match(/\{>>/g) || []).length;
888
+
889
+ return {
890
+ annotated,
891
+ stats: {
892
+ insertions,
893
+ deletions,
894
+ substitutions,
895
+ comments,
896
+ total: insertions + deletions + substitutions + comments,
897
+ },
898
+ extractedMedia,
899
+ };
900
+ }
901
+
902
+ /**
903
+ * Move extracted media files to a figures directory with better names
904
+ */
905
+ export function moveExtractedMedia(
906
+ mediaFiles: string[],
907
+ figuresDir: string,
908
+ prefix: string = 'figure'
909
+ ): MoveExtractedMediaResult {
910
+ const moved: MovedFile[] = [];
911
+ const errors: string[] = [];
912
+
913
+ if (!fs.existsSync(figuresDir)) {
914
+ fs.mkdirSync(figuresDir, { recursive: true });
915
+ }
916
+
917
+ for (let i = 0; i < mediaFiles.length; i++) {
918
+ const src = mediaFiles[i];
919
+ const ext = path.extname(src).toLowerCase();
920
+ const newName = `${prefix}${i + 1}${ext}`;
921
+ const dest = path.join(figuresDir, newName);
922
+
923
+ try {
924
+ fs.copyFileSync(src, dest);
925
+ moved.push({ from: src, to: dest, name: newName });
926
+ } catch (err: any) {
927
+ errors.push(`Failed to copy ${src}: ${err.message}`);
928
+ }
929
+ }
930
+
931
+ return { moved, errors };
932
+ }