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
2 changes: 1 addition & 1 deletion REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,7 @@ docx track-changes apply doc.docx --accept rev0 --reject rev1 # finalize: acce

**JSX for emitters.** Constructing OOXML fragments imperatively (`<w:rPr>` β†’ `<w:b/>` β†’ `<w:color w:val="800080"/>`) is verbose, so fresh XML is authored in JSX with a custom factory: `<w.rPr><w.b/><w.color w-val="800080"/></w.rPr>` becomes the right `XmlNode` tree.

**Span-aware comments & hyperlinks.** `comments add --at p3:5-20` (and `hyperlinks add`) find the runs containing offsets 5 and 20, split them at the boundaries (preserving `<w:rPr>` on both halves), and insert markers between the slices. Comments authored by older tools that lack `w14:paraId` (required by `commentsExtended.xml`) get a fresh paraId injected automatically on resolve/reply.
**Span-aware comments & hyperlinks.** `comments add --at p3:5-20` (and `hyperlinks add`) find the runs containing offsets 5 and 20, split them at the boundaries (preserving `<w:rPr>` on both halves), and insert markers between the slices. Comments authored by older tools that lack `w14:paraId` (required by `commentsExtended.xml`) get a fresh paraId injected automatically on resolve/reply. When a Word-saved file carries Word's comment sidecars (`commentsExtended.xml`, `commentsIds.xml`, `commentsExtensible.xml`), every `comments add`/`reply`/`delete` adds or removes the matching entry in each one, so Word never offers to repair the file. A file without them stays without them.

**Tracked changes.** With `<w:trackRevisions/>` set, `insert`/`edit`/`delete`/`replace` emit native `<w:ins>`/`<w:del>` (attributed via `--author`, `$DOCX_AUTHOR`, or `Reviewer`); pass `--track` to one of those commands (or the `tables` verbs / `images delete`) to track just that invocation even when the doc toggle is off. `edit --at pN --text` runs a word-level diff so unchanged words keep their formatting and only changed words are wrapped β€” the same shape Word produces mid-tracking. `accept`/`reject` handle run-level ins/del/moveFrom/moveTo, `sectPrChange`, paragraph-mark ins/del, and the table-structural revisions (rowIns/rowDel, cellIns/cellDel, tblGridChange, tcPrChange). OOXML has no tracked-change construct for hyperlink edits or image swaps, so under tracking those emit a `[docx-cli]` audit comment instead of a fake revision (image _deletion_ is honest removal β€” it wraps a real `<w:del>`).

Expand Down
158 changes: 146 additions & 12 deletions src/core/ast/document/comments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ import type { RelationshipsView } from "./relationships";

