From f5e6bd276905e72cf5c7658e54769583cb65ca61 Mon Sep 17 00:00:00 2001 From: Alex Casillas Date: Tue, 23 Jun 2026 10:51:05 +0100 Subject: [PATCH 1/2] feat: emit word/commentsIds.xml with durable comment ids (w16cid) --- src/export/packer/next-compiler.spec.ts | 65 ++++++++++++++ src/export/packer/next-compiler.ts | 26 ++++++ src/file/content-types/content-types.ts | 9 ++ src/file/file.spec.ts | 36 ++++++++ src/file/file.ts | 23 ++++- src/file/paragraph/run/comment-run.spec.ts | 88 +++++++++++++++++++ src/file/paragraph/run/comment-run.ts | 45 ++++++++-- .../paragraph/run/comments-extended.spec.ts | 42 ++++++++- src/file/paragraph/run/comments-extended.ts | 79 ++++++++++++++++- .../relationship/relationship.ts | 1 + 10 files changed, 403 insertions(+), 11 deletions(-) diff --git a/src/export/packer/next-compiler.spec.ts b/src/export/packer/next-compiler.spec.ts index 08759b39d9..d75b781e88 100644 --- a/src/export/packer/next-compiler.spec.ts +++ b/src/export/packer/next-compiler.spec.ts @@ -153,6 +153,71 @@ describe("Compiler", () => { expect(commentsExtendedText).to.contain("w15:paraIdParent"); }); + it("should not include commentsIds.xml when comments have no durableId", () => { + const file = new File({ + sections: [], + comments: { + children: [{ id: 0, children: [new Paragraph("comment")] }], + }, + }); + const zipFile = compiler.compile(file); + const fileNames = Object.keys(zipFile.files).map((f) => zipFile.files[f].name); + + expect(fileNames).to.not.include("word/commentsIds.xml"); + }); + + it("should include commentsIds.xml for a single (non-threaded) comment with durableId", { timeout: 99999999 }, async () => { + const file = new File({ + sections: [], + comments: { + children: [{ id: 0, children: [new Paragraph("comment")], durableId: "12AB34CD" }], + }, + }); + const zipFile = compiler.compile(file); + const fileNames = Object.keys(zipFile.files).map((f) => zipFile.files[f].name); + + expect(fileNames).to.include("word/commentsIds.xml"); + + const commentsIdsText = await zipFile.file("word/commentsIds.xml")?.async("text"); + expect(commentsIdsText).to.contain("w16cid:commentsIds"); + expect(commentsIdsText).to.contain("w16cid:commentId"); + expect(commentsIdsText).to.contain('w16cid:paraId="00000001"'); + expect(commentsIdsText).to.contain('w16cid:durableId="12AB34CD"'); + + // Content type override is registered + const contentTypesText = await zipFile.file("[Content_Types].xml")?.async("text"); + expect(contentTypesText).to.contain("application/vnd.openxmlformats-officedocument.wordprocessingml.commentsIds+xml"); + expect(contentTypesText).to.contain("/word/commentsIds.xml"); + + // Relationship is registered + const relsText = await zipFile.file("word/_rels/document.xml.rels")?.async("text"); + expect(relsText).to.contain("http://schemas.microsoft.com/office/2016/09/relationships/commentsIds"); + expect(relsText).to.contain("commentsIds.xml"); + }); + + it("should include commentsIds.xml mapping paraId to durableId for threaded comments", { timeout: 99999999 }, async () => { + const file = new File({ + sections: [], + comments: { + children: [ + { id: 0, children: [new Paragraph("parent")], durableId: "11112222" }, + { id: 1, children: [new Paragraph("reply")], parentId: 0, durableId: "33334444" }, + ], + }, + }); + const zipFile = compiler.compile(file); + const fileNames = Object.keys(zipFile.files).map((f) => zipFile.files[f].name); + + expect(fileNames).to.include("word/commentsIds.xml"); + expect(fileNames).to.include("word/commentsExtended.xml"); + + const commentsIdsText = await zipFile.file("word/commentsIds.xml")?.async("text"); + expect(commentsIdsText).to.contain('w16cid:paraId="00000001"'); + expect(commentsIdsText).to.contain('w16cid:durableId="11112222"'); + expect(commentsIdsText).to.contain('w16cid:paraId="00000002"'); + expect(commentsIdsText).to.contain('w16cid:durableId="33334444"'); + }); + it("should call the format method X times equalling X files to be formatted", () => { // This test is required because before, there was a case where Document was formatted twice, which was inefficient // This also caused issues such as running prepForXml multiple times as format() was ran multiple times. diff --git a/src/export/packer/next-compiler.ts b/src/export/packer/next-compiler.ts index 96f7130a01..f4bb25668f 100644 --- a/src/export/packer/next-compiler.ts +++ b/src/export/packer/next-compiler.ts @@ -75,6 +75,8 @@ type IXmlifyedFileMapping = { readonly CommentsRelationships?: IXmlifyedFile; /** Comments extended for reply threading (word/commentsExtended.xml) */ readonly CommentsExtended?: IXmlifyedFile; + /** Durable comment ids (word/commentsIds.xml) */ + readonly CommentsIds?: IXmlifyedFile; /** Font table (word/fontTable.xml) */ readonly FontTable?: IXmlifyedFile; /** Font table relationships (word/_rels/fontTable.xml.rels) */ @@ -661,6 +663,30 @@ export class Compiler { }, } : {}), + ...(file.CommentsIds + ? { + CommentsIds: { + data: xml( + this.formatter.format(file.CommentsIds, { + viewWrapper: { + View: file.CommentsIds, + Relationships: file.Comments.Relationships, + }, + file, + stack: [], + }), + { + indent: prettify, + declaration: { + standalone: "yes", + encoding: "UTF-8", + }, + }, + ), + path: "word/commentsIds.xml", + }, + } + : {}), FontTable: { data: xml( this.formatter.format(file.FontTable.View, { diff --git a/src/file/content-types/content-types.ts b/src/file/content-types/content-types.ts index 3132a8d443..1c0a93b40a 100644 --- a/src/file/content-types/content-types.ts +++ b/src/file/content-types/content-types.ts @@ -83,6 +83,15 @@ export class ContentTypes extends XmlComponent { ); } + /** + * Registers the commentsIds part in the content types. + */ + public addCommentsIds(): void { + this.root.push( + createOverride("application/vnd.openxmlformats-officedocument.wordprocessingml.commentsIds+xml", "/word/commentsIds.xml"), + ); + } + /** * Registers a footer part in the content types. * diff --git a/src/file/file.spec.ts b/src/file/file.spec.ts index 3dcadb2760..031f87abfc 100644 --- a/src/file/file.spec.ts +++ b/src/file/file.spec.ts @@ -443,6 +443,42 @@ describe("File", () => { expect(doc.CommentsExtended).to.be.undefined; }); + + it("should create CommentsIds when a single (non-threaded) comment has durableId", () => { + const doc = new File({ + comments: { + children: [{ id: 0, children: [new Paragraph("comment")], durableId: "12AB34CD" }], + }, + sections: [], + }); + + expect(doc.CommentsIds).to.not.be.undefined; + }); + + it("should create CommentsIds when threaded comments have durableId", () => { + const doc = new File({ + comments: { + children: [ + { id: 0, children: [new Paragraph("parent")], durableId: "11112222" }, + { id: 1, children: [new Paragraph("reply")], parentId: 0, durableId: "33334444" }, + ], + }, + sections: [], + }); + + expect(doc.CommentsIds).to.not.be.undefined; + }); + + it("should not create CommentsIds when no durableId", () => { + const doc = new File({ + comments: { + children: [{ id: 0, children: [new Paragraph("comment")] }], + }, + sections: [], + }); + + expect(doc.CommentsIds).to.be.undefined; + }); }); describe("#numbering", () => { diff --git a/src/file/file.ts b/src/file/file.ts index 29c03f7a0d..75057dbb55 100644 --- a/src/file/file.ts +++ b/src/file/file.ts @@ -22,7 +22,7 @@ import { HeaderWrapper, type IDocumentHeader } from "./header-wrapper"; import { Media } from "./media"; import { Numbering } from "./numbering"; import { Comments } from "./paragraph/run/comment-run"; -import { CommentsExtended } from "./paragraph/run/comments-extended"; +import { CommentsExtended, CommentsIds } from "./paragraph/run/comments-extended"; import { Relationships } from "./relationships"; import { Settings } from "./settings"; import { Styles } from "./styles"; @@ -167,6 +167,8 @@ export class File { private readonly comments: Comments; /** Extended comment data for reply threading and resolved state (word/commentsExtended.xml). */ private readonly commentsExtended?: CommentsExtended; + /** Durable comment id mapping (word/commentsIds.xml). */ + private readonly commentsIds?: CommentsIds; private readonly fontWrapper: FontWrapper; public constructor(options: IPropertiesOptions) { @@ -184,6 +186,10 @@ export class File { if (this.comments.ThreadData) { this.commentsExtended = new CommentsExtended(this.comments.ThreadData); } + // Build commentsIds.xml when comments carry a durableId + if (this.comments.CommentIdsData) { + this.commentsIds = new CommentsIds(this.comments.CommentIdsData); + } this.fileRelationships = new Relationships(); this.customProperties = new CustomProperties(options.customProperties ?? []); this.appProperties = new AppProperties(); @@ -392,6 +398,16 @@ export class File { ); this.contentTypes.addCommentsExtended(); } + + if (this.commentsIds) { + this.documentWrapper.Relationships.addRelationship( + // eslint-disable-next-line functional/immutable-data + this.currentRelationshipId++, + "http://schemas.microsoft.com/office/2016/09/relationships/commentsIds", + "commentsIds.xml", + ); + this.contentTypes.addCommentsIds(); + } } public get Document(): DocumentWrapper { @@ -459,6 +475,11 @@ export class File { return this.commentsExtended; } + /** Durable comment id part. Undefined when no comment carries a durableId. */ + public get CommentsIds(): CommentsIds | undefined { + return this.commentsIds; + } + public get FontTable(): FontWrapper { return this.fontWrapper; } diff --git a/src/file/paragraph/run/comment-run.spec.ts b/src/file/paragraph/run/comment-run.spec.ts index 1efb74f8b8..5ff1546132 100644 --- a/src/file/paragraph/run/comment-run.spec.ts +++ b/src/file/paragraph/run/comment-run.spec.ts @@ -272,6 +272,94 @@ describe("Comments", () => { const serialized = JSON.stringify(tree); expect(serialized).to.not.contain("w14:paraId"); }); + + it("should not have CommentIdsData when no durableId is used", () => { + const component = new Comments({ + children: [{ id: 0, children: [new Paragraph("comment")], date: new Date("1999-01-01T00:00:00.000Z") }], + }); + expect(component.CommentIdsData).to.be.undefined; + }); + + it("should produce CommentIdsData for a single (non-threaded) comment with durableId", () => { + const component = new Comments({ + children: [ + { + id: 0, + children: [new Paragraph("comment")], + date: new Date("1999-01-01T00:00:00.000Z"), + durableId: "12AB34CD", + }, + ], + }); + expect(component.CommentIdsData).to.deep.equal([{ paraId: "00000001", durableId: "12AB34CD" }]); + }); + + it("should fall back to paraId as durableId when durableId is omitted on a durable-bearing document", () => { + const component = new Comments({ + children: [ + { + id: 0, + children: [new Paragraph("with-durable")], + date: new Date("1999-01-01T00:00:00.000Z"), + durableId: "AAAA1111", + }, + { + id: 1, + children: [new Paragraph("without-durable")], + date: new Date("1999-01-01T00:00:00.000Z"), + }, + ], + }); + expect(component.CommentIdsData).to.deep.equal([ + { paraId: "00000001", durableId: "AAAA1111" }, + { paraId: "00000002", durableId: "00000002" }, + ]); + }); + + it("should produce both ThreadData and CommentIdsData for threaded comments with durableId", () => { + const component = new Comments({ + children: [ + { + id: 0, + children: [new Paragraph("parent")], + date: new Date("1999-01-01T00:00:00.000Z"), + durableId: "11112222", + }, + { + id: 1, + children: [new Paragraph("reply")], + date: new Date("1999-01-01T00:00:00.000Z"), + parentId: 0, + durableId: "33334444", + }, + ], + }); + expect(component.ThreadData).to.deep.equal([ + { paraId: "00000001", parentParaId: undefined, done: undefined }, + { paraId: "00000002", parentParaId: "00000001", done: undefined }, + ]); + expect(component.CommentIdsData).to.deep.equal([ + { paraId: "00000001", durableId: "11112222" }, + { paraId: "00000002", durableId: "33334444" }, + ]); + }); + + it("should inject w14:paraId when only durableId (no threading) is used", () => { + const component = new Comments({ + children: [ + { + id: 0, + children: [new Paragraph("comment")], + date: new Date("1999-01-01T00:00:00.000Z"), + durableId: "12AB34CD", + }, + ], + }); + const tree = new Formatter().format(component); + const serialized = JSON.stringify(tree); + expect(serialized).to.contain('"w14:paraId":"00000001"'); + expect(serialized).to.contain('"w14:textId":"00000001"'); + }); }); }); diff --git a/src/file/paragraph/run/comment-run.ts b/src/file/paragraph/run/comment-run.ts index 226335c86f..1870b3f498 100644 --- a/src/file/paragraph/run/comment-run.ts +++ b/src/file/paragraph/run/comment-run.ts @@ -37,6 +37,8 @@ export type ICommentOptions = { readonly parentId?: number; /** Whether the comment thread is marked as resolved */ readonly resolved?: boolean; + /** Stable comment id written to word/commentsIds.xml (w16cid:durableId). Preserved by Word across edits, unlike w:id. */ + readonly durableId?: string; }; /** @@ -329,6 +331,16 @@ export type ICommentThreadData = { readonly done?: boolean; }; +/** + * Comment id data for a single comment, used to build commentsIds.xml. + */ +export type ICommentIdData = { + /** 8-character uppercase hex identifier linking to w14:paraId on the comment's paragraph */ + readonly paraId: string; + /** Stable comment id preserved by Word across edits (maps to w16cid:durableId) */ + readonly durableId: string; +}; + /** * Converts a comment ID to a deterministic 8-character uppercase hex paraId. */ @@ -375,6 +387,7 @@ export const commentIdToParaId = (id: number): string => (id + 1).toString(16).t export class Comments extends XmlComponent { private readonly relationships: Relationships; private readonly threadData?: readonly ICommentThreadData[]; + private readonly commentIdsData?: readonly ICommentIdData[]; public constructor({ children }: ICommentsOptions) { super("w:comments"); @@ -415,22 +428,33 @@ export class Comments extends XmlComponent { }), ); - // When any comment uses parentId, generate paraIds for all comments - // and build threadData for CommentsExtended (commentsExtended.xml) + // Generate paraIds for all comments when reply threading is active (parentId) + // or any comment carries a durableId. paraIds back both commentsExtended.xml + // (threading) and commentsIds.xml (durable ids). const hasThreading = children.some((child) => child.parentId !== undefined); + const hasDurableIds = children.some((child) => child.durableId !== undefined); - if (hasThreading) { + if (hasThreading || hasDurableIds) { const idToParaId = new Map(children.map((child) => [child.id, commentIdToParaId(child.id)])); for (const child of children) { this.root.push(new Comment(child, idToParaId.get(child.id))); } - this.threadData = children.map((child) => ({ - paraId: idToParaId.get(child.id)!, - parentParaId: child.parentId !== undefined ? idToParaId.get(child.parentId) : undefined, - done: child.resolved, - })); + if (hasThreading) { + this.threadData = children.map((child) => ({ + paraId: idToParaId.get(child.id)!, + parentParaId: child.parentId !== undefined ? idToParaId.get(child.parentId) : undefined, + done: child.resolved, + })); + } + + if (hasDurableIds) { + this.commentIdsData = children.map((child) => ({ + paraId: idToParaId.get(child.id)!, + durableId: child.durableId ?? idToParaId.get(child.id)!, + })); + } } else { for (const child of children) { this.root.push(new Comment(child)); @@ -448,4 +472,9 @@ export class Comments extends XmlComponent { public get ThreadData(): readonly ICommentThreadData[] | undefined { return this.threadData; } + + /** Comment id data for commentsIds.xml, or undefined when no comments carry a durableId. */ + public get CommentIdsData(): readonly ICommentIdData[] | undefined { + return this.commentIdsData; + } } diff --git a/src/file/paragraph/run/comments-extended.spec.ts b/src/file/paragraph/run/comments-extended.spec.ts index 0638ca1da0..8da7663f05 100644 --- a/src/file/paragraph/run/comments-extended.spec.ts +++ b/src/file/paragraph/run/comments-extended.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { Formatter } from "@export/formatter"; -import { CommentsExtended } from "./comments-extended"; +import { CommentsExtended, CommentsIds } from "./comments-extended"; describe("CommentsExtended", () => { describe("#constructor()", () => { @@ -67,3 +67,43 @@ describe("CommentsExtended", () => { }); }); }); + +describe("CommentsIds", () => { + describe("#constructor()", () => { + it("should create with a single comment id (non-threaded)", () => { + const component = new CommentsIds([{ paraId: "00000001", durableId: "12AB34CD" }]); + const tree = new Formatter().format(component); + expect(tree).to.deep.equal({ + "w16cid:commentsIds": [ + { + _attr: { + "xmlns:w16cid": "http://schemas.microsoft.com/office/word/2016/wordml/cid", + "xmlns:mc": "http://schemas.openxmlformats.org/markup-compatibility/2006", + "mc:Ignorable": "w16cid", + }, + }, + { + "w16cid:commentId": { _attr: { "w16cid:paraId": "00000001", "w16cid:durableId": "12AB34CD" } }, + }, + ], + }); + }); + + it("should map paraId to durableId for multiple (threaded) comments", () => { + const component = new CommentsIds([ + { paraId: "00000001", durableId: "11112222" }, + { paraId: "00000002", durableId: "33334444" }, + ]); + const tree = new Formatter().format(component); + const entries = (tree["w16cid:commentsIds"] as readonly unknown[]).slice(1); + expect(entries).to.deep.equal([ + { + "w16cid:commentId": { _attr: { "w16cid:paraId": "00000001", "w16cid:durableId": "11112222" } }, + }, + { + "w16cid:commentId": { _attr: { "w16cid:paraId": "00000002", "w16cid:durableId": "33334444" } }, + }, + ]); + }); + }); +}); diff --git a/src/file/paragraph/run/comments-extended.ts b/src/file/paragraph/run/comments-extended.ts index 5046d97461..9d3ec22b46 100644 --- a/src/file/paragraph/run/comments-extended.ts +++ b/src/file/paragraph/run/comments-extended.ts @@ -10,7 +10,7 @@ */ import { XmlAttributeComponent, XmlComponent } from "@file/xml-components"; -import type { ICommentThreadData } from "./comment-run"; +import type { ICommentIdData, ICommentThreadData } from "./comment-run"; /** * @internal @@ -94,3 +94,80 @@ export class CommentsExtended extends XmlComponent { } } } + +/** + * @internal + */ +class CommentIdAttributes extends XmlAttributeComponent<{ + readonly paraId: string; + readonly durableId: string; +}> { + protected readonly xmlKeys = { + paraId: "w16cid:paraId", + durableId: "w16cid:durableId", + }; +} + +/** + * @internal + */ +class CommentId extends XmlComponent { + public constructor(options: ICommentIdData) { + super("w16cid:commentId"); + + this.root.push( + new CommentIdAttributes({ + paraId: options.paraId, + durableId: options.durableId, + }), + ); + } +} + +/** + * @internal + */ +class CommentsIdsAttributes extends XmlAttributeComponent<{ + readonly "xmlns:w16cid"?: string; + readonly "xmlns:mc"?: string; + readonly "mc:Ignorable"?: string; +}> { + protected readonly xmlKeys = { + "xmlns:w16cid": "xmlns:w16cid", + "xmlns:mc": "xmlns:mc", + "mc:Ignorable": "mc:Ignorable", + }; +} + +/** + * Represents the commentsIds part (word/commentsIds.xml). + * + * Contains w16cid:commentId elements that map each comment's volatile paraId + * to a stable w16cid:durableId preserved by Word across edits. + * + * ## XSD Schema (wml-cid.xsd) + * ```xml + * + * + * + * + * + * ``` + */ +export class CommentsIds extends XmlComponent { + public constructor(commentIdsData: readonly ICommentIdData[]) { + super("w16cid:commentsIds"); + + this.root.push( + new CommentsIdsAttributes({ + "xmlns:w16cid": "http://schemas.microsoft.com/office/word/2016/wordml/cid", + "xmlns:mc": "http://schemas.openxmlformats.org/markup-compatibility/2006", + "mc:Ignorable": "w16cid", + }), + ); + + for (const data of commentIdsData) { + this.root.push(new CommentId(data)); + } + } +} diff --git a/src/file/relationships/relationship/relationship.ts b/src/file/relationships/relationship/relationship.ts index 7d58451c11..201c1d98c0 100644 --- a/src/file/relationships/relationship/relationship.ts +++ b/src/file/relationships/relationship/relationship.ts @@ -25,6 +25,7 @@ export type RelationshipType = | "http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes" | "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" | "http://schemas.microsoft.com/office/2011/relationships/commentsExtended" + | "http://schemas.microsoft.com/office/2016/09/relationships/commentsIds" | "http://schemas.openxmlformats.org/officeDocument/2006/relationships/font"; /** From c74aa3237b5a5fe5a93319ed7a67eee0c949f157 Mon Sep 17 00:00:00 2001 From: Alex Casillas Date: Tue, 23 Jun 2026 11:05:49 +0100 Subject: [PATCH 2/2] test(comments): assert commentsIds XML shape and cover mixed durable/non-durable --- src/export/packer/next-compiler.spec.ts | 24 ++++++++++++++++++++++++ src/file/file.spec.ts | 7 +++++++ 2 files changed, 31 insertions(+) diff --git a/src/export/packer/next-compiler.spec.ts b/src/export/packer/next-compiler.spec.ts index d75b781e88..63b3da5f2c 100644 --- a/src/export/packer/next-compiler.spec.ts +++ b/src/export/packer/next-compiler.spec.ts @@ -218,6 +218,30 @@ describe("Compiler", () => { expect(commentsIdsText).to.contain('w16cid:durableId="33334444"'); }); + it("should emit commentsIds.xml falling back to paraId for comments without a durableId", { timeout: 99999999 }, async () => { + const file = new File({ + sections: [], + comments: { + children: [ + { id: 0, children: [new Paragraph("with durable")], durableId: "12AB34CD" }, + { id: 1, children: [new Paragraph("without durable")] }, + ], + }, + }); + const zipFile = compiler.compile(file); + const fileNames = Object.keys(zipFile.files).map((f) => zipFile.files[f].name); + + expect(fileNames).to.include("word/commentsIds.xml"); + + const commentsIdsText = await zipFile.file("word/commentsIds.xml")?.async("text"); + // Comment WITH a durableId keeps its durableId (paraId 00000001 for id 0) + expect(commentsIdsText).to.contain('w16cid:paraId="00000001"'); + expect(commentsIdsText).to.contain('w16cid:durableId="12AB34CD"'); + // Comment WITHOUT a durableId falls back to its generated paraId (00000002 for id 1) + expect(commentsIdsText).to.contain('w16cid:paraId="00000002"'); + expect(commentsIdsText).to.contain('w16cid:durableId="00000002"'); + }); + it("should call the format method X times equalling X files to be formatted", () => { // This test is required because before, there was a case where Document was formatted twice, which was inefficient // This also caused issues such as running prepForXml multiple times as format() was ran multiple times. diff --git a/src/file/file.spec.ts b/src/file/file.spec.ts index 031f87abfc..89bd7dcc7f 100644 --- a/src/file/file.spec.ts +++ b/src/file/file.spec.ts @@ -453,6 +453,13 @@ describe("File", () => { }); expect(doc.CommentsIds).to.not.be.undefined; + + const tree = new Formatter().format(doc.CommentsIds!); + const root = tree["w16cid:commentsIds"] as readonly Record }>[]; + const commentId = root.find((entry) => "w16cid:commentId" in entry)?.["w16cid:commentId"]; + expect(commentId).to.not.be.undefined; + expect(commentId!._attr["w16cid:durableId"]).to.equal("12AB34CD"); + expect(commentId!._attr["w16cid:paraId"]).to.be.a("string"); }); it("should create CommentsIds when threaded comments have durableId", () => {