docrev 0.6.6 → 0.6.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,23 +1,14 @@
1
1
  # docrev
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/docrev)](https://www.npmjs.com/package/docrev)
4
+ [![npm downloads](https://img.shields.io/npm/dm/docrev)](https://www.npmjs.com/package/docrev)
5
+ [![node](https://img.shields.io/node/v/docrev)](https://nodejs.org)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
7
+ [![CI](https://github.com/gcol33/docrev/actions/workflows/ci.yml/badge.svg)](https://github.com/gcol33/docrev/actions/workflows/ci.yml)
4
8
 
5
- **Write papers in plain text. Generate Word when needed.**
9
+ A CLI for writing scientific papers in Markdown while collaborating with Word users.
6
10
 
7
- ## Why
8
-
9
- Scientific papers go through many revision cycles. You send a Word document to collaborators, they add comments and track changes, you address the feedback and send it back. Then journal submission, reviewer comments, more revisions. After a few rounds, track changes become unreadable, you have fifteen versions of the same file, and nobody knows which one is current. Equations break when copying between documents. Figures get embedded at the wrong resolution or disappear entirely.
10
-
11
- docrev takes a different approach. You write in plain text using Markdown, a simple formatting syntax that takes ten minutes to learn. When you need to share with collaborators or submit to a journal, docrev generates a Word document or PDF. Your collaborators review and comment in Word as usual. When they send it back, docrev imports their feedback into your Markdown files. You address the comments, rebuild the document, and send it back. The cycle continues, but your source files stay clean and under version control.
12
-
13
- Your collaborators do not need to install anything or change how they work. They keep using Word. You handle the conversion on your end. The result is proper version history, equations that never break, figures that stay linked rather than embedded, and automated citation formatting.
14
-
15
- ```
16
- Markdown files --> docrev --> Word/PDF
17
- ^ |
18
- | v
19
- +---- docrev <---- reviewer feedback
20
- ```
11
+ You write in Markdown under version control. Your collaborators use Word. docrev converts between the two, preserving track changes, comments, equations, and cross-references.
21
12
 
22
13
  ## Install
23
14
 
@@ -26,20 +17,20 @@ npm install -g docrev
26
17
  brew install pandoc
27
18
  ```
28
19
 
29
- ## Start a Project
20
+ Pandoc is required for document conversion. On Windows use `winget install JohnMacFarlane.Pandoc`, on Linux use `apt install pandoc`.
30
21
 
31
- From scratch:
32
- ```bash
33
- rev new my-paper
34
- cd my-paper
35
- ```
22
+ ## Getting Started
23
+
24
+ ### Starting from a Word Document
25
+
26
+ If you have an existing manuscript in Word:
36
27
 
37
- From existing Word document:
38
28
  ```bash
39
29
  rev import manuscript.docx
40
30
  ```
41
31
 
42
- Project structure:
32
+ This converts your document to markdown, splitting it into sections:
33
+
43
34
  ```
44
35
  my-paper/
45
36
  ├── introduction.md
@@ -50,130 +41,211 @@ my-paper/
50
41
  └── rev.yaml
51
42
  ```
52
43
 
53
- ## Markdown Basics
44
+ Track changes and comments from the Word document are preserved as annotations in the markdown files (see below).
54
45
 
55
- ```markdown
56
- # Heading
46
+ ### Starting from Scratch
57
47
 
58
- Paragraph text. **Bold** and *italic*.
48
+ To start a new paper in markdown:
59
49
 
60
- - Bullet point
61
- - Another point
50
+ ```bash
51
+ rev new my-paper
52
+ cd my-paper
53
+ ```
54
+
55
+ This creates the same project structure with empty section files. Write your paper in the markdown files, then build Word documents to share with collaborators.
56
+
57
+ ## The Revision Cycle
58
+
59
+ ### 1. Build and Share
62
60
 
63
- 1. Numbered item
64
- 2. Second item
61
+ Generate a Word document from your markdown:
62
+
63
+ ```bash
64
+ rev build docx
65
65
  ```
66
66
 
67
- ### Citations
67
+ Send this to your collaborators. They review it in Word, adding comments and track changes as usual.
68
68
 
69
- references.bib:
70
- ```bibtex
71
- @article{Smith2020,
72
- author = {Smith, Jane},
73
- title = {Paper Title},
74
- journal = {Nature},
75
- year = {2020}
76
- }
69
+ ### 2. Import Feedback
70
+
71
+ When collaborators return the reviewed document, import their feedback:
72
+
73
+ ```bash
74
+ rev sections reviewed.docx
77
75
  ```
78
76
 
79
- In text:
77
+ This updates your markdown files with their comments and track changes, converted to inline annotations.
78
+
79
+ ### 3. Review Track Changes
80
+
81
+ Track changes appear as inline annotations in your markdown:
82
+
80
83
  ```markdown
81
- Previous studies [@Smith2020] demonstrated this.
84
+ The sample size was {--100--}{++150++} individuals.
85
+ We collected data {~~monthly~>weekly~~} from each site.
82
86
  ```
83
87
 
84
- Output: "Previous studies (Smith 2020) demonstrated this."
88
+ - `{++text++}` inserted text
89
+ - `{--text--}` — deleted text
90
+ - `{~~old~>new~~}` — substitution
85
91
 
86
- ### Equations
92
+ To accept a change, keep the new text and delete the markup. To reject it, keep the old text. When you're done, the file is clean markdown.
93
+
94
+ ### 4. Respond to Comments
87
95
 
88
- Inline: `$E = mc^2$`
96
+ Comments appear inline in your markdown:
89
97
 
90
- Display:
91
98
  ```markdown
92
- $$
93
- \hat{p} = \frac{\sum_d w_d p_d}{\sum_d w_d}
94
- $$
99
+ We used a random sampling approach.
100
+ {>>Reviewer 2: Please clarify the sampling method.<<}
95
101
  ```
96
102
 
97
- ### Figures
103
+ List all comments in a file:
98
104
 
99
- ```markdown
100
- ![Study site locations](figures/map.png){#fig:map}
105
+ ```bash
106
+ rev comments methods.md
107
+ ```
101
108
 
102
- Results shown in @fig:map indicate regional variation.
109
+ Reply from the command line:
110
+
111
+ ```bash
112
+ rev config user "Your Name" # one-time setup
113
+ rev reply methods.md -n 1 -m "Added clarification in paragraph 2"
103
114
  ```
104
115
 
105
- Output: "Results shown in Figure 1 indicate regional variation."
116
+ Your reply threads beneath the original:
106
117
 
107
- Figure numbers update automatically when reordered.
118
+ ```markdown
119
+ We used a random sampling approach.
120
+ {>>Reviewer 2: Please clarify the sampling method.<<}
121
+ {>>Your Name: Added clarification in paragraph 2.<<}
122
+ ```
108
123
 
109
- ## Build
124
+ Mark comments as resolved:
110
125
 
111
126
  ```bash
112
- rev build docx # Word document
113
- rev build pdf # PDF
114
- rev build --dual # Clean + comments versions
115
- rev watch docx # Auto-rebuild on save
127
+ rev resolve methods.md -n 1
116
128
  ```
117
129
 
118
- ## Handle Reviewer Feedback
130
+ ### 5. Rebuild with Comment Threads
131
+
132
+ Generate both a clean version and one showing the comment threads:
119
133
 
120
- Import reviewed document:
121
134
  ```bash
122
- rev sections reviewed.docx
135
+ rev build --dual
123
136
  ```
124
137
 
125
- View comments:
138
+ This produces:
139
+ - `paper.docx` — clean, for submission
140
+ - `paper_comments.docx` — includes comment threads as Word comments
141
+
142
+ Your collaborators see the full conversation in the comments pane.
143
+
144
+ ### 6. Repeat
145
+
146
+ Send the updated Word document. Import new feedback with `rev sections`. Continue until done.
147
+
148
+ ## Before Submission
149
+
150
+ ### Validate Your Bibliography
151
+
152
+ Check that DOIs in your bibliography resolve correctly:
153
+
126
154
  ```bash
127
- rev comments methods.md
155
+ rev doi check references.bib
156
+ ```
157
+
158
+ Find DOIs for entries missing them:
159
+
160
+ ```bash
161
+ rev doi lookup references.bib
128
162
  ```
129
163
 
130
- Reply to comment:
164
+ Add a citation directly from a DOI:
165
+
131
166
  ```bash
132
- rev config user "Your Name"
133
- rev reply methods.md -n 1 -m "Clarified sampling methodology"
167
+ rev doi add 10.1038/s41586-020-2649-2
134
168
  ```
135
169
 
136
- Rebuild with threaded comments:
170
+ ### Run Pre-Submission Checks
171
+
172
+ Check for broken references, missing citations, and common issues:
173
+
137
174
  ```bash
138
- rev build --dual
175
+ rev check
176
+ ```
177
+
178
+ ## Writing in Markdown
179
+
180
+ ### Citations
181
+
182
+ Add references to `references.bib`:
183
+
184
+ ```bibtex
185
+ @article{Smith2020,
186
+ author = {Smith, Jane},
187
+ title = {Paper Title},
188
+ journal = {Nature},
189
+ year = {2020},
190
+ doi = {10.1038/example}
191
+ }
192
+ ```
193
+
194
+ Cite in text:
195
+
196
+ ```markdown
197
+ Previous work [@Smith2020] established this relationship.
198
+ Multiple sources support this [@Smith2020; @Jones2021].
199
+ ```
200
+
201
+ ### Equations
202
+
203
+ Inline equations use single dollar signs: `$E = mc^2$`
204
+
205
+ Display equations use double dollar signs:
206
+
207
+ ```markdown
208
+ $$
209
+ \bar{x} = \frac{1}{n} \sum_{i=1}^{n} x_i
210
+ $$
139
211
  ```
140
212
 
141
- Output:
142
- - `paper.docx` (clean)
143
- - `paper_comments.docx` (with comment threads)
213
+ ### Figures and Cross-References
144
214
 
145
- ## Commands
215
+ ```markdown
216
+ ![Study site locations](figures/map.png){#fig:map}
217
+
218
+ Results are shown in @fig:map.
219
+ ```
220
+
221
+ The reference `@fig:map` becomes "Figure 1" in the output. Numbers update automatically when figures are reordered.
222
+
223
+ Tables and equations work the same way with `@tbl:label` and `@eq:label`.
224
+
225
+ ## Useful Commands
146
226
 
147
227
  | Task | Command |
148
228
  |------|---------|
149
- | New project | `rev new my-paper` |
150
- | Import Word | `rev import manuscript.docx` |
229
+ | Start new project | `rev new my-paper` |
230
+ | Import Word document | `rev import manuscript.docx` |
231
+ | Import feedback | `rev sections reviewed.docx` |
232
+ | List comments | `rev comments methods.md` |
233
+ | Reply to comment | `rev reply methods.md -n 1 -m "response"` |
151
234
  | Build Word | `rev build docx` |
152
235
  | Build PDF | `rev build pdf` |
153
- | Import feedback | `rev sections reviewed.docx` |
154
- | View comments | `rev comments methods.md` |
155
- | Reply to comment | `rev reply methods.md -n 1 -m "text"` |
236
+ | Build both clean and annotated | `rev build --dual` |
237
+ | Check DOIs | `rev doi check references.bib` |
238
+ | Find missing DOIs | `rev doi lookup references.bib` |
156
239
  | Word count | `rev word-count` |
157
- | Validate DOIs | `rev doi check` |
158
- | Pre-submit check | `rev check` |
240
+ | Pre-submission check | `rev check` |
241
+ | Watch for changes | `rev watch docx` |
159
242
 
160
- Full reference: [docs/commands.md](docs/commands.md)
243
+ Full command reference: [docs/commands.md](docs/commands.md)
161
244
 
162
245
  ## Requirements
163
246
 
164
247
  - Node.js 18+
165
- - Pandoc
166
-
167
- ```bash
168
- # macOS
169
- brew install pandoc
170
-
171
- # Windows
172
- winget install JohnMacFarlane.Pandoc
173
-
174
- # Linux
175
- sudo apt install pandoc
176
- ```
248
+ - Pandoc 2.11+
177
249
 
178
250
  ## License
179
251
 
package/bin/rev.js CHANGED
@@ -1683,7 +1683,11 @@ program
1683
1683
  }
1684
1684
  }
1685
1685
 
1686
- // Step 1: Replace comments with markers
1686
+ // Step 1: Strip track changes but keep comments for Word conversion
1687
+ // This applies {++insertions++}, removes {--deletions--}, keeps {>>comments<<}
1688
+ markdown = stripAnnotations(markdown, { keepComments: true });
1689
+
1690
+ // Step 2: Replace comments with markers
1687
1691
  const spinMarkers = fmt.spinner('Preparing markers...').start();
1688
1692
  const { markedMarkdown, comments } = prepareMarkdownWithMarkers(markdown);
1689
1693
  spinMarkers.stop();
@@ -1691,11 +1695,11 @@ program
1691
1695
  if (comments.length === 0) {
1692
1696
  console.log(chalk.yellow('\nNo comments found - skipping comments DOCX'));
1693
1697
  } else {
1694
- // Step 2: Write marked markdown to temp file
1698
+ // Step 3: Write marked markdown to temp file
1695
1699
  const markedPath = path.join(dir, '.paper-marked.md');
1696
1700
  fs.writeFileSync(markedPath, markedMarkdown, 'utf-8');
1697
1701
 
1698
- // Step 3: Build DOCX from marked markdown using pandoc
1702
+ // Step 4: Build DOCX from marked markdown using pandoc
1699
1703
  const spinBuild = fmt.spinner('Building marked DOCX...').start();
1700
1704
  const markedDocxPath = path.join(dir, '.paper-marked.docx');
1701
1705
  const pandocResult = await runPandoc(markedPath, 'docx', config, { ...options, outputPath: markedDocxPath });
@@ -1704,7 +1708,7 @@ program
1704
1708
  if (!pandocResult.success) {
1705
1709
  console.error(chalk.yellow(`\nWarning: Could not build marked DOCX: ${pandocResult.error}`));
1706
1710
  } else {
1707
- // Step 4: Replace markers with comment ranges
1711
+ // Step 5: Replace markers with comment ranges
1708
1712
  const commentsDocxPath = docxResult.outputPath.replace(/\.docx$/, '_comments.docx');
1709
1713
  const spinInject = fmt.spinner('Injecting comments at markers...').start();
1710
1714
  const commentResult = await injectCommentsAtMarkers(markedDocxPath, comments, commentsDocxPath);
package/lib/import.js CHANGED
@@ -268,7 +268,8 @@ export function insertCommentsIntoMarkdown(markdown, comments, anchors, options
268
268
 
269
269
  if (occurrences.length === 1) {
270
270
  // Unique match - easy case
271
- return { ...c, pos: occurrences[0] + anchor.length, anchorText: anchor };
271
+ // Position at START of anchor (comment goes before, anchor gets marked)
272
+ return { ...c, pos: occurrences[0], anchorText: anchor, anchorEnd: occurrences[0] + anchor.length };
272
273
  }
273
274
 
274
275
  // Multiple occurrences - use context for disambiguation
@@ -321,16 +322,26 @@ export function insertCommentsIntoMarkdown(markdown, comments, anchors, options
321
322
  // Mark this position as used for tie-breaking subsequent comments
322
323
  usedPositions.add(bestIdx);
323
324
 
324
- return { ...c, pos: bestIdx + anchor.length, anchorText: anchor };
325
- }).filter((c) => c.pos > 0);
325
+ // Position at START of anchor (comment goes before, anchor gets marked)
326
+ return { ...c, pos: bestIdx, anchorText: anchor, anchorEnd: bestIdx + anchor.length };
327
+ }).filter((c) => c.pos >= 0);
326
328
 
327
329
  // Sort by position descending (insert from end to avoid offset issues)
328
330
  commentsWithPositions.sort((a, b) => b.pos - a.pos);
329
331
 
330
- // Insert each comment
332
+ // Insert each comment with anchor marking
331
333
  for (const c of commentsWithPositions) {
332
- const commentMark = ` {>>${c.author}: ${c.text}<<}`;
333
- result = result.slice(0, c.pos) + commentMark + result.slice(c.pos);
334
+ const comment = `{>>${c.author}: ${c.text}<<}`;
335
+ if (c.anchorText && c.anchorEnd) {
336
+ // Replace anchor text with: {>>comment<<}[anchor]{.mark}
337
+ const before = result.slice(0, c.pos);
338
+ const anchor = result.slice(c.pos, c.anchorEnd);
339
+ const after = result.slice(c.anchorEnd);
340
+ result = before + comment + `[${anchor}]{.mark}` + after;
341
+ } else {
342
+ // No anchor - just insert comment at position
343
+ result = result.slice(0, c.pos) + ` ${comment}` + result.slice(c.pos);
344
+ }
334
345
  }
335
346
 
336
347
  // Log warnings unless quiet mode
@@ -2,9 +2,9 @@
2
2
  * Word comment injection with reply threading
3
3
  *
4
4
  * Flow:
5
- * 1. prepareMarkdownWithMarkers() - Parse comments, detect Guy→Gilles reply pairs
6
- * - Guy comments get markers: ⟦CMS:n⟧anchor⟦CME:n⟧
7
- * - Gilles replies: no markers (they attach to parent comment)
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
8
  * 2. Pandoc converts to DOCX
9
9
  * 3. injectCommentsAtMarkers() - Insert comment ranges for parents only
10
10
  * - Replies go in comments.xml with parent reference in commentsExtended.xml
@@ -71,50 +71,41 @@ export function prepareMarkdownWithMarkers(markdown) {
71
71
  return { markedMarkdown: markdown, comments: [] };
72
72
  }
73
73
 
74
- // Detect reply relationships: Gilles immediately following Guy = reply
74
+ // Detect reply relationships based on adjacency
75
+ // First comment in a cluster = parent, all subsequent = replies to that parent
75
76
  // Comments are "adjacent" if there's only whitespace between them (< 50 chars)
76
77
  const ADJACENT_THRESHOLD = 50;
77
78
  const comments = [];
78
- let lastGuyIdx = -1;
79
+ let clusterParentIdx = -1; // Index of first comment in current cluster
79
80
  let lastCommentEnd = -1;
80
81
 
81
82
  for (let i = 0; i < rawMatches.length; i++) {
82
83
  const m = rawMatches[i];
83
- const isGuy = m.author === 'Guy Colling';
84
- const isGilles = m.author === 'Gilles Colling';
85
84
 
86
85
  // Check if this comment is adjacent to the previous one
87
86
  const gap = lastCommentEnd >= 0 ? m.start - lastCommentEnd : Infinity;
88
87
  const isAdjacent = gap < ADJACENT_THRESHOLD;
89
88
 
90
- // Reset lastGuyIdx if there's a gap (comments not in same cluster)
89
+ // Reset cluster if there's a gap (comments not in same cluster)
91
90
  if (!isAdjacent) {
92
- lastGuyIdx = -1;
91
+ clusterParentIdx = -1;
93
92
  }
94
93
 
95
- if (isGuy) {
94
+ if (clusterParentIdx === -1) {
95
+ // First comment in cluster = parent (regardless of author)
96
96
  comments.push({
97
97
  ...m,
98
98
  isReply: false,
99
99
  parentIdx: null,
100
100
  commentIdx: comments.length
101
101
  });
102
- lastGuyIdx = comments.length - 1;
103
- } else if (isGilles && lastGuyIdx >= 0 && isAdjacent) {
104
- // Gilles immediately following Guy (same cluster) = reply
105
- comments.push({
106
- ...m,
107
- isReply: true,
108
- parentIdx: lastGuyIdx,
109
- commentIdx: comments.length
110
- });
111
- // Don't reset lastGuyIdx - multiple replies could follow
102
+ clusterParentIdx = comments.length - 1;
112
103
  } else {
113
- // Standalone comment (not a reply)
104
+ // Subsequent comment in cluster = reply to first comment
114
105
  comments.push({
115
106
  ...m,
116
- isReply: false,
117
- parentIdx: null,
107
+ isReply: true,
108
+ parentIdx: clusterParentIdx,
118
109
  commentIdx: comments.length
119
110
  });
120
111
  }
@@ -122,6 +113,21 @@ export function prepareMarkdownWithMarkers(markdown) {
122
113
  lastCommentEnd = m.end;
123
114
  }
124
115
 
116
+ // Propagate anchors from replies to parents
117
+ // If a reply has an anchor but its parent doesn't, move the anchor to the parent
118
+ // Track flags for special handling during marker generation
119
+ for (const c of comments) {
120
+ if (c.isReply && c.anchor && c.parentIdx !== null) {
121
+ const parent = comments[c.parentIdx];
122
+ if (!parent.anchor) {
123
+ parent.anchor = c.anchor;
124
+ parent.anchorFromReply = true; // Parent's anchor came from a reply (markers placed by reply)
125
+ c.placesParentMarkers = true; // This reply should place the parent's markers
126
+ c.anchor = null;
127
+ }
128
+ }
129
+ }
130
+
125
131
  // Build marked markdown - only parent comments get markers
126
132
  // Process from end to start to preserve positions
127
133
  let markedMarkdown = markdown;
@@ -131,12 +137,57 @@ export function prepareMarkdownWithMarkers(markdown) {
131
137
 
132
138
  if (c.isReply) {
133
139
  // Reply: remove from document entirely (will be in comments.xml only)
134
- markedMarkdown = markedMarkdown.slice(0, c.start) + markedMarkdown.slice(c.end);
140
+ // Also consume leading whitespace to avoid double spaces
141
+ let removeStart = c.start;
142
+ while (removeStart > 0 && /\s/.test(markedMarkdown[removeStart - 1])) {
143
+ removeStart--;
144
+ }
145
+
146
+ // If this reply places parent's markers (anchor was propagated)
147
+ if (c.placesParentMarkers && c.parentIdx !== null) {
148
+ // Extract anchor text from the original match
149
+ const anchorMatch = c.fullMatch.match(/\[([^\]]+)\]\{\.mark\}$/);
150
+ if (anchorMatch) {
151
+ const anchorText = anchorMatch[1];
152
+ // Output markers with PARENT's index around the anchor text
153
+ const parentIdx = c.parentIdx;
154
+ const replacement = `${MARKER_START_PREFIX}${parentIdx}${MARKER_SUFFIX}${anchorText}${MARKER_END_PREFIX}${parentIdx}${MARKER_SUFFIX}`;
155
+ markedMarkdown = markedMarkdown.slice(0, removeStart) + replacement + markedMarkdown.slice(c.end);
156
+ } else {
157
+ markedMarkdown = markedMarkdown.slice(0, removeStart) + markedMarkdown.slice(c.end);
158
+ }
159
+ } else {
160
+ markedMarkdown = markedMarkdown.slice(0, removeStart) + markedMarkdown.slice(c.end);
161
+ }
135
162
  } else {
136
- // Parent comment: replace with markers
137
- const anchor = c.anchor || '';
138
- const replacement = `${MARKER_START_PREFIX}${i}${MARKER_SUFFIX}${anchor}${MARKER_END_PREFIX}${i}${MARKER_SUFFIX}`;
139
- markedMarkdown = markedMarkdown.slice(0, c.start) + replacement + markedMarkdown.slice(c.end);
163
+ // Parent comment
164
+ if (c.anchorFromReply) {
165
+ // Anchor markers are placed by the reply, just remove this comment
166
+ let removeStart = c.start;
167
+ while (removeStart > 0 && /\s/.test(markedMarkdown[removeStart - 1])) {
168
+ removeStart--;
169
+ }
170
+ markedMarkdown = markedMarkdown.slice(0, removeStart) + markedMarkdown.slice(c.end);
171
+ } else {
172
+ // Normal case: replace with markers
173
+ let anchor = c.anchor || '';
174
+
175
+ // If no anchor, try to use the next word as fallback to avoid empty ranges
176
+ if (!anchor) {
177
+ const afterComment = markedMarkdown.slice(c.end);
178
+ // Match the next word (skip leading whitespace/punctuation)
179
+ const nextWordMatch = afterComment.match(/^\s*([a-zA-Z0-9]+)/);
180
+ if (nextWordMatch) {
181
+ anchor = nextWordMatch[1];
182
+ // Also need to consume the matched text from afterComment
183
+ c.consumeAfter = nextWordMatch[0].length;
184
+ }
185
+ }
186
+
187
+ const replacement = `${MARKER_START_PREFIX}${i}${MARKER_SUFFIX}${anchor}${MARKER_END_PREFIX}${i}${MARKER_SUFFIX}`;
188
+ const endPos = c.end + (c.consumeAfter || 0);
189
+ markedMarkdown = markedMarkdown.slice(0, c.start) + replacement + markedMarkdown.slice(endPos);
190
+ }
140
191
  }
141
192
  }
142
193
 
@@ -239,11 +290,7 @@ function createCommentsExtensibleXml(comments) {
239
290
  return xml;
240
291
  }
241
292
 
242
- // Known Windows Live user IDs for authors (from manual_comments.docx)
243
- const AUTHOR_USER_IDS = {
244
- 'Guy Colling': '9ff4d97962428673',
245
- 'Gilles Colling': '46e930a4c4b85dfd',
246
- };
293
+ // Generate deterministic user IDs for authors (no hardcoded personal data)
247
294
 
248
295
  function createPeopleXml(comments) {
249
296
  // Extract unique authors
@@ -265,7 +312,7 @@ function createPeopleXml(comments) {
265
312
  xml += 'mc:Ignorable="w14 w15 w16se w16cid w16 w16cex w16sdtdh">';
266
313
 
267
314
  for (const author of authors) {
268
- const userId = AUTHOR_USER_IDS[author] || generateUserId(author);
315
+ const userId = generateUserId(author);
269
316
  xml += `<w15:person w15:author="${escapeXml(author)}">`;
270
317
  xml += `<w15:presenceInfo w15:providerId="Windows Live" w15:userId="${userId}"/>`;
271
318
  xml += `</w15:person>`;
@@ -366,9 +413,14 @@ export async function injectCommentsAtMarkers(docxPath, comments, outputPath) {
366
413
  const endInText = fullText.indexOf(endMarker);
367
414
  if (startInText === -1 || endInText === -1) continue;
368
415
 
369
- const textBefore = fullText.slice(0, startInText);
416
+ let textBefore = fullText.slice(0, startInText);
370
417
  const anchorText = fullText.slice(startInText + startMarker.length, endInText);
371
- const textAfter = fullText.slice(endInText + endMarker.length);
418
+ let textAfter = fullText.slice(endInText + endMarker.length);
419
+
420
+ // When anchor is empty, normalize double spaces to single space
421
+ if (!anchorText && textBefore.endsWith(' ') && textAfter.startsWith(' ')) {
422
+ textAfter = textAfter.slice(1); // Remove leading space from textAfter
423
+ }
372
424
 
373
425
  // Build replacement
374
426
  let replacement = '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docrev",
3
- "version": "0.6.6",
3
+ "version": "0.6.13",
4
4
  "description": "Academic paper revision workflow: Word ↔ Markdown round-trips, DOI validation, reviewer comments",
5
5
  "type": "module",
6
6
  "types": "types/index.d.ts",