Skip to content

feat: edit and track-change footnote/endnote bodies#646

Open
jacobjove wants to merge 8 commits into
eigenpal:mainfrom
jacobjove:feat/note-serialization-378
Open

feat: edit and track-change footnote/endnote bodies#646
jacobjove wants to merge 8 commits into
eigenpal:mainfrom
jacobjove:feat/note-serialization-378

Conversation

@jacobjove
Copy link
Copy Markdown
Contributor

@jacobjove jacobjove commented May 31, 2026

What & why

#378 / #415 routed footnote and endnote rendering through the body pipeline. The save path was never updated: repackDocx copies word/footnotes.xml and word/endnotes.xml verbatim from the original archive, so any edit or tracked change made to a note body is silently dropped on save. getChanges() also walks only the document body, so tracked changes inside notes are invisible to the review pipeline.

This PR adds the save-side and review-side counterparts, symmetric across footnotes and endnotes.

Changes

  • Note-body serializer (noteSerializer.ts): serializeFootnotes / serializeEndnotes reuse the document body's serializeBlockContent, so note bodies round-trip with full fidelity — w:ins/w:del, run/paragraph properties, fields, tables — instead of a minimal hand-rolled serializer.
  • Run model: the run parser dropped <w:separator> / <w:continuationSeparator> and the in-body auto-number marks <w:footnoteRef> / <w:endnoteRef>. Added SeparatorContent and NoteRefMarkContent so note bodies round-trip losslessly (all existing run consumers use permissive defaults; typecheck clean).
  • Save path: repackDocx / repackDocxFromRaw write the note parts via serialize{Footnotes,Endnotes}ToZip. Separator notes are kept in new package.footnoteSeparators / endnoteSeparators (out of footnotes/endnotes, which feed layout/font/variable code and must stay normal-only) and re-emitted ahead of the normal notes.
  • Review: DocxReviewer.getChanges() gains opt-in includeFootnotes / includeEndnotes; in-note changes carry noteId / noteType. Default behavior is unchanged (body-only).
  • Synthetic endnotes-tracked-changes.docx fixture + integration tests (parse → edit a note → repack → reparse; getChanges discovery from a parsed doc).

Notes for review

A few choices I'd value your steer on — none of this was covered by #378/#415, which were render-only:

  • Run-model additions vs. raw passthrough. I modeled the separator + ref marks as run content so editing stays faithful and the body serializeBlockContent can be reused unchanged. The alternative was selective raw-XML passthrough of unedited notes, which doesn't help edited notes.
  • Separator storage. Kept in dedicated *Separators package fields rather than mixed into footnotes/endnotes, since layout/font/variable detection expect those to be normal notes only.
  • Unconditional re-serialize. Note parts are rewritten whenever the doc has notes, mirroring serializeCommentsToZip — the same contract as the body, which repackDocx already rewrites every save. Happy to gate behind an edit flag if you'd prefer.
  • Content-types / rels for notes-from-scratch is intentionally deferred: a document that carries notes already declares those parts, and round-trips today.

Verification

bun run typecheck clean across all packages; bun test 941 pass / 0 fail; bun run lint 0 errors; api:extract committed; bun changeset added (minor). Also checked end-to-end against a real 38-endnote document: an edit to a note body persists through repack + reparse, separator and w:endnoteRef markers survive, and body endnote-reference count is unchanged.

Follow-up to #378 / #415.

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag @codesmith with what you need. Autofix is disabled.

Jacob Jove and others added 5 commits May 30, 2026 22:16
Note bodies were parsed but never serialized: rezip copied footnotes.xml /
endnotes.xml verbatim, so edits and tracked changes inside notes were dropped
on save. The run parser also discarded the separator markers (w:separator /
w:continuationSeparator) and the in-body auto-number marks (w:footnoteRef /
w:endnoteRef), making the note model lossy.

Add SeparatorContent and NoteRefMarkContent to the run-content model, parse
them, and serialize them, so note bodies round-trip through the same
serializeBlockContent the document body uses — preserving w:ins/w:del, run /
paragraph properties, fields, and tables. Add serializeFootnotes /
serializeEndnotes (noteSerializer.ts) reusing that block serializer.

This is the model layer only; rezip wiring follows separately.

Refs eigenpal#378

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
repackDocx and repackDocxFromRaw copied word/footnotes.xml and
word/endnotes.xml verbatim from the original ZIP, so edits and tracked
changes made to note bodies never persisted. Add serializeFootnotesToZip /
serializeEndnotesToZip (mirroring serializeCommentsToZip) and call them from
both repack paths.