const COMMENTS_PART_NAME = "word/comments.xml";
const COMMENTS_EXT_PART_NAME = "word/commentsExtended.xml";
// Word 2016+/2018+ sidecars keyed to each comment's `w14:paraId`
// (`commentsIds`) and to the `durableId` it mints there (`commentsExtensible`).
// We never create them β€” a package without them is consistent β€” but when a
// Word-saved input carries them, every comment we add or delete must be
// mirrored, or Word reports unreadable content and renumbers every comment.
const COMMENTS_IDS_PART_NAME = "word/commentsIds.xml";
const COMMENTS_EXTENSIBLE_PART_NAME = "word/commentsExtensible.xml";
const COMMENTS_RELATIONSHIP_TYPE =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments";
const COMMENTS_EXT_RELATIONSHIP_TYPE =
Expand All @@ -25,11 +32,19 @@ const NS_W15 = "http://schemas.microsoft.com/office/word/2012/wordml";
export class CommentsView {
tree: XmlNode[];
extendedTree?: XmlNode[];
idsTree?: XmlNode[];
extensibleTree?: XmlNode[];
commentReferences: Map<string, CommentReference> = new Map();

constructor(tree: XmlNode[] = [<CommentsRoot />], extendedTree?: XmlNode[]) {
constructor(
tree: XmlNode[] = [<CommentsRoot />],
extendedTree?: XmlNode[],
durableSidecars: { idsTree?: XmlNode[]; extensibleTree?: XmlNode[] } = {},
) {
this.tree = tree;
this.extendedTree = extendedTree;
this.idsTree = durableSidecars.idsTree;
this.extensibleTree = durableSidecars.extensibleTree;
// `w14:paraId`/`w14:textId` (Word's threading keys, written on every comment
// body) are valid only when `w14` rides the root's `mc:Ignorable`. A part
// that came WITH the document β€” from Word, which often omits it here, or an
Expand All @@ -41,13 +56,17 @@ export class CommentsView {
if (root) declareW14(root);
}

/** Load this view (and its extended sidecar) from a package; returns
* undefined if the base comments part is absent. */
/** Load this view (and its extended/ids/extensible sidecars) from a
* package; returns undefined if the base comments part is absent. */
static async fromPackage(pkg: Pkg): Promise<CommentsView | undefined> {
const tree = await pkg.readPart(COMMENTS_PART_NAME);
if (!tree) return undefined;
const extendedTree = await pkg.readPart(COMMENTS_EXT_PART_NAME);
return new CommentsView(tree, extendedTree);
const [extendedTree, idsTree, extensibleTree] = await Promise.all([
pkg.readPart(COMMENTS_EXT_PART_NAME),
pkg.readPart(COMMENTS_IDS_PART_NAME),
pkg.readPart(COMMENTS_EXTENSIBLE_PART_NAME),
]);
return new CommentsView(tree, extendedTree, { idsTree, extensibleTree });
}

/** Parse a view from raw XML; returns undefined if the base input is
Expand All @@ -64,15 +83,18 @@ export class CommentsView {
);
}

/** Serialize this view's tree (and the extended sidecar if present) into
* the package's `word/comments.xml` and `word/commentsExtended.xml`. */
/** Serialize this view's tree (and each sidecar that is present) into
* the package's `word/comments.xml`, `commentsExtended.xml`,
* `commentsIds.xml` and `commentsExtensible.xml`. */
writeTo(pkg: Pkg): void {
pkg.writeText(COMMENTS_PART_NAME, XmlNode.serialize(this.tree));
if (this.extendedTree) {
pkg.writeText(
COMMENTS_EXT_PART_NAME,
XmlNode.serialize(this.extendedTree),
);
const sidecars: [string, XmlNode[] | undefined][] = [
[COMMENTS_EXT_PART_NAME, this.extendedTree],
[COMMENTS_IDS_PART_NAME, this.idsTree],
[COMMENTS_EXTENSIBLE_PART_NAME, this.extensibleTree],
];
for (const [partName, sidecarTree] of sidecars) {
if (sidecarTree) pkg.writeText(partName, XmlNode.serialize(sidecarTree));
}
}

Expand Down Expand Up @@ -288,6 +310,88 @@ export class CommentsView {
root.children.splice(insertIndex, 0, comment);
}

/** Register a freshly appended comment, keyed by its (last) paragraph's
* `paraId`, in every sidecar the package ALREADY carries β€” the shape Word
* writes: a `<w15:commentEx done="0">` (skipped for a `threaded` reply,
* whose lens places its own entry with `paraIdParent`), a
* `<w16cid:commentId>` with a freshly minted `durableId`, and a
* `<w16cex:commentExtensible>` carrying that `durableId` + `dateUtc`. An
* absent sidecar stays absent. */
registerSidecarEntries(
paraId: string,
date: string,
options: { threaded: boolean },
): void {
const extendedRoot = sidecarRoot(this.extendedTree, "w15:commentsEx");
if (extendedRoot && !options.threaded) {
extendedRoot.children.push(
new XmlNode("w15:commentEx", {
"w15:paraId": paraId,
"w15:done": "0",
}),
);
}
const idsRoot = sidecarRoot(this.idsTree, "w16cid:commentsIds");
if (!idsRoot) return;
const durableId = mintDurableId(idsRoot);
idsRoot.children.push(
new XmlNode("w16cid:commentId", {
"w16cid:paraId": paraId,
"w16cid:durableId": durableId,
}),
);
const extensibleRoot = sidecarRoot(
this.extensibleTree,
"w16cex:commentsExtensible",
);
if (!extensibleRoot) return;
const dateUtc = toDateUtc(date);
extensibleRoot.children.push(
new XmlNode("w16cex:commentExtensible", {
"w16cex:durableId": durableId,
...(dateUtc ? { "w16cex:dateUtc": dateUtc } : {}),
}),
);
}

/** Drop every sidecar entry keyed to a deleted comment's `paraId`: its
* `<w15:commentEx>`, its `<w16cid:commentId>`, and the
* `<w16cex:commentExtensible>` sharing that entry's `durableId`. */
removeSidecarEntries(paraId: string): void {
const extendedRoot = sidecarRoot(this.extendedTree, "w15:commentsEx");
if (extendedRoot) {
extendedRoot.children = extendedRoot.children.filter(
(child) =>
!(
child.tag === "w15:commentEx" &&
child.getAttribute("w15:paraId") === paraId
),
);
}
const idsRoot = sidecarRoot(this.idsTree, "w16cid:commentsIds");
if (!idsRoot) return;
const removedDurableIds = new Set<string>();
idsRoot.children = idsRoot.children.filter((child) => {
if (child.tag !== "w16cid:commentId") return true;
if (child.getAttribute("w16cid:paraId") !== paraId) return true;
const durableId = child.getAttribute("w16cid:durableId");
if (durableId) removedDurableIds.add(durableId);
return false;
});
const extensibleRoot = sidecarRoot(
this.extensibleTree,
"w16cex:commentsExtensible",
);
if (!extensibleRoot) return;
extensibleRoot.children = extensibleRoot.children.filter(
(child) =>
!(
child.tag === "w16cex:commentExtensible" &&
removedDurableIds.has(child.getAttribute("w16cex:durableId") ?? "")
),
);
}

/** Parse this part (plus the extended sidecar) into the AST `Comment[]` the
* reader exposes on `Body.comments`, and populate `commentReferences` for
* mutators. `anchors` are the span ranges the body walk computed from
Expand Down Expand Up @@ -421,6 +525,36 @@ function lastParagraph(comment: XmlNode | undefined): XmlNode | undefined {
return comment?.findChildren("w:p").at(-1);
}

function sidecarRoot(
tree: XmlNode[] | undefined,
rootTag: string,
): XmlNode | undefined {
return tree ? XmlNode.findRoot(tree, rootTag) : undefined;
}

/** Mint a `w16cid:durableId` unique within the part. MS-DOCX bounds it to
* (0, 0x7FFFFFFF) β€” the paraId range minus its top value β€” so reuse the
* paraId minter (and its `DOCX_CLI_PARA_ID_SEED` determinism seam). */
function mintDurableId(idsRoot: XmlNode): string {
const taken = new Set(
idsRoot.children.map((child) =>
child.getAttribute("w16cid:durableId")?.toUpperCase(),
),
);
for (;;) {
const candidate = generateParaId();
if (candidate !== "7FFFFFFF" && !taken.has(candidate)) return candidate;
}
}

/** Word's `w16cex:dateUtc` is a whole-second UTC `xsd:dateTime`. Undefined
* (attribute omitted β€” it's optional) when `date` doesn't parse. */
function toDateUtc(date: string): string | undefined {
const parsed = new Date(date);
if (Number.isNaN(parsed.getTime())) return undefined;
return parsed.toISOString().replace(/\.\d{3}Z$/, "Z");
}

/** Mint a fresh `w14:paraId` value. Word writes 8-char uppercase hex; we
* match. Per MS-DOCX, a paraId MUST be greater than 0x00000000 and less
* than 0x80000000 β€” Word silently re-keys out-of-range ids on open, which
Expand Down
19 changes: 6 additions & 13 deletions src/core/comments/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,8 @@ export class Comments {
}

/** Remove every id in `ids`: splice the `<w:comment>` body, prune any
* `<w15:commentEx>` entry keyed to its paraId, and strip
* sidecar entry keyed to its paraId (`commentsExtended`/`commentsIds`/
* `commentsExtensible`), and strip
* `<w:commentRangeStart>` / `<w:commentRangeEnd>` /
* `<w:commentReference>` markers from the body. Deleting a thread parent
* cascades through its replies so none is left with dangling body markers
Expand Down Expand Up @@ -325,18 +326,7 @@ export class Comments {
const index = root.children.indexOf(node);
if (index !== -1) root.children.splice(index, 1);

if (paraId && view.extendedTree) {
const extRoot = XmlNode.findRoot(view.extendedTree, "w15:commentsEx");
if (extRoot) {
extRoot.children = extRoot.children.filter(
(child) =>
!(
child.tag === "w15:commentEx" &&
child.getAttribute("w15:paraId") === paraId
),
);
}
}
if (paraId) view.removeSidecarEntries(paraId);

removeCommentMarkers(this.document.documentTree, numericId);
}
Expand Down Expand Up @@ -392,6 +382,9 @@ export class Comments {
}}
/>
);
commentsView.registerSidecarEntries(paraId, date, {
threaded: Boolean(options?.threadParentNumericId),
});
if (options?.threadParentNumericId) {
commentsView.insertReplyAfter(options.threadParentNumericId, comment);
return;
Expand Down
4 changes: 4 additions & 0 deletions src/core/raw/parts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,10 @@ function partReplaceRoute(name: string): PartReplaceRoute | { hint: string } {
"Comment ids pair with body range marks β€” use docx comments add/reply/resolve.",
"word/commentsExtended.xml":
"Threading pairs with comments.xml β€” use docx comments add/reply/resolve.",
"word/commentsIds.xml":
"Durable ids pair with comments.xml β€” use docx comments add/reply/delete.",
"word/commentsExtensible.xml":
"Durable ids pair with comments.xml β€” use docx comments add/reply/delete.",
"docProps/core.xml": "Core properties are managed by docx-cli.",
"[Content_Types].xml":
"Content types are registered automatically when parts are added.",
Expand Down
120 changes: 120 additions & 0 deletions tests/cli/comments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1391,3 +1391,123 @@ describe("docx comments reply --batch", () => {
expect(result.exitCode).toBe(2);
});
});

describe("docx comments β€” Word's durable-id sidecars (issue #33)", () => {
// Word 365 writes three sidecars beside comments.xml, each with ONE entry per
// comment: commentsExtended (`w15:commentEx`, keyed by the comment's
// `w14:paraId`), commentsIds (`w16cid:commentId`, paraId β†’ durableId) and
// commentsExtensible (`w16cex:commentExtensible`, keyed by durableId). A
// comment missing from them makes Word offer to repair the file β€” and the
// repair renumbers every comment.
const NS_MC = "http://schemas.openxmlformats.org/markup-compatibility/2006";

async function wordShapedDocument(): Promise<string> {
const workspace = tempWorkspace("comments-sidecars");
const docPath = join(workspace, "word-shaped.docx");
await runCli("create", docPath, "--text", "First clause. Second clause.");
await runCli("comments", "add", docPath, "--at", "p0", "--text", "Seed");
const pkg = await Pkg.open(docPath);
const paraId = (await pkg.readText("word/comments.xml")).match(
/w14:paraId="([0-9A-F]{8})"/,
)?.[1];
expect(paraId).toBeDefined();
pkg.writeText(
"word/commentsExtended.xml",
`<w15:commentsEx xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" xmlns:mc="${NS_MC}" mc:Ignorable="w15"><w15:commentEx w15:paraId="${paraId}" w15:done="0"/></w15:commentsEx>`,
);
pkg.writeText(
"word/commentsIds.xml",
`<w16cid:commentsIds xmlns:w16cid="http://schemas.microsoft.com/office/word/2016/wordml/cid" xmlns:mc="${NS_MC}" mc:Ignorable="w16cid"><w16cid:commentId w16cid:paraId="${paraId}" w16cid:durableId="1A2B3C4D"/></w16cid:commentsIds>`,
);
pkg.writeText(
"word/commentsExtensible.xml",
`<w16cex:commentsExtensible xmlns:w16cex="http://schemas.microsoft.com/office/word/2018/wordml/cex" xmlns:mc="${NS_MC}" mc:Ignorable="w16cex"><w16cex:commentExtensible w16cex:durableId="1A2B3C4D" w16cex:dateUtc="2024-01-01T00:00:00Z"/></w16cex:commentsExtensible>`,
);
await pkg.save();
return docPath;
}

async function sidecarState(docPath: string) {
const all = (xml: string, pattern: RegExp) =>
[...xml.matchAll(pattern)].map((match) => match[1]);
const comments = await readPart(docPath, "word/comments.xml");
const ids = await readPart(docPath, "word/commentsIds.xml");
return {
commentParaIds: all(
comments,
/<w:comment [^>]*>.*?w14:paraId="([0-9A-F]{8})"/gs,
),
extendedParaIds: all(
await readPart(docPath, "word/commentsExtended.xml"),
/<w15:commentEx [^>]*w15:paraId="([0-9A-F]{8})"/g,
),
idsParaIds: all(ids, /w16cid:paraId="([0-9A-F]{8})"/g),
idsDurableIds: all(ids, /w16cid:durableId="([0-9A-F]{8})"/g),
extensibleDurableIds: all(
await readPart(docPath, "word/commentsExtensible.xml"),
/w16cex:durableId="([0-9A-F]{8})"/g,
),
};
}

function expectConsistent(state: Awaited<ReturnType<typeof sidecarState>>) {
const sorted = (values: (string | undefined)[]) => [...values].sort();
expect(sorted(state.extendedParaIds)).toEqual(sorted(state.commentParaIds));
expect(sorted(state.idsParaIds)).toEqual(sorted(state.commentParaIds));
expect(sorted(state.extensibleDurableIds)).toEqual(
sorted(state.idsDurableIds),
);
expect(new Set(state.idsDurableIds).size).toBe(state.idsDurableIds.length);
}

test("add, reply and delete keep every sidecar 1:1 with comments.xml", async () => {
const docPath = await wordShapedDocument();

await runCli(
"comments",
"add",
docPath,
"--anchor",
"First",
"--text",
"A",
);
await runCli("comments", "add", docPath, "--at", "p0", "--text", "B");
let state = await sidecarState(docPath);
expect(state.commentParaIds).toHaveLength(3);
expectConsistent(state);
expect(state.idsDurableIds).toContain("1A2B3C4D");

await runCli("comments", "reply", docPath, "--at", "c1", "--text", "R");
state = await sidecarState(docPath);
expect(state.commentParaIds).toHaveLength(4);
expectConsistent(state);

// Deleting the thread root cascades to its reply β€” both leave every part.
await runCli("comments", "delete", docPath, "--at", "c1");
state = await sidecarState(docPath);
expect(state.commentParaIds).toHaveLength(2);
expectConsistent(state);

await runCli("comments", "delete", docPath, "--at", "c0");
state = await sidecarState(docPath);
expect(state.idsDurableIds).not.toContain("1A2B3C4D");
expectConsistent(state);
expect((await runCli("validate", docPath)).exitCode).toBe(0);
});

test("a package without the sidecars stays without them", async () => {
const workspace = tempWorkspace("comments-no-sidecars");
const docPath = join(workspace, "plain.docx");
await runCli("create", docPath, "--text", "Clause.");
await runCli("comments", "add", docPath, "--at", "p0", "--text", "A");
const pkg = await Pkg.open(docPath);
for (const part of [
"word/commentsExtended.xml",
"word/commentsIds.xml",
"word/commentsExtensible.xml",
]) {
expect(await pkg.readPart(part)).toBeUndefined();
}
});
});
Loading