/** * Word comment injection with reply threading * * Flow: * 1. prepareMarkdownWithMarkers() - Parse comments, detect reply relationships * - First comment in a cluster = parent (gets markers: ⟦CMS:n⟧anchor⟦CME:n⟧) * - Subsequent adjacent comments = replies (no markers, attach to parent) * 2. Pandoc converts to DOCX * 3. injectCommentsAtMarkers() - Insert comment ranges for parents only * - Replies go in comments.xml with parent reference in commentsExtended.xml */ import * as fs from 'fs'; import AdmZip from 'adm-zip'; import { escapeXml } from './utils.js'; const MARKER_START_PREFIX = '⟦CMS:'; const MARKER_END_PREFIX = '⟦CME:'; const MARKER_SUFFIX = '⟧'; interface ParsedComment { author: string; text: string; anchor: string | null; start: number; end: number; fullMatch: string; } interface PreparedComment extends ParsedComment { isReply: boolean; parentIdx: number | null; commentIdx: number; anchorFromReply?: boolean; placesParentMarkers?: boolean; } interface PrepareResult { markedMarkdown: string; comments: PreparedComment[]; } interface CommentWithIds extends PreparedComment { id: string; paraId: string; paraId2: string; durableId: string; parentParaId?: string; } interface InjectionResult { success: boolean; commentCount: number; replyCount?: number; skippedComments: number; error?: string; } function generateParaId(commentIdx: number, paraNum: number): string { // Generate 8-character uppercase hex ID matching Word format // Word uses IDs like "3F25BC58", "0331C187" // Must be deterministic - same inputs always produce same output const id = 0x10000000 + (commentIdx * 0x00100000) + (paraNum * 0x00001000); return id.toString(16).toUpperCase().padStart(8, '0'); } /** * Parse comments and create markers * * Returns: * - markedMarkdown: markdown with markers for parent comments only * - comments: array with author, text, isReply, parentIdx */ export function prepareMarkdownWithMarkers(markdown: string): PrepareResult { // Match the comment block first; extend manually to capture an optional // trailing `[anchor]{.mark}` span. A regex `[^\]]+` for the anchor would // bail on the inner `]` of nested syntax (e.g. `[[0..9]]{.mark}` or // `[*phrase*]{.mark}` after pandoc-rewriting), so we walk the brackets // ourselves and verify a `{.mark}` suffix. const commentPattern = /\{>>([\s\S]+?)<<\}/g; function tryParseTrailingAnchor( text: string, fromIdx: number, ): { anchor: string; endIdx: number } | null { let i = fromIdx; while (i < text.length && /\s/.test(text[i] ?? '')) i++; if (text[i] !== '[') return null; let depth = 1; let j = i + 1; while (j < text.length) { const ch = text[j]; if (ch === '[') depth++; else if (ch === ']') { depth--; if (depth === 0) break; } j++; } if (depth !== 0) return null; if (text.slice(j + 1, j + 8) !== '{.mark}') return null; return { anchor: text.slice(i + 1, j), endIdx: j + 8 }; } const REPLY_PREFIX = '↪ '; const rawMatches: (ParsedComment & { explicitReply: boolean })[] = []; let match: RegExpExecArray | null; while ((match = commentPattern.exec(markdown)) !== null) { const content = match[1] ?? ''; let author = 'Unknown'; let text = content; const colonIdx = content.indexOf(':'); if (colonIdx > 0 && colonIdx < 30) { author = content.slice(0, colonIdx).trim(); text = content.slice(colonIdx + 1).trim(); } // The `↪ ` prefix is the authoritative reply signal emitted by // `insertCommentsIntoMarkdown`. Strip it from the author before injection // so Word displays the real name. let explicitReply = false; if (author.startsWith(REPLY_PREFIX)) { explicitReply = true; author = author.slice(REPLY_PREFIX.length).trim(); } const commentEnd = match.index + match[0].length; const trailing = tryParseTrailingAnchor(markdown, commentEnd); rawMatches.push({ author, text, anchor: trailing ? trailing.anchor : null, start: match.index, end: trailing ? trailing.endIdx : commentEnd, fullMatch: markdown.slice(match.index, trailing ? trailing.endIdx : commentEnd), explicitReply, }); // Advance regex lastIndex past the consumed anchor so the next iteration // doesn't re-scan inside it (e.g. `[*emphasis*]{.mark}` would otherwise // tempt the matcher to look for another `{>>...<<}` in the body of the // anchor span). if (trailing) { commentPattern.lastIndex = trailing.endIdx; } } if (rawMatches.length === 0) { return { markedMarkdown: markdown, comments: [] }; } // Two-mode reply detection driven by the markdown itself: // - If any comment carries the `↪ ` author prefix, the markdown came // through `insertCommentsIntoMarkdown` and we use prefix-only mode. // Distinct clusters that happen to land at gap=0 (a real failure // mode on dense reviewer docs — 298-comment paper produced 9 such // collisions) are not misthreaded. // - If no comment carries the prefix, the markdown was hand-typed. // Fall back to gap < 10 adjacency for backward compat with users // who write CriticMarkup directly. const ADJACENT_THRESHOLD = 10; const useExplicitMode = rawMatches.some(m => m.explicitReply); const comments: PreparedComment[] = []; let clusterParentIdx = -1; // Index of first comment in current cluster let lastCommentEnd = -1; for (let i = 0; i < rawMatches.length; i++) { const m = rawMatches[i]; if (!m) continue; const gap = lastCommentEnd >= 0 ? m.start - lastCommentEnd : Infinity; const isAdjacent = useExplicitMode ? m.explicitReply : gap < ADJACENT_THRESHOLD; // Reset cluster if there's a gap (comments not in same cluster) if (!isAdjacent) { clusterParentIdx = -1; } if (clusterParentIdx === -1) { // First comment in cluster = parent (regardless of author) comments.push({ author: m.author, text: m.text, anchor: m.anchor, start: m.start, end: m.end, fullMatch: m.fullMatch, isReply: false, parentIdx: null, commentIdx: comments.length }); clusterParentIdx = comments.length - 1; } else { // Subsequent comment in cluster = reply to first comment comments.push({ author: m.author, text: m.text, anchor: m.anchor, start: m.start, end: m.end, fullMatch: m.fullMatch, isReply: true, parentIdx: clusterParentIdx, commentIdx: comments.length }); } lastCommentEnd = m.end; } // Propagate anchors from replies to parents // If a reply has an anchor but its parent doesn't, move the anchor to the parent // Track flags for special handling during marker generation for (const c of comments) { if (c.isReply && c.anchor && c.parentIdx !== null) { const parent = comments[c.parentIdx]; if (parent && !parent.anchor) { parent.anchor = c.anchor; parent.anchorFromReply = true; // Parent's anchor came from a reply (markers placed by reply) c.placesParentMarkers = true; // This reply should place the parent's markers c.anchor = null; } } } // Build marked markdown - only parent comments get markers // Process from end to start to preserve positions let markedMarkdown = markdown; for (let i = comments.length - 1; i >= 0; i--) { const c = comments[i]; if (!c) continue; if (c.isReply) { // Reply: remove from document entirely (will be in comments.xml only) // Also consume one preceding whitespace char to avoid double spaces. // We deliberately consume at most one — walking arbitrarily backwards // would shift positions that lower-index comments still depend on. let removeStart = c.start; if (removeStart > 0 && /\s/.test(markedMarkdown[removeStart - 1] ?? '')) { removeStart--; } // If this reply places parent's markers (anchor was propagated) if (c.placesParentMarkers && c.parentIdx !== null) { // Extract anchor text from the original match const anchorMatch = c.fullMatch.match(/\[([^\]]+)\]\{\.mark\}$/); if (anchorMatch) { const anchorText = anchorMatch[1] ?? ''; // Output markers with PARENT's index around the anchor text const parentIdx = c.parentIdx; const replacement = `${MARKER_START_PREFIX}${parentIdx}${MARKER_SUFFIX}${anchorText}${MARKER_END_PREFIX}${parentIdx}${MARKER_SUFFIX}`; markedMarkdown = markedMarkdown.slice(0, removeStart) + replacement + markedMarkdown.slice(c.end); } else { markedMarkdown = markedMarkdown.slice(0, removeStart) + markedMarkdown.slice(c.end); } } else { markedMarkdown = markedMarkdown.slice(0, removeStart) + markedMarkdown.slice(c.end); } } else { // Parent comment if (c.anchorFromReply) { // Anchor markers are placed by the reply, just remove this comment. // Consume one preceding whitespace char only (see reply branch above). let removeStart = c.start; if (removeStart > 0 && /\s/.test(markedMarkdown[removeStart - 1] ?? '')) { removeStart--; } markedMarkdown = markedMarkdown.slice(0, removeStart) + markedMarkdown.slice(c.end); } else { // Normal case: replace with markers const anchor = c.anchor || ''; const replacement = `${MARKER_START_PREFIX}${i}${MARKER_SUFFIX}${anchor}${MARKER_END_PREFIX}${i}${MARKER_SUFFIX}`; markedMarkdown = markedMarkdown.slice(0, c.start) + replacement + markedMarkdown.slice(c.end); } } } return { markedMarkdown, comments }; } function createCommentsXml(comments: CommentWithIds[]): string { // Word expects date without milliseconds: 2025-12-30T08:33:00Z const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); let xml = '\n'; // Minimal namespaces matching golden file structure xml += ''; // Use a consistent rsid (8-char hex) for all comments in this batch const rsid = '00' + (Date.now() % 0xFFFFFF).toString(16).toUpperCase().padStart(6, '0'); for (const comment of comments) { xml += ``; // First paragraph: rsidRDefault="00000000", annotationRef without rStyle wrapper xml += ``; xml += ``; xml += `${escapeXml(comment.text)}`; xml += ``; if (comment.isReply) { // Second empty paragraph: rsidRDefault matches rsidR xml += ``; } xml += ``; } xml += ''; return xml; } function createCommentsExtendedXml(comments: CommentWithIds[]): string { let xml = '\n'; // Minimal namespaces matching golden file structure xml += ''; for (const comment of comments) { if (comment.isReply && comment.parentParaId) { // Reply: use paraId2 (the second/empty paragraph) and link to parent's paraId xml += ``; } else { // Parent comment: use paraId (first paragraph) xml += ``; } } xml += ''; return xml; } function generateDurableId(index: number): string { // Generate unique 8-char hex ID for durableId // CRITICAL: Must stay within signed 32-bit range (< 0x7FFFFFFF = 2147483647) // Word interprets durableIds as signed 32-bit integers const base = 0x10000000 + (Date.now() % 0x40000000); // Base between 0x10000000 and 0x50000000 const id = (base + index * 0x01000000) % 0x7FFFFFFF; // Keep under signed 32-bit max return id.toString(16).toUpperCase().padStart(8, '0'); } function createCommentsIdsXml(comments: CommentWithIds[]): string { let xml = '\n'; // Minimal namespaces matching golden file structure xml += '`; } xml += ''; return xml; } function createCommentsExtensibleXml(comments: CommentWithIds[]): string { const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); let xml = '\n'; // Minimal namespaces matching golden file structure xml += '`; } xml += ''; return xml; } // Generate deterministic user IDs for authors (no hardcoded personal data) function createPeopleXml(comments: CommentWithIds[]): string { // Extract unique authors const authors = [...new Set(comments.map(c => c.author))]; let xml = '\n'; xml += '`; xml += ``; xml += ``; } xml += ''; return xml; } function generateUserId(author: string): string { // Generate a deterministic 16-char hex ID from author name let hash = 0; for (let i = 0; i < author.length; i++) { hash = ((hash << 5) - hash) + author.charCodeAt(i); hash = hash & hash; } return Math.abs(hash).toString(16).padStart(16, '0').slice(0, 16); } /** * Inject comments at marker positions */ export async function injectCommentsAtMarkers( docxPath: string, comments: PreparedComment[], outputPath: string ): Promise { try { if (!fs.existsSync(docxPath)) { return { success: false, commentCount: 0, skippedComments: 0, error: `File not found: ${docxPath}` }; } if (comments.length === 0) { fs.copyFileSync(docxPath, outputPath); return { success: true, commentCount: 0, skippedComments: 0 }; } const zip = new AdmZip(docxPath); const documentEntry = zip.getEntry('word/document.xml'); if (!documentEntry) { return { success: false, commentCount: 0, skippedComments: 0, error: 'Invalid DOCX: no document.xml' }; } let documentXml = zip.readAsText(documentEntry); // Assign IDs and paraIds (IDs start at 1, not 0 - Word convention) const commentsWithIds: CommentWithIds[] = comments.map((c, idx) => ({ ...c, id: String(idx + 1), paraId: generateParaId(idx, 1), // First paragraph (e.g., 10000001) paraId2: generateParaId(idx, 2), // Second paragraph (e.g., 10000002) durableId: generateDurableId(idx), // Unique ID for commentsIds/commentsExtensible })); // Link replies to parent paraIds for (const c of commentsWithIds) { if (c.isReply && c.parentIdx !== null) { const parent = commentsWithIds[c.parentIdx]; if (parent) { c.parentParaId = parent.paraId; } } } const injectedIds = new Set(); // Process only parent comments (non-replies) for document ranges const parentComments = commentsWithIds.filter(c => !c.isReply); for (let i = parentComments.length - 1; i >= 0; i--) { const comment = parentComments[i]; if (!comment) continue; const idx = comment.commentIdx; const startMarker = `${MARKER_START_PREFIX}${idx}${MARKER_SUFFIX}`; const endMarker = `${MARKER_END_PREFIX}${idx}${MARKER_SUFFIX}`; // Pandoc duplicates inline image alt-text into // metadata attributes AND into the visible caption paragraph. A naive // indexOf hits the metadata-attribute occurrence first, where there is // no element so dissectRun fails. Skip occurrences whose position // is inside an XML tag (last unbalanced '<' before position). // See: https://github.com/gcol33/docrev/issues/4 function findInTextContent(haystack: string, needle: string, fromIdx = 0): number { let i = fromIdx; while (true) { const p = haystack.indexOf(needle, i); if (p < 0) return -1; const lastLt = haystack.lastIndexOf('<', p); const lastGt = haystack.lastIndexOf('>', p); if (lastLt > lastGt) { i = p + 1; continue; } return p; } } const startPos = findInTextContent(documentXml, startMarker); const endPos = startPos === -1 ? -1 : findInTextContent(documentXml, endMarker, startPos + startMarker.length); if (startPos === -1 || endPos === -1) continue; // Find the runs containing each marker. Pandoc may split a single // markdown anchor across multiple blocks when it applies styling // mid-anchor (smart-quote substitution, *italic*, `code`, **bold**). // The same-run path (current happy path) collapses into the multi-run // path when start and end runs coincide. const startRunOpen = Math.max( documentXml.lastIndexOf('', startPos), documentXml.lastIndexOf('', startPos); const endRunOpen = Math.max( documentXml.lastIndexOf('', endPos), documentXml.lastIndexOf('', endPos); if ( startRunOpen === -1 || startRunCloseIdx === -1 || endRunOpen === -1 || endRunCloseIdx === -1 ) continue; const startRunClose = startRunCloseIdx + ''.length; const endRunClose = endRunCloseIdx + ''.length; const startRunFull = documentXml.slice(startRunOpen, startRunClose); const endRunFull = documentXml.slice(endRunOpen, endRunClose); // Extract and element shape from each run. Both pieces // are needed verbatim so a textBefore split keeps its original styling // and so the post-anchor textAfter render keeps the end run's styling. function dissectRun(runXml: string, marker: string): { rPr: string; tElement: string; textBefore: string; textAfter: string; } | null { const rPrMatch = runXml.match(/[\s\S]*?<\/w:rPr>/); const tMatch = runXml.match(/]*>([\s\S]*?)<\/w:t>/); if (!tMatch) return null; const tOpenMatch = tMatch[0].match(/]*>/); if (!tOpenMatch) return null; const tContent = tMatch[1] ?? ''; const markerInT = tContent.indexOf(marker); if (markerInT === -1) return null; return { rPr: rPrMatch ? rPrMatch[0] : '', tElement: tOpenMatch[0], textBefore: tContent.slice(0, markerInT), textAfter: tContent.slice(markerInT + marker.length), }; } let replacement = ''; const replies = commentsWithIds.filter(c => c.isReply && c.parentIdx === comment?.commentIdx); const emitRangeStarts = () => { replacement += ``; for (const reply of replies) { replacement += ``; } }; const emitRangeEnds = () => { replacement += ``; replacement += ``; for (const reply of replies) { replacement += ``; replacement += ``; injectedIds.add(reply.id); } }; if (startRunOpen === endRunOpen) { // Same-run path: both markers live inside one . Original logic. const startInfo = dissectRun(startRunFull, startMarker); if (!startInfo) continue; const fullText = startInfo.textBefore + startMarker + startInfo.textAfter; const endInTextRel = startInfo.textAfter.indexOf(endMarker); if (endInTextRel === -1) continue; const anchorTextSame = startInfo.textAfter.slice(0, endInTextRel); let textAfter = startInfo.textAfter.slice(endInTextRel + endMarker.length); let anchorText = anchorTextSame; let textBefore = startInfo.textBefore; // Empty anchor: borrow the next word so the comment has something // to anchor on. Then normalize the trailing double space. if (!anchorText && textAfter) { const wordMatch = textAfter.match(/^\s*(\S+)/); if (wordMatch) { anchorText = wordMatch[1] ?? ''; textAfter = textAfter.slice(wordMatch[0].length); } } if (!anchorText && textBefore.endsWith(' ') && textAfter.startsWith(' ')) { textAfter = textAfter.slice(1); } // Suppress unused warning for pre-empty-anchor fullText var void fullText; if (textBefore) { replacement += `${startInfo.rPr}${startInfo.tElement}${textBefore}`; } emitRangeStarts(); if (anchorText) { replacement += `${startInfo.rPr}${startInfo.tElement}${anchorText}`; } emitRangeEnds(); if (textAfter) { replacement += `${startInfo.rPr}${startInfo.tElement}${textAfter}`; } documentXml = documentXml.slice(0, startRunOpen) + replacement + documentXml.slice(startRunClose); injectedIds.add(comment.id); continue; } // Multi-run path: markers sit in different blocks because pandoc // applied mid-anchor styling. Split the start run at the start marker, // keep all middle runs verbatim (they carry the styled anchor portions), // split the end run at the end marker. const startInfo = dissectRun(startRunFull, startMarker); const endInfo = dissectRun(endRunFull, endMarker); if (!startInfo || !endInfo) continue; const middle = documentXml.slice(startRunClose, endRunOpen); if (startInfo.textBefore) { replacement += `${startInfo.rPr}${startInfo.tElement}${startInfo.textBefore}`; } emitRangeStarts(); if (startInfo.textAfter) { replacement += `${startInfo.rPr}${startInfo.tElement}${startInfo.textAfter}`; } replacement += middle; if (endInfo.textBefore) { replacement += `${endInfo.rPr}${endInfo.tElement}${endInfo.textBefore}`; } emitRangeEnds(); if (endInfo.textAfter) { replacement += `${endInfo.rPr}${endInfo.tElement}${endInfo.textAfter}`; } documentXml = documentXml.slice(0, startRunOpen) + replacement + documentXml.slice(endRunClose); injectedIds.add(comment.id); } // Add required namespaces to document.xml for comment threading const requiredNs: Record = { 'xmlns:w14': 'http://schemas.microsoft.com/office/word/2010/wordml', 'xmlns:w15': 'http://schemas.microsoft.com/office/word/2012/wordml', 'xmlns:w16cid': 'http://schemas.microsoft.com/office/word/2016/wordml/cid', 'xmlns:w16cex': 'http://schemas.microsoft.com/office/word/2018/wordml/cex', 'xmlns:mc': 'http://schemas.openxmlformats.org/markup-compatibility/2006', }; // Find ]*>/); if (docTagMatch) { let docTag = docTagMatch[0]; let modified = false; for (const [attr, val] of Object.entries(requiredNs)) { if (!docTag.includes(attr)) { docTag = docTag.replace('>', ` ${attr}="${val}">`); modified = true; } } // Add mc:Ignorable if mc namespace was added if (modified && !docTag.includes('mc:Ignorable')) { docTag = docTag.replace('>', ' mc:Ignorable="w14 w15 w16cid w16cex">'); } documentXml = documentXml.replace(docTagMatch[0], docTag); } // Update document.xml zip.updateFile('word/document.xml', Buffer.from(documentXml, 'utf-8')); // All comments (parents + replies) go in comments.xml // But only include if parent was injected const includedComments = commentsWithIds.filter(c => { if (!c.isReply) { return injectedIds.has(c.id); } else { // Include reply if its parent was injected const parent = c.parentIdx !== null ? commentsWithIds[c.parentIdx] : undefined; return parent && injectedIds.has(parent.id); } }); // Create comments.xml const commentsXml = createCommentsXml(includedComments); if (zip.getEntry('word/comments.xml')) { zip.updateFile('word/comments.xml', Buffer.from(commentsXml, 'utf-8')); } else { zip.addFile('word/comments.xml', Buffer.from(commentsXml, 'utf-8')); } // Create commentsExtended.xml with reply threading const commentsExtXml = createCommentsExtendedXml(includedComments); if (zip.getEntry('word/commentsExtended.xml')) { zip.updateFile('word/commentsExtended.xml', Buffer.from(commentsExtXml, 'utf-8')); } else { zip.addFile('word/commentsExtended.xml', Buffer.from(commentsExtXml, 'utf-8')); } // Create commentsIds.xml (Word 2016+) const commentsIdsXml = createCommentsIdsXml(includedComments); if (zip.getEntry('word/commentsIds.xml')) { zip.updateFile('word/commentsIds.xml', Buffer.from(commentsIdsXml, 'utf-8')); } else { zip.addFile('word/commentsIds.xml', Buffer.from(commentsIdsXml, 'utf-8')); } // Create commentsExtensible.xml (Word 2018+) const commentsExtensibleXml = createCommentsExtensibleXml(includedComments); if (zip.getEntry('word/commentsExtensible.xml')) { zip.updateFile('word/commentsExtensible.xml', Buffer.from(commentsExtensibleXml, 'utf-8')); } else { zip.addFile('word/commentsExtensible.xml', Buffer.from(commentsExtensibleXml, 'utf-8')); } // Create people.xml (author definitions with Windows Live IDs) const peopleXml = createPeopleXml(includedComments); if (zip.getEntry('word/people.xml')) { zip.updateFile('word/people.xml', Buffer.from(peopleXml, 'utf-8')); } else { zip.addFile('word/people.xml', Buffer.from(peopleXml, 'utf-8')); } // Update [Content_Types].xml const contentTypesEntry = zip.getEntry('[Content_Types].xml'); if (contentTypesEntry) { let contentTypes = zip.readAsText(contentTypesEntry); if (!contentTypes.includes('comments.xml')) { const insertPoint = contentTypes.lastIndexOf(''); contentTypes = contentTypes.slice(0, insertPoint) + '\n' + contentTypes.slice(insertPoint); } if (!contentTypes.includes('commentsExtended.xml')) { const insertPoint = contentTypes.lastIndexOf(''); contentTypes = contentTypes.slice(0, insertPoint) + '\n' + contentTypes.slice(insertPoint); } if (!contentTypes.includes('commentsIds.xml')) { const insertPoint = contentTypes.lastIndexOf(''); contentTypes = contentTypes.slice(0, insertPoint) + '\n' + contentTypes.slice(insertPoint); } if (!contentTypes.includes('commentsExtensible.xml')) { const insertPoint = contentTypes.lastIndexOf(''); contentTypes = contentTypes.slice(0, insertPoint) + '\n' + contentTypes.slice(insertPoint); } if (!contentTypes.includes('people.xml')) { const insertPoint = contentTypes.lastIndexOf(''); contentTypes = contentTypes.slice(0, insertPoint) + '\n' + contentTypes.slice(insertPoint); } zip.updateFile('[Content_Types].xml', Buffer.from(contentTypes, 'utf-8')); } // Update relationships const relsEntry = zip.getEntry('word/_rels/document.xml.rels'); if (relsEntry) { let rels = zip.readAsText(relsEntry); const rIdMatches = rels.match(/rId(\d+)/g) || []; const maxId = rIdMatches.reduce((max, r) => Math.max(max, parseInt(r.replace('rId', ''))), 0); if (!rels.includes('comments.xml')) { const insertPoint = rels.lastIndexOf(''); rels = rels.slice(0, insertPoint) + `\n` + rels.slice(insertPoint); } if (!rels.includes('commentsExtended.xml')) { const insertPoint = rels.lastIndexOf(''); rels = rels.slice(0, insertPoint) + `\n` + rels.slice(insertPoint); } if (!rels.includes('commentsIds.xml')) { const insertPoint = rels.lastIndexOf(''); rels = rels.slice(0, insertPoint) + `\n` + rels.slice(insertPoint); } if (!rels.includes('commentsExtensible.xml')) { const insertPoint = rels.lastIndexOf(''); rels = rels.slice(0, insertPoint) + `\n` + rels.slice(insertPoint); } if (!rels.includes('people.xml')) { const insertPoint = rels.lastIndexOf(''); rels = rels.slice(0, insertPoint) + `\n` + rels.slice(insertPoint); } zip.updateFile('word/_rels/document.xml.rels', Buffer.from(rels, 'utf-8')); } zip.writeZip(outputPath); const parentCount = includedComments.filter(c => !c.isReply).length; const replyCount = includedComments.filter(c => c.isReply).length; return { success: true, commentCount: parentCount, replyCount: replyCount, skippedComments: comments.length - includedComments.length, }; } catch (err: any) { return { success: false, commentCount: 0, skippedComments: 0, error: err.message }; } }