Skip to content
Open
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
7 changes: 3 additions & 4 deletions packages/docx-core/SUPPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ The "OOXML revision element" column uses ECMA-376 element names from the tracked
| `layout.ts` — `setTableRowHeight` | `packages/docx-core/src/primitives/layout.ts` | `w:trPrChange` | Row geometry changes belong under row-property revisions. **Verified by [120.8] (#143) regression test.** |
| `layout.ts` — `setTableCellPadding` | `packages/docx-core/src/primitives/layout.ts` | `w:tcPrChange` | Cell padding changes belong under cell-property revisions. **Verified by [120.8] (#143) regression test.** |
| `comments.ts` — `addComment` | `packages/docx-core/src/primitives/comments.ts` | `w:ins` | The comment anchor inserted into the body story and the initial comment text are revisionable content. Companion package bootstrap is listed in Table B. **Verified by [120.8] (#143) regression test.** |
| `comments.ts` — `addCommentReply` | `packages/docx-core/src/primitives/comments.ts` | *no body markup (see #174)* | Replies are side-part-only operations (no body anchor exists per-reply); the implementation accepts `ctx` as plumbing only. Classification revisit is tracked as **#174**. **Verified by [120.8] (#143) regression test (locks current side-part-only behavior).** |
| `comments.ts` — `deleteComment` | `packages/docx-core/src/primitives/comments.ts` | `w:del` | Deleting a comment removes body anchors and comment/reply text. Cleanup of `commentsExtended.xml` remains the Table B companion. **Verified by [120.8] (#143) regression test.** |
| `footnotes.ts` — `addFootnote` | `packages/docx-core/src/primitives/footnotes.ts` | `w:ins` | The inserted `w:footnoteReference` in the body story and the new footnote text both belong to the revisionable surface. Companion package bootstrap is listed in Table B. **Verified by [120.8] (#143) regression test.** |
| `footnotes.ts` — `updateFootnoteText` | `packages/docx-core/src/primitives/footnotes.ts` | `w:ins`, `w:del` | Updating note text is a content revision inside the footnote body. **Verified by [120.8] (#143) regression test.** |
Expand All @@ -42,7 +41,7 @@ Use the alternate contract below whenever a primitive/tool mutates relationships
| Primitive / tool | Source path | What it mutates | Alternate contract |
| --- | --- | --- | --- |
| `comments.ts` — `bootstrapCommentParts` | `packages/docx-core/src/primitives/comments.ts` | Creates `word/comments.xml`, `word/commentsExtended.xml`, and `word/people.xml`; updates `word/_rels/document.xml.rels`; updates `[Content_Types].xml`. Companion body/comment-text rows are in Table A. | Recorded in the session's non-revision change manifest (per #122) and surfaced in the save report. Not wrapped in OOXML revision markup. |
| `comments.ts` — threaded comment metadata (`addComment`, `addCommentReply`, `deleteComment`) | `packages/docx-core/src/primitives/comments.ts` | Maintains `commentsExtended.xml` reply graph and `people.xml` author metadata that Word needs for comments and threaded replies. `addComment` always writes author metadata to `people.xml`, even for root comments without a thread. Companion content rows are in Table A. | Recorded in the session's non-revision change manifest (per #122) and surfaced in the save report. Not wrapped in OOXML revision markup. |
| `comments.ts` — threaded comment metadata (`addComment`, `addCommentReply`, `deleteComment`) | `packages/docx-core/src/primitives/comments.ts` | Maintains `commentsExtended.xml` reply graph and `people.xml` author metadata that Word needs for comments and threaded replies. `addCommentReply` is classified here because replies are side-part metadata writes with no body anchor per reply; `addComment` always writes author metadata to `people.xml`, even for root comments without a thread. Companion root-comment/deletion content rows are in Table A. | Recorded in the session's non-revision change manifest (per #122) and surfaced in the save report. Not wrapped in OOXML revision markup. |
| `footnotes.ts` — `bootstrapFootnoteParts` | `packages/docx-core/src/primitives/footnotes.ts` | Creates `word/footnotes.xml`; updates `word/_rels/document.xml.rels`; updates `[Content_Types].xml`. Companion reference/note-text rows are in Table A. | Recorded in the session's non-revision change manifest (per #122) and surfaced in the save report. Not wrapped in OOXML revision markup. |
| `add_comment` | `packages/docx-mcp/src/tools/add_comment.ts` | Always writes author metadata to `people.xml`. May also trigger comment-part bootstrap (`comments.xml`, `commentsExtended.xml`, relationships, content types) when the package lacks comment infrastructure, and writes to `commentsExtended.xml` for threaded replies. Companion anchor/text row is in Table A. | Recorded in the session's non-revision change manifest (per #122) and surfaced in the save report. Not wrapped in OOXML revision markup. |
| `delete_comment` | `packages/docx-mcp/src/tools/delete_comment.ts` | Removes threaded-comment companion metadata from `commentsExtended.xml` alongside the Table A content deletion. | Recorded in the session's non-revision change manifest (per #122) and surfaced in the save report. Not wrapped in OOXML revision markup. |
Expand All @@ -56,7 +55,7 @@ These files are intentionally outside the revisionable-surface contract. Some pe

### `docx-core` primitive files

- `accept_changes.ts` — tracked-change consumer that accepts existing `w:ins` / `w:del` / property-change markup instead of creating new AI-authored revisions.
- `accept_changes.ts` — tracked-change consumer that accepts existing `w:ins` / `w:del` / property-change markup in `document.xml` and supported side-story parts (`footnotes.xml`, `endnotes.xml`, `comments.xml`) instead of creating new AI-authored revisions.
- `bookmarks.ts` — internal paragraph-bookmark scaffolding for stable selectors and anchor lookup, not user-visible AI content authorship.
- `document.ts` — `DocxDocument` facade that routes to lower-level primitives; the contract is defined at the delegated primitive level, not this wrapper.
- `document_view.ts` — read-only projection layer for toon/json/simple views, style discovery, and footnote marker display.
Expand All @@ -71,7 +70,7 @@ These files are intentionally outside the revisionable-surface contract. Some pe
- `namespaces.ts` — OOXML namespace constants only.
- `numbering.ts` — read-only numbering model parser and label formatter.
- `prevent_double_elevation.ts` — internal style-normalization helper for reference styles, not a user-directed AI mutation contract.
- `reject_changes.ts` — tracked-change consumer that rejects existing revisions instead of creating new ones.
- `reject_changes.ts` — tracked-change consumer that rejects existing revisions in `document.xml` and supported side-story parts (`footnotes.xml`, `endnotes.xml`, `comments.xml`) instead of creating new ones.
- `relationships.ts` — read-only relationship parser.
- `semantic_tags.ts` — string-level inline-tag helpers.
- `simplify_redlines.ts` — post-processing helper that simplifies existing tracked-change markup instead of originating it.
Expand Down
32 changes: 16 additions & 16 deletions packages/docx-core/src/primitives/accept_changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,21 +166,21 @@ const PR_CHANGE_LOCALS = [
// ── Public API ──────────────────────────────────────────────────────

/**
* Accept all tracked changes in the document body, producing a clean
* document with no revision markup.
* Accept all tracked changes in the document body or story root, producing a
* clean document with no revision markup.
*
* Mutates the Document in place (same convention as simplifyRedlines
* and mergeRuns).
*/
export function acceptChanges(doc: Document): AcceptChangesResult {
const body = doc.getElementsByTagNameNS(W_NS, 'body').item(0);
if (!body) {
const root = doc.getElementsByTagNameNS(W_NS, 'body').item(0) ?? doc.documentElement;
if (!root) {
return { insertionsAccepted: 0, deletionsAccepted: 0, movesResolved: 0, propertyChangesResolved: 0 };
}

// Phase A — Identify paragraphs to remove
const paragraphsToRemove = new Set<Element>();
const allParagraphs = collectByLocalName(body, 'p');
const allParagraphs = collectByLocalName(root, 'p');

for (const p of allParagraphs) {
// Paragraph-level deletion marker: w:p > w:pPr > w:rPr > w:del
Expand All @@ -195,26 +195,26 @@ export function acceptChanges(doc: Document): AcceptChangesResult {
}

// Phase B — Remove deletions and move sources
const deletionsAccepted = removeAllByLocalName(body, 'del');
const moveFromRemoved = removeAllByLocalName(body, 'moveFrom');
removeAllByLocalName(body, 'moveFromRangeStart');
removeAllByLocalName(body, 'moveFromRangeEnd');
removeAllByLocalName(body, 'moveToRangeStart');
removeAllByLocalName(body, 'moveToRangeEnd');
const deletionsAccepted = removeAllByLocalName(root, 'del');
const moveFromRemoved = removeAllByLocalName(root, 'moveFrom');
removeAllByLocalName(root, 'moveFromRangeStart');
removeAllByLocalName(root, 'moveFromRangeEnd');
removeAllByLocalName(root, 'moveToRangeStart');
removeAllByLocalName(root, 'moveToRangeEnd');

// Phase C — Unwrap insertions and move destinations (depth-sorted)
const insertionsAccepted = unwrapAllByLocalName(body, 'ins');
const moveToUnwrapped = unwrapAllByLocalName(body, 'moveTo');
const insertionsAccepted = unwrapAllByLocalName(root, 'ins');
const moveToUnwrapped = unwrapAllByLocalName(root, 'moveTo');

// Phase D — Remove property change records
let propertyChangesResolved = 0;
for (const localName of PR_CHANGE_LOCALS) {
propertyChangesResolved += removeAllByLocalName(body, localName);
propertyChangesResolved += removeAllByLocalName(root, localName);
}

// Phase E — Cleanup
// Strip paragraph-level revision markers from w:pPr/w:rPr
for (const p of collectByLocalName(body, 'p')) {
for (const p of collectByLocalName(root, 'p')) {
for (let i = 0; i < p.childNodes.length; i++) {
const child = p.childNodes[i]!;
if (!isW(child, 'pPr')) continue;
Expand Down Expand Up @@ -244,7 +244,7 @@ export function acceptChanges(doc: Document): AcceptChangesResult {
}

// Strip w:rsidDel attributes on remaining elements
const allElements = body.getElementsByTagNameNS(W_NS, '*');
const allElements = root.getElementsByTagNameNS(W_NS, '*');
for (let i = 0; i < allElements.length; i++) {
const el = allElements[i]!;
if (el.hasAttributeNS(W_NS, 'rsidDel')) {
Expand Down
176 changes: 165 additions & 11 deletions packages/docx-core/src/primitives/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { simplifyRedlines } from './simplify_redlines.js';
import { preventDoubleElevation } from './prevent_double_elevation.js';
import { validateDocument, type ValidateDocumentResult } from './validate_document.js';
import { acceptChanges as acceptChangesImpl, type AcceptChangesResult } from './accept_changes.js';
import { rejectChanges as rejectChangesImpl, type RejectChangesResult } from './reject_changes.js';
import {
bootstrapCommentParts,
addComment as addCommentImpl,
Expand Down Expand Up @@ -69,6 +70,89 @@ export type NormalizationResult = {
doubleElevationsFixed: number;
};

const REVISION_STORY_PART_PATHS = [
'word/footnotes.xml',
'word/endnotes.xml',
'word/comments.xml',
] as const;

function emptyAcceptChangesResult(): AcceptChangesResult {
return { insertionsAccepted: 0, deletionsAccepted: 0, movesResolved: 0, propertyChangesResolved: 0 };
}

function hasAcceptedChanges(result: AcceptChangesResult): boolean {
return (
result.insertionsAccepted > 0 ||
result.deletionsAccepted > 0 ||
result.movesResolved > 0 ||
result.propertyChangesResolved > 0
);
}

function addAcceptChangesResult(total: AcceptChangesResult, result: AcceptChangesResult): void {
total.insertionsAccepted += result.insertionsAccepted;
total.deletionsAccepted += result.deletionsAccepted;
total.movesResolved += result.movesResolved;
total.propertyChangesResolved += result.propertyChangesResolved;
}

function emptyRejectChangesResult(): RejectChangesResult {
return { insertionsRemoved: 0, deletionsRestored: 0, movesReverted: 0, propertyChangesReverted: 0 };
}

function hasRejectedChanges(result: RejectChangesResult): boolean {
return (
result.insertionsRemoved > 0 ||
result.deletionsRestored > 0 ||
result.movesReverted > 0 ||
result.propertyChangesReverted > 0
);
}

function addRejectChangesResult(total: RejectChangesResult, result: RejectChangesResult): void {
total.insertionsRemoved += result.insertionsRemoved;
total.deletionsRestored += result.deletionsRestored;
total.movesReverted += result.movesReverted;
total.propertyChangesReverted += result.propertyChangesReverted;
}

function parseWId(el: Element): number | null {
const idStr = el.getAttributeNS(OOXML.W_NS, 'id') ?? el.getAttribute('w:id');
if (!idStr) return null;
const n = parseInt(idStr, 10);
return Number.isNaN(n) ? null : n;
}

function collectLiveFootnoteRefIds(doc: Document): Set<number> {
const ids = new Set<number>();
const refs = doc.getElementsByTagNameNS(OOXML.W_NS, W.footnoteReference);
for (let i = 0; i < refs.length; i++) {
const id = parseWId(refs.item(i) as Element);
if (id !== null) ids.add(id);
}
return ids;
}

// Side-effect of accept/reject on document.xml: a body w:footnoteReference that
// lived inside a removed w:del (accept) or w:ins (reject) is gone afterwards.
// The corresponding <w:footnote w:id=N> in footnotes.xml is then unreachable —
// remove it so the side part matches the post-sweep body. Reserved separator /
// continuationSeparator entries are preserved unconditionally.
function pruneOrphanedFootnotes(footnotesDoc: Document, liveRefIds: Set<number>): number {
const entries = Array.from(footnotesDoc.getElementsByTagNameNS(OOXML.W_NS, W.footnote));
let pruned = 0;
for (const fn of entries) {
const typ = fn.getAttributeNS(OOXML.W_NS, 'type') ?? fn.getAttribute('w:type');
if (typ === W.separator || typ === W.continuationSeparator) continue;
const id = parseWId(fn);
if (id === null) continue;
if (liveRefIds.has(id)) continue;
fn.parentNode?.removeChild(fn);
pruned++;
}
return pruned;
}

export type ParagraphRef = {
id: string; // _bk_###
text: string;
Expand Down Expand Up @@ -394,21 +478,91 @@ export class DocxDocument {
}

/**
* Accept all tracked changes in the document body, producing a clean
* document with no revision markup.
* Accept all tracked changes in document.xml plus supported revisionable
* side-story parts, producing clean XML with no revision markup.
*/
acceptChanges(): AcceptChangesResult {
const result = acceptChangesImpl(this.documentXml);
if (
result.insertionsAccepted > 0 ||
result.deletionsAccepted > 0 ||
result.movesResolved > 0 ||
result.propertyChangesResolved > 0
) {
async acceptChanges(): Promise<AcceptChangesResult> {
const total = emptyAcceptChangesResult();
const bodyResult = acceptChangesImpl(this.documentXml);
addAcceptChangesResult(total, bodyResult);

// After accepting, footnotes whose body reference lived inside a removed
// w:del are orphaned. Only worth checking when the body sweep removed
// deletions (the only operation that can drop a footnoteReference).
const liveFootnoteRefIds = bodyResult.deletionsAccepted > 0
? collectLiveFootnoteRefIds(this.documentXml)
: null;

for (const partPath of REVISION_STORY_PART_PATHS) {
const xml = await this.zip.readTextOrNull(partPath);
if (!xml) continue;

const partDoc = parseXml(xml);
const partResult = acceptChangesImpl(partDoc);
addAcceptChangesResult(total, partResult);

let footnotesPruned = 0;
if (partPath === 'word/footnotes.xml' && liveFootnoteRefIds) {
footnotesPruned = pruneOrphanedFootnotes(partDoc, liveFootnoteRefIds);
}

if (hasAcceptedChanges(partResult) || footnotesPruned > 0) {
this.zip.writeText(partPath, serializeXml(partDoc));
if (partPath === 'word/footnotes.xml') {
this.footnotesXml = partDoc;
}
}
}

if (hasAcceptedChanges(total)) {
this.dirty = true;
this.documentViewCache = null;
}
return result;
return total;
}

/**
* Reject all tracked changes in document.xml plus supported revisionable
* side-story parts, restoring their pre-edit state where possible.
*/
async rejectChanges(): Promise<RejectChangesResult> {
const total = emptyRejectChangesResult();
const bodyResult = rejectChangesImpl(this.documentXml);
addRejectChangesResult(total, bodyResult);

// After rejecting, footnotes whose body reference lived inside a removed
// w:ins are orphaned. Only worth checking when the body sweep removed
// insertions (the only operation that can drop a footnoteReference).
const liveFootnoteRefIds = bodyResult.insertionsRemoved > 0
? collectLiveFootnoteRefIds(this.documentXml)
: null;

for (const partPath of REVISION_STORY_PART_PATHS) {
const xml = await this.zip.readTextOrNull(partPath);
if (!xml) continue;

const partDoc = parseXml(xml);
const partResult = rejectChangesImpl(partDoc);
addRejectChangesResult(total, partResult);

let footnotesPruned = 0;
if (partPath === 'word/footnotes.xml' && liveFootnoteRefIds) {
footnotesPruned = pruneOrphanedFootnotes(partDoc, liveFootnoteRefIds);
}

if (hasRejectedChanges(partResult) || footnotesPruned > 0) {
this.zip.writeText(partPath, serializeXml(partDoc));
if (partPath === 'word/footnotes.xml') {
this.footnotesXml = partDoc;
}
}
}

if (hasRejectedChanges(total)) {
this.dirty = true;
this.documentViewCache = null;
}
return total;
}

removeJuniorBookmarks(): number {
Expand Down
Loading
Loading