Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 87 additions & 1 deletion packages/docx-core/src/baselines/atomizer/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1235,8 +1235,9 @@ async function mergeCommentAncillaryParts(
// (and any roots not yet present in the result, defensively).
await mergeMissingCommentDefinitions(resultArchive, commentById, includedCommentIds);

// Merge commentsExtended and people for the expanded set.
// Merge commentsExtended, commentsIds, and people for the expanded set.
await mergeCommentsExtended(sourceArchive, resultArchive, includedParaIds);
await mergeCommentsIds(sourceArchive, resultArchive, includedParaIds);
await mergePeople(sourceArchive, resultArchive, includedAuthors);
}

Expand Down Expand Up @@ -1358,6 +1359,91 @@ const COMMENTS_EXTENDED_DESCRIPTOR: AuxiliaryPartDescriptor = {
idBearingTags: [], // keyed by w15:paraId, not w:id
};

const COMMENTS_IDS_DESCRIPTOR: AuxiliaryPartDescriptor = {
label: 'commentsIds',
partPath: 'word/commentsIds.xml',
referenceTag: '',
entryTag: 'w16cid:commentId',
rootTag: 'w16cid:commentsIds',
contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.commentsIds+xml',
relationshipType: 'http://schemas.microsoft.com/office/2016/09/relationships/commentsIds',
idBearingTags: [], // keyed by w16cid:paraId, not w:id
};

/**
* Merge the merge-source side's commentsIds.xml durable-ID rows for the
* expanded comment set. commentsIds.xml ([MS-DOCX]) keys each
* <w16cid:commentId> by the comment paragraph's w16cid:paraId — the same
* paraIds threaded through commentsExtended.xml — so merged-in comments retain
* their Word durable IDs instead of forcing Word to regenerate them (issue
* #471). Mirrors mergeCommentsExtended's append/bootstrap shape.
*/
async function mergeCommentsIds(
sourceArchive: DocxArchive,
resultArchive: DocxArchive,
mergedParaIds: Set<string>,
): Promise<void> {
if (mergedParaIds.size === 0) return;

const sourceXml = await sourceArchive.getFile('word/commentsIds.xml');
if (!sourceXml) return;

const sourceDoc = parseXml(sourceXml);
const sourceEntries = sourceDoc.getElementsByTagName('w16cid:commentId');

// Collect rows whose paraId matches a merged comment's paragraph.
const entriesToMerge: Element[] = [];
for (let i = 0; i < sourceEntries.length; i++) {
const el = sourceEntries[i] as Element;
const paraId = el.getAttribute('w16cid:paraId');
if (paraId && mergedParaIds.has(paraId)) {
entriesToMerge.push(el);
}
}

if (entriesToMerge.length === 0) return;

const resultXml = await resultArchive.getFile('word/commentsIds.xml');

if (resultXml) {
const resultDoc = parseXml(resultXml);
const rootEl = resultDoc.documentElement;

const existingParaIds = new Set<string>();
const existing = rootEl.getElementsByTagName('w16cid:commentId');
for (let i = 0; i < existing.length; i++) {
const pid = (existing[i] as Element).getAttribute('w16cid:paraId');
if (pid) existingParaIds.add(pid);
}

for (const el of entriesToMerge) {
const pid = el.getAttribute('w16cid:paraId');
if (pid && !existingParaIds.has(pid)) {
rootEl.appendChild(resultDoc.importNode(el, true));
}
}

resultArchive.setFile('word/commentsIds.xml', serializer.serializeToString(resultDoc));
return;
}

// Bootstrap: result lacks commentsIds.xml but the merged comments carry
// durable IDs. Clone the source's root (preserves namespaces), drop
// non-matching rows, then add OPC metadata.
const newDoc = parseXml(sourceXml);
const newRoot = newDoc.documentElement;
const allEntries = newRoot.getElementsByTagName('w16cid:commentId');
const toRemove: Element[] = [];
for (let i = 0; i < allEntries.length; i++) {
const el = allEntries[i] as Element;
const paraId = el.getAttribute('w16cid:paraId');
if (!paraId || !mergedParaIds.has(paraId)) toRemove.push(el);
}
for (const el of toRemove) newRoot.removeChild(el);
resultArchive.setFile('word/commentsIds.xml', serializer.serializeToString(newDoc));
await ensureOpcMetadata(resultArchive, COMMENTS_IDS_DESCRIPTOR);
}

const PEOPLE_DESCRIPTOR: AuxiliaryPartDescriptor = {
label: 'people',
partPath: 'word/people.xml',
Expand Down
166 changes: 166 additions & 0 deletions packages/docx-core/src/integration/rebuild-auxiliary-merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,58 @@ import { compareDocuments } from '../index.js';
import { buildSyntheticDocx, buildDocxFromParts, getResultParts } from './synthetic-docx-fixture.js';
import { buildDocxFromBodyXml } from '../testing/ooxml-fixtures.js';
import { parseXml } from '../primitives/xml.js';
import type JSZip from 'jszip';

/**
* Re-zip a DOCX buffer after mutating its parts. Used to splice in Word
* extension parts (e.g. commentsIds.xml) the synthetic fixture does not emit.
*/
async function spliceZip(buffer: Buffer, mutate: (zip: JSZip) => Promise<void>): Promise<Buffer> {
const JSZip = (await import('jszip')).default;
const zip = await JSZip.loadAsync(buffer);
await mutate(zip);
return (await zip.generateAsync({ type: 'nodebuffer' })) as Buffer;
}

/**
* Splice a Word commentsIds.xml part (with its content-type override and
* relationship) into a DOCX buffer, carrying one <w16cid:commentId> durable-ID
* row per [paraId, durableId] pair. [MS-DOCX] keys these rows by the comment
* paragraph's w16cid:paraId.
*/
async function addCommentsIdsPart(
buffer: Buffer,
rows: Array<{ paraId: string; durableId: string }>,
): Promise<Buffer> {
return spliceZip(buffer, async (zip) => {
const entries = rows
.map((r) => `<w16cid:commentId w16cid:paraId="${r.paraId}" w16cid:durableId="${r.durableId}"/>`)
.join('');
zip.file(
'word/commentsIds.xml',
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<w16cid:commentsIds xmlns:w16cid="http://schemas.microsoft.com/office/word/2016/wordml/cid">` +
entries +
`</w16cid:commentsIds>`
);
const contentTypes = await zip.file('[Content_Types].xml')!.async('string');
zip.file(
'[Content_Types].xml',
contentTypes.replace(
'</Types>',
`<Override PartName="/word/commentsIds.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.commentsIds+xml"/></Types>`
)
);
const rels = await zip.file('word/_rels/document.xml.rels')!.async('string');
zip.file(
'word/_rels/document.xml.rels',
rels.replace(
'</Relationships>',
`<Relationship Id="rId900" Type="http://schemas.microsoft.com/office/2016/09/relationships/commentsIds" Target="commentsIds.xml"/></Relationships>`
)
);
});
}

/**
* Find every element with the given local name and report its immediate-parent
Expand Down Expand Up @@ -222,6 +274,120 @@ describe('Rebuild Auxiliary Part Merging (issue #94)', () => {
});
});

// ---------------------------------------------------------------------------
// Issue #471: merge-source commentsIds.xml durable-ID rows dropped
//
// commentsIds.xml ([MS-DOCX]) keys each <w16cid:commentId> durable-ID row by
// the comment paragraph's w16cid:paraId. Before the fix, neither
// reconstruction mode merged these rows, so merged-in comments lost their
// Word durable IDs (cosmetic — Word regenerates them — but the source's
// durable-ID metadata was silently dropped).
// ---------------------------------------------------------------------------
describe('Comment with commentsIds.xml added on revised side (issue #471)', () => {
test('rebuild bootstraps commentsIds.xml with the merge-source durable ID', async ({ given, when, then }: AllureBddContext) => {
let original: Buffer, revised: Buffer;
await given('original has no comments and revised adds one with a commentsIds durable ID', async () => {
original = await buildSyntheticDocx({
paragraphs: ['First paragraph', 'Commented paragraph', 'Third paragraph'],
});
const revisedBase = await buildSyntheticDocx({
paragraphs: ['First paragraph', 'Commented paragraph', 'Third paragraph'],
commentOnParagraph: 1,
commentText: 'Has durable id',
commentAuthor: 'Alice',
commentAncillaryParts: true,
});
revised = await addCommentsIdsPart(revisedBase, [
{ paraId: '00000001', durableId: '11111111' },
]);
});

let result: Awaited<ReturnType<typeof compareDocuments>>;
await when('documents are compared in rebuild mode', async () => {
result = await compareDocuments(original, revised, {
engine: 'atomizer',
reconstructionMode: 'rebuild',
});
});

await then('rebuild creates commentsIds.xml carrying the durable ID with OPC metadata', async () => {
expect(result.reconstructionModeUsed).toBe('rebuild');

const parts = await getResultParts(result.document);

expect(parts.commentsIdsXml).not.toBeNull();
expect(parts.commentsIdsXml!).toContain('w16cid:paraId="00000001"');
expect(parts.commentsIdsXml!).toContain('w16cid:durableId="11111111"');

expect(parts.contentTypesXml!).toContain('commentsIds.xml');
expect(parts.contentTypesXml!).toContain(
'application/vnd.openxmlformats-officedocument.wordprocessingml.commentsIds+xml'
);
expect(parts.relsXml!).toContain('commentsIds.xml');
expect(parts.relsXml!).toContain(
'http://schemas.microsoft.com/office/2016/09/relationships/commentsIds'
);
});
});

test('rebuild appends the reply durable ID into an existing commentsIds.xml', async ({ given, when, then }: AllureBddContext) => {
let original: Buffer, revised: Buffer;
await given('original has a root durable ID; revised adds a reply with its own durable ID', async () => {
const originalBase = await buildSyntheticDocx({
paragraphs: ['First paragraph', 'Commented paragraph', 'Third paragraph'],
commentOnParagraph: 1,
commentText: 'Root question',
commentAuthor: 'Alice',
commentAncillaryParts: true,
});
original = await addCommentsIdsPart(originalBase, [
{ paraId: '00000001', durableId: '11111111' },
]);
const revisedBase = await buildSyntheticDocx({
paragraphs: ['First paragraph', 'Commented paragraph', 'Third paragraph'],
commentOnParagraph: 1,
commentText: 'Root question',
commentAuthor: 'Alice',
commentAncillaryParts: true,
replyText: 'Reply text',
replyAuthor: 'Bob',
});
revised = await addCommentsIdsPart(revisedBase, [
{ paraId: '00000001', durableId: '11111111' },
{ paraId: '00000002', durableId: '22222222' },
]);
});

let result: Awaited<ReturnType<typeof compareDocuments>>;
await when('documents are compared in rebuild mode', async () => {
result = await compareDocuments(original, revised, {
engine: 'atomizer',
reconstructionMode: 'rebuild',
});
});

await then('both root and reply durable IDs survive in commentsIds.xml', async () => {
expect(result.reconstructionModeUsed).toBe('rebuild');

const parts = await getResultParts(result.document);

expect(parts.commentsIdsXml).not.toBeNull();
expect(parts.commentsIdsXml!).toContain('w16cid:paraId="00000001"');
expect(parts.commentsIdsXml!).toContain('w16cid:durableId="11111111"');
expect(parts.commentsIdsXml!).toContain('w16cid:paraId="00000002"');
expect(parts.commentsIdsXml!).toContain('w16cid:durableId="22222222"');

// Count-guard: appending the reply durable-ID row must not duplicate
// the pre-existing root row. Each paraId keys EXACTLY ONE
// <w16cid:commentId> durable-ID row.
const idRowsForPara = (paraId: string) =>
(parts.commentsIdsXml!.match(new RegExp(`w16cid:paraId="${paraId}"`, 'g')) ?? []).length;
expect(idRowsForPara('00000001')).toBe(1);
expect(idRowsForPara('00000002')).toBe(1);
});
});
});

// ---------------------------------------------------------------------------
// Issue #108: Reply comments dropped in rebuild mode
//
Expand Down