Separator notes are parsed out of package.footnotes/endnotes (which feed
layout and must stay normal-only), so retain them in new
package.footnoteSeparators / endnoteSeparators fields and re-emit them ahead
of the normal notes. Content-type / rels registration is intentionally
skipped: a document carrying notes already declares the part, and
notes-from-scratch is out of scope.

Verified end-to-end against a 38-endnote document: an edit to a note body
persists through repack and reparse, separator count is preserved, and the
body endnote-reference count is unchanged.

Refs eigenpal#378

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
getChanges() walked only the document body, so tracked changes inside note
bodies were invisible to the review pipeline. Add opt-in includeFootnotes /
includeEndnotes filter flags; when set, walk the package's footnote/endnote
bodies too. Each note change carries noteId + noteType ('footnote'|'endnote');
paragraphIndex is note-local for those.

Default behavior is unchanged — body-only unless a caller opts in. Note bodies
live on the package, so DocxReviewer.getChanges threads them into the walk;
the new forEachNoteParagraph mirrors forEachParagraph over a note's blocks.

Refs eigenpal#378

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a synthetic endnotes-tracked-changes.docx fixture (separators, the
w:endnoteRef number mark, and a w:ins inside an endnote body) plus a generator
in generate-fixtures.ts. Two integration tests exercise the full path against
it: core parses → edits a note → repacks → reparses (edit + separators +
endnoteRef survive; body refs unchanged), and agents' DocxReviewer.getChanges
surfaces the in-note insertion with noteType/noteId only when includeEndnotes
is set.

Refs eigenpal#378

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Refs eigenpal#378

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented May 31, 2026

Someone is attempting to deploy a commit to the EigenPal Team on Vercel.

A member of the Team first needs to authorize it.

@eigenpal-release-pal
Copy link
Copy Markdown
Contributor

eigenpal-release-pal Bot commented May 31, 2026

All contributors have signed the CLA ✍️ ✅

Posted by the CLA bot.

A tracked-change w:id is unique only within its part (document.xml /
footnotes.xml / endnotes.xml), so the same id can appear in the body and in a
note. The change map was keyed by id alone, so with includeFootnotes/
includeEndnotes set, a note change could clobber a body change sharing its id.
Key on location + id instead. Add a regression test for the collision.

Also document that note changes are surfaced for discovery only — accept/reject
still operate on the body, so a change carrying noteId can't yet be
accepted/rejected.

Refs eigenpal#378

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jacobjove
Copy link
Copy Markdown
Contributor Author

I have read the CLA Document and I hereby sign the CLA

eigenpal-release-pal Bot added a commit that referenced this pull request May 31, 2026
jacobjove and others added 2 commits May 30, 2026 22:45
Hoist the full OOXML_NAMESPACES + MC_IGNORABLE block into serializer/
xmlUtils.ts so commentSerializer and noteSerializer share one copy instead
of two verbatim ~40-line duplicates. Collapse forEachParagraph and
forEachNoteParagraph onto a shared walkParagraphs helper, dropping the
duplicated table walk and a dead `as Table` cast. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
jacobjove pushed a commit to jacobjove/docx-editor that referenced this pull request May 31, 2026
eigenpal#646 surfaced note-body tracked changes for discovery only — accept/reject
operated on the document body, so a change carrying noteId/noteType could not
be resolved. Lift that: pass a ReviewChange from getChanges to acceptChange/
rejectChange to resolve it inside its footnote/endnote, or use acceptAll/
rejectAll with { includeFootnotes, includeEndnotes } for bulk. The result
persists via eigenpal#646's note save path on toBuffer().

The per-item mutation (applyChangeAtIndex) and the numeric acceptChange(id)
body path are unchanged; note resolution reuses forEachNoteParagraph and a
shared processParagraph helper. Within a located note all matching items are
processed (notes are small, bounded); the body keeps its first-paragraph-stop.

Programmatic DocxReviewer surface only — not exposed as an agent/MCP tool,
per wordCompat's "accept/reject is human-only" non-goal for the tool surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jedrazb jedrazb self-requested a review June 1, 2026 06:30
@vercel
Copy link
Copy Markdown

vercel Bot commented Jun 2, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docx-editor Ready Ready Preview, Comment Jun 2, 2026 7:04am

Request Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant