feat: a comment carries the answers to it

The thread rides the comment mark's attrs, so two runs of one comment still
merge back together and no second store is needed. ODF writes each answer as
its own annotation over the same range, naming its comment in
loext:parent-name; Word writes its own w:comment tied by w15:paraIdParent in
commentsExtended.xml (both probed). Either importer folds the answers back
into the comment they belong to, and the printed list carries them indented.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:02 +02:00
1 parent f51e3377e6
commit f338aec89c
8 files changed
+225 -38

No files matched your search

+1 -1
View File
@@ -30,7 +30,7 @@ Five topics are large enough to have their own deep-dive — read the file befor
- **`bulletList.ts`** / **`orderedList.ts`** — the TipTap lists plus a marker-type attr: `setBulletChar` (innermost list, `null` = default cycle) and the ordered numbering cycle (`utils/orderedListTypes.ts`, multilevel targets the outermost list).
- **`listStyle.ts`** — the named list style (`listStyleName` on the outermost list; nested lists inherit) and the walk resolving every list's effective marker (`data-eff-list-style`, `data-bullet`+`--bullet`, indent, label alignment) from node attr > style level > depth cycle — the style level decides the depth's kind, so a number level numbers a `<ul>` (counter-offset via `counter-reset`, since `<ul>` ignores `start`); `setListStyle` assigns (clearing the subtree's direct marker attrs and retyping each list to its level's kind), `listStyleNameAt` feeds the dropdowns — see `src/lib/styles/CLAUDE.md`.
- **`link.ts`** — hyperlink mark; owns `Mod-k`, which fires the event `Toolbar.svelte` listens for to open the link dialog. A `#name` href targets a bookmark: `Editor.svelte`'s `handleClick` scrolls to it instead of opening a tab. The `plain` attr (`data-plain`, `editor.css`) suppresses the editor's own blue: Word paints a link through its **Hyperlink character style**, which arrives as an ordinary mark, so every DOCX link is plain and one carrying no style is drawn like the text around it — while LibreOffice paints every ODF `text:a` styled or not (probed), so those never are. The DOCX exporter paints `#0563C1` only on a non-plain link, which is how a round trip tells its own links from a file's.
- **`comment.ts`** — an annotation on a range of text, a mark like `bookmark`: the whole comment (author, date, body, resolved) rides its attrs, so two runs of one comment merge back together and no second store is needed. `commentRanges(doc)` walks every range (an id split by a paragraph boundary yields several, which is why the commands walk all of them), `comments(doc)` keeps one per id for `CommentsPane.svelte`. A point comment — LibreOffice's, made with nothing selected, written with no `office:name` and no end element — attaches on import to the run before it, else the one after: the editor's comment is a mark and needs a range. `commentIdAt(state)` is the comment the selection lies within, which a plugin tints heavier (`pm-comment-active`, skipping a resolved one) and `CommentsPane.svelte`, `ChangeBarLayer.svelte` and `ConnectorLayer.svelte` each mark their own half of. It compares **ranges**, not the marks at the caret: the mark is non-inclusive, so `$pos.marks()` is empty at either end of it — and a click on the pane's card selects exactly that range, which would then match nothing.
- **`comment.ts`** — an annotation on a range of text, a mark like `bookmark`: the whole comment (author, date, body, resolved) rides its attrs, so two runs of one comment merge back together and no second store is needed. `commentRanges(doc)` walks every range (an id split by a paragraph boundary yields several, which is why the commands walk all of them), `comments(doc)` keeps one per id for `CommentsPane.svelte`. A point comment — LibreOffice's, made with nothing selected, written with no `office:name` and no end element — attaches on import to the run before it, else the one after: the editor's comment is a mark and needs a range. A comment carries its **thread** in the same attrs (`replies`, one entry per answer, appended by `replyToComment`): ODF gives an answer its own `office:annotation` naming its comment in `loext:parent-name` (probed) and Word its own `w:comment` tied by `w15:paraIdParent` in commentsExtended.xml, so both importers fold the answers back into the comment they belong to instead of listing them beside it. `commentIdAt(state)` is the comment the selection lies within, which a plugin tints heavier (`pm-comment-active`, skipping a resolved one) and `CommentsPane.svelte`, `ChangeBarLayer.svelte` and `ConnectorLayer.svelte` each mark their own half of. It compares **ranges**, not the marks at the caret: the mark is non-inclusive, so `$pos.marks()` is empty at either end of it — and a click on the pane's card selects exactly that range, which would then match nothing.
- **`trackChanges.ts`** — the `insertion`/`deletion` marks plus the recording plugin. `appendTransaction` marks whatever a transaction added, and `adjacentInsertion` reuses the id of the run just before it when the author matches, so a typed sentence is one change and not one per keystroke. A delete never reaches the document: `filterTransaction` rejects it and dispatches a mark pass instead (the author's own pending insertion is removed for real, as both word processors do). A **replacement** — typing or pasting over a selection — goes the same way (`replacedRange`), the new content landing after the struck-out text it replaces; recorded as the insertion alone, its other half was simply dropped and a reject lost the original. Every transaction of our own carries the `RECORDING` meta — accept/reject included, or its `tr.delete` would be read as a fresh user deletion and marked up again. **Loading a document carries it too** (`App.svelte`'s `loadContent`, both for a new document and for an import): a `setContent` is one transaction that inserts the whole file, so without the meta an opened document arrives wholly marked as the reader's own insertion, underlined in the author colour. Both word processors record what is typed after an open, never the file. `revisions(doc)` merges adjacent runs of one id, so an export writes one change per id. The **author's colour** is a decoration, not the mark: a mark cannot know the document's other authors, so a plugin walks the revisions, hands out `REVISION_AUTHOR_COLORS` (LibreOffice's own author palette) in order of first appearance and paints `pm-rev-ins`/`-del` + `pm-rev-a<n>`. That span nests *inside* the mark's, so it carries the underline/strikethrough too — drawn by the ancestor it would take the fallback colour. Cached on the doc; the walk is the whole document and `decorations` runs on every view update. `acceptRevision(id)`/`rejectRevision(id)` apply every range of one change, which is what `RevisionsPane.svelte` lists as one row. `revisionIdAt(state)` is the change the selection lies within — the row the pane marks and the margin bar `ChangeBarLayer.svelte` draws heavier — compared by range for the same reason `commentIdAt` is.
- **`placeholderField.ts`** — a template placeholder, an inline atom drawn gray as ‹label›: a click selects the whole field (NodeSelection) and typing replaces it; Tab/Shift-Tab from a selected field jump to the next/previous one. Round-trips to ODF `<text:placeholder>` and a tagged DOCX `w:sdt` content control.
- **`caption.ts`** — a caption is an ordinary paragraph in the `Caption` style (LibreOffice's, probed: 10pt italic, 0.212cm above and below) holding one `sequenceField` atom, its running number. `sequenceFields(doc)` counts one counter per category in document order; the cached `number` attr is what an export writes, and an `appendTransaction` (off the undo stack) plus an `onCreate` pass keep it true — a file's own cached numbers are unreadable wherever the format is not arabic. The counter's name differs per format: ODF `Illustration`/`Table`, Word `Figure`/`Table`, and `ODF_SEQ_CATEGORY` reads both back. The caption paragraph is **boxed to the frame it captions** (`captionPlacement`), so it stands under the picture instead of at the margin: LibreOffice frames picture and caption together, and a loose paragraph carries that over as `indent`/`indentRight` — the frame's own span in the column, measured (`frameSpanCm`), since that span comes from the wrap, the offset and the column, not from one attr. The indent is also what lifts the caption clear of a **side-wrapped** frame: a line with no room beside a float drops below it. It is centred in that box only under a frame that is itself centred; a block with no frame at all has only its own alignment to pass on. `getFrameDebug` reports the same two spans for the debug dump. `CaptionDialog.svelte` (References tab) owns the insert.
+41 -2
View File
@@ -9,11 +9,15 @@ import { Decoration, DecorationSet } from '@tiptap/pm/view';
// The whole comment rides the mark's attrs, so it needs no store of its own: two runs of
// the same comment carry identical attrs and merge back together.
/** An answer in a comment's thread: ODF's loext:parent-name, Word's w15:paraIdParent. */
export type CommentReply = { author: string; date: string; text: string };
export type CommentRange = {
id: string;
author: string;
date: string;
text: string;
replies: CommentReply[];
resolved: boolean;
from: number;
to: number;
@@ -33,6 +37,20 @@ function commentIdOf(marks: readonly PMMark[]): string | null {
return typeof id === 'string' && id ? id : null;
}
function repliesOf(value: unknown): CommentReply[] {
const list = typeof value === 'string' ? safeParse(value) : value;
if (!Array.isArray(list)) return [];
return list.map((r) => ({
author: String((r as CommentReply)?.author ?? ''),
date: String((r as CommentReply)?.date ?? ''),
text: String((r as CommentReply)?.text ?? ''),
}));
}
function safeParse(json: string): unknown {
try { return JSON.parse(json); } catch { return []; }
}
export function commentMarkAt(marks: readonly PMMark[]): PMMark | null {
return marks.find((m) => m.type.name === 'comment') ?? null;
}
@@ -66,6 +84,7 @@ export function commentRanges(doc: PMNode): CommentRange[] {
author: String(mark.attrs.author ?? ''),
date: String(mark.attrs.date ?? ''),
text: String(mark.attrs.text ?? ''),
replies: repliesOf(mark.attrs.replies),
resolved: mark.attrs.resolved === true,
from: pos,
to: pos + node.nodeSize,
@@ -88,7 +107,9 @@ declare module '@tiptap/core' {
comment: {
/** Comment the selection; returns false on an empty one, as in Word. */
addComment: (opts: { author: string; text: string }) => ReturnType;
updateComment: (id: string, patch: { text?: string; resolved?: boolean }) => ReturnType;
updateComment: (id: string, patch: { text?: string; resolved?: boolean; replies?: CommentReply[] }) => ReturnType;
/** Appends to the comment's thread, as the reply box in either review view does. */
replyToComment: (id: string, reply: { author: string; text: string }) => ReturnType;
removeComment: (id: string) => ReturnType;
};
}
@@ -107,6 +128,14 @@ export const Comment = Mark.create({
author: { default: '', parseHTML: (el) => el.getAttribute('data-comment-author') || '', renderHTML: (a) => ({ 'data-comment-author': String(a.author ?? '') }) },
date: { default: '', parseHTML: (el) => el.getAttribute('data-comment-date') || '', renderHTML: (a) => ({ 'data-comment-date': String(a.date ?? '') }) },
text: { default: '', parseHTML: (el) => el.getAttribute('data-comment-text') || '', renderHTML: (a) => ({ 'data-comment-text': String(a.text ?? '') }) },
replies: {
default: [] as CommentReply[],
parseHTML: (el) => repliesOf(el.getAttribute('data-comment-replies') || '[]'),
renderHTML: (a) => {
const list = repliesOf(a.replies);
return list.length ? { 'data-comment-replies': JSON.stringify(list) } : {};
},
},
resolved: {
default: false,
parseHTML: (el) => el.getAttribute('data-comment-resolved') === 'true',
@@ -130,7 +159,7 @@ export const Comment = Mark.create({
({ state, commands }) => {
if (state.selection.empty) return false;
return commands.setMark(this.name, {
id: newCommentId(), author, text, date: new Date().toISOString(), resolved: false,
id: newCommentId(), author, text, replies: [], date: new Date().toISOString(), resolved: false,
});
},
updateComment:
@@ -144,12 +173,22 @@ export const Comment = Mark.create({
tr.addMark(r.from, r.to, state.schema.marks.comment.create({
id: r.id, author: r.author, date: r.date,
text: patch.text ?? r.text,
replies: patch.replies ?? r.replies,
resolved: patch.resolved ?? r.resolved,
}));
}
}
return true;
},
replyToComment:
(id, reply) =>
({ state, commands }) => {
const c = commentRanges(state.doc).find((r) => r.id === id);
if (!c || !reply.text.trim()) return false;
return commands.updateComment(id, {
replies: [...c.replies, { author: reply.author, text: reply.text.trim(), date: new Date().toISOString() }],
});
},
removeComment:
(id) =>
({ tr, state, dispatch }) => {
+46 -15
View File
@@ -180,25 +180,45 @@ let docTextBoxes: TextBoxDocx[] = [];
// Comments, numbered in document order before the runs are built — Word's ids are
// integers and word/comments.xml has to exist before the Document is constructed.
type CommentDocx = { id: number; author: string; date: Date; text: string; resolved: boolean };
type CommentDocx = { id: number; author: string; date: Date; text: string; resolved: boolean; parent?: number };
let docComments: CommentDocx[] = [];
let docCommentIds = new Map<string, number>();
// The answers in each comment's thread, by the comment's Word id: each is a comment of
// its own, referenced at the same anchor and tied to its parent in commentsExtended.xml.
let docCommentReplies = new Map<number, number[]>();
function collectComments(node: TiptapNode): void {
for (const child of node.content ?? []) {
const mark = child.type === 'text' ? child.marks?.find(m => m.type === 'comment') : undefined;
const id = mark ? String(mark.attrs?.id ?? '') : '';
if (id && !docCommentIds.has(id)) {
docCommentIds.set(id, docComments.length);
const raw = String(mark!.attrs?.date ?? '');
const date = new Date(raw);
const parsed = (raw: string) => {
const date = new Date(raw);
return Number.isNaN(date.getTime()) ? new Date() : date;
};
const parent = docComments.length;
docCommentIds.set(id, parent);
docComments.push({
id: docComments.length,
id: parent,
author: String(mark!.attrs?.author ?? ''),
date: Number.isNaN(date.getTime()) ? new Date() : date,
date: parsed(String(mark!.attrs?.date ?? '')),
text: String(mark!.attrs?.text ?? ''),
resolved: mark!.attrs?.resolved === true,
});
const replies = Array.isArray(mark!.attrs?.replies) ? mark!.attrs.replies : [];
if (replies.length) {
docCommentReplies.set(parent, replies.map((r: { author?: string; date?: string; text?: string }) => {
const id = docComments.length;
docComments.push({
id, parent,
author: String(r?.author ?? ''),
date: parsed(String(r?.date ?? '')),
text: String(r?.text ?? ''),
resolved: false,
});
return id;
}));
}
}
if (!id) collectComments(child);
}
@@ -634,6 +654,9 @@ function inlineToRuns(content: TiptapNode[] = [], force: TextProps = {}): Inline
if (openComment === null) return;
flush();
out.push(new CommentRangeEnd(openComment), new TextRun({ children: [new CommentReference(openComment)] }));
// An answer needs no range of its own: its reference at the same anchor is what
// carries it, and commentsExtended.xml names the comment it belongs to.
for (const r of docCommentReplies.get(openComment) ?? []) out.push(new TextRun({ children: [new CommentReference(r)] }));
openComment = null;
};
const openCommentAt = (id: number | null) => {
@@ -1045,6 +1068,7 @@ function txbxParagraphXml(node: TiptapNode, parts: TxbxParts, indentTwip = 0, nu
const closeComment = () => {
if (openComment === null) return;
runs += `<w:commentRangeEnd w:id="${openComment}"/><w:r><w:commentReference w:id="${openComment}"/></w:r>`;
for (const r of docCommentReplies.get(openComment) ?? []) runs += `<w:r><w:commentReference w:id="${r}"/></w:r>`;
openComment = null;
};
let openBookmarkName: string | null = null;
@@ -1552,11 +1576,13 @@ function applyPlaceholdersDocx(bytes: Uint8Array, placeholders: string[]): Uint8
const W14_NS = 'http://schemas.microsoft.com/office/word/2010/wordml';
const W15_NS = 'http://schemas.microsoft.com/office/word/2012/wordml';
// Post-pack pass: a resolved comment is w15:done in word/commentsExtended.xml, keyed by
// the w14:paraId of the comment body's last paragraph — the package knows neither part.
function applyCommentsResolvedDocx(bytes: Uint8Array): Uint8Array {
const resolved = docComments.filter((c) => c.resolved);
if (!resolved.length) return bytes;
// Post-pack pass: word/commentsExtended.xml, which carries what w:comment cannot — a
// comment is handled (w15:done) and an answer names its comment (w15:paraIdParent).
// Both are keyed by the w14:paraId of a comment body's last paragraph, and the package
// writes neither part.
function applyCommentsExtendedDocx(bytes: Uint8Array): Uint8Array {
const threaded = docComments.filter((c) => c.resolved || c.parent !== undefined);
if (!threaded.length) return bytes;
const files = unzipSync(bytes);
const relsPath = 'word/_rels/document.xml.rels';
const comments = files['word/comments.xml'] ? strFromU8(files['word/comments.xml']) : '';
@@ -1572,7 +1598,9 @@ function applyCommentsResolvedDocx(bytes: Uint8Array): Uint8Array {
xml = /<w:comments\b[^>]*\bmc:Ignorable="/.test(xml)
? xml.replace(/(<w:comments\b[^>]*\bmc:Ignorable=")((?:(?!w14\b)[^"])*")/, '$1w14 $2')
: xml.replace(/<w:comments\b/, '<w:comments mc:Ignorable="w14"');
for (const c of resolved) {
// Every comment in a thread needs its own paraId, not only the ones being marked.
const stamped = docComments.filter((c) => threaded.some((t) => t === c || t.parent === c.id));
for (const c of stamped) {
// Stamp the comment's last body paragraph (Word keys the whole comment off it);
// the greedy tempered scan ends at the last <w:p before </w:comment>.
xml = xml.replace(
@@ -1584,7 +1612,9 @@ function applyCommentsResolvedDocx(bytes: Uint8Array): Uint8Array {
files['word/commentsExtended.xml'] = strToU8(
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`
+ `<w15:commentsEx xmlns:w15="${W15_NS}">`
+ resolved.map((c) => `<w15:commentEx w15:paraId="${paraId(c.id)}" w15:done="1"/>`).join('')
+ stamped.map((c) => `<w15:commentEx w15:paraId="${paraId(c.id)}"`
+ (c.parent === undefined ? '' : ` w15:paraIdParent="${paraId(c.parent)}"`)
+ ` w15:done="${c.resolved ? 1 : 0}"/>`).join('')
+ '</w15:commentsEx>');
files[relsPath] = strToU8(rels.replace('</Relationships>',
`<Relationship Id="rId${maxIdIn(rels, /Id="rId(\d+)"/g) + 1}" Type="http://schemas.microsoft.com/office/2011/relationships/commentsExtended" Target="commentsExtended.xml"/></Relationships>`));
@@ -2576,6 +2606,7 @@ export async function buildDocx(
docTextBoxes = [];
docComments = [];
docCommentIds = new Map();
docCommentReplies = new Map();
docRevisionIds = new Map();
collectComments(docJson);
const notesByClass: Record<NoteKind, Record<string, { children: Paragraph[] }>> = { footnote: {}, endnote: {} };
@@ -2745,8 +2776,8 @@ export async function buildDocx(
const packed = applyFormulasDocx(applyTextBoxesDocx(linked, docTextBoxes), docFormulas);
const cited = applyBibliographyDocx(applyPlaceholdersDocx(applyRubyDocx(packed, docRubies), docPlaceholders), docSources, docCitationStyle(docJson));
const withNotes = docNoteIds.size ? applyNoteMarksDocx(applyNotePrDocx(cited, notesSettings)) : cited;
const withResolved = applyCommentsResolvedDocx(withNotes);
const mirrored = margins.mirrored ? applyMirrorMarginsDocx(withResolved) : withResolved;
const threaded = applyCommentsExtendedDocx(withNotes);
const mirrored = margins.mirrored ? applyMirrorMarginsDocx(threaded) : threaded;
const bidi = applyNoHyphensDocx(rtl ? applyBidiDocx(mirrored) : mirrored);
const dims = pageDimsCm(pageFormat, orientation);
const marked = applyFoldMarksDocx(bidi, foldMarks, dims.w * 10);
+26 -8
View File
@@ -898,7 +898,11 @@ const bookmarkNameOf = (node: TiptapNode): string =>
String(node.marks?.find((m) => m.type === 'bookmark')?.attrs?.name ?? '');
// One comment, collected by replaceComments and emitted by applyComments.
type CommentExport = { name: string; author: string; date: string; text: string; resolved: boolean };
type CommentExport = {
name: string; author: string; date: string; text: string; resolved: boolean;
/** The thread under it: LibreOffice writes each answer as its own annotation. */
replies: { author: string; date: string; text: string }[];
};
// Bracket each comment's text with the CMS/CME sentinels, the same shape as the bookmark
// pair — spliced into the run text, so a comment inside a cell or a list rides along too.
@@ -927,6 +931,7 @@ function replaceComments(node: TiptapNode, out: CommentExport[]): TiptapNode {
date: String(mark!.attrs?.date ?? ''),
text: String(mark!.attrs?.text ?? ''),
resolved: mark!.attrs?.resolved === true,
replies: Array.isArray(mark!.attrs?.replies) ? mark!.attrs.replies : [],
});
content.push({ ...child, text: `${CMS}${openIndex}${CMS}${child.text ?? ''}` });
lastOfRange = content.length - 1;
@@ -4636,6 +4641,9 @@ function applyBookmarks(odtBytes: Uint8Array, refs: CrossRefExport[]): Uint8Arra
return rezipOdt(files);
}
/** The name an answer's own annotation carries, derived from the comment's. */
const replyName = (name: string, i: number) => `${name}_r${i + 1}`;
// CMS/CME sentinels → <office:annotation> (author, date and body) and
// <office:annotation-end/>. content.xml declares neither dc: nor loext:, so the two
// namespaces are added on the root when a document actually has comments.
@@ -4654,16 +4662,26 @@ function applyComments(odtBytes: Uint8Array, list: CommentExport[]): Uint8Array
if (!c) return '';
// LibreOffice reads its own resolved flag off loext: and writes it either way;
// its comment bodies carry the pool's Comment paragraph style.
const body = c.text.split('\n').map(line => `<text:p text:style-name="Comment">${escapeXml(line)}</text:p>`).join('');
return `<office:annotation office:name="${escapeXml(c.name)}" loext:resolved="${c.resolved}">`
+ (c.author ? `<dc:creator>${escapeXml(c.author)}</dc:creator>` : '')
+ (c.date ? `<dc:date>${escapeXml(c.date)}</dc:date>` : '')
+ (body || '<text:p text:style-name="Comment"/>')
+ '</office:annotation>';
// An answer is an annotation of its own over the same range, pointing at the
// comment it belongs to — LibreOffice's loext:parent-name (probed).
const annotation = (name: string, a: { author: string; date: string; text: string }, parent: string) => {
const body = a.text.split('\n').map(line => `<text:p text:style-name="Comment">${escapeXml(line)}</text:p>`).join('');
return `<office:annotation office:name="${escapeXml(name)}"`
+ (parent ? ` loext:parent-name="${escapeXml(parent)}"` : '')
+ ` loext:resolved="${c.resolved}">`
+ (a.author ? `<dc:creator>${escapeXml(a.author)}</dc:creator>` : '')
+ (a.date ? `<dc:date>${escapeXml(a.date)}</dc:date>` : '')
+ (body || '<text:p text:style-name="Comment"/>')
+ '</office:annotation>';
};
return annotation(c.name, c, '')
+ c.replies.map((r, i) => annotation(replyName(c.name, i), r, c.name)).join('');
})
.replace(new RegExp(`${CME}(\\d+)${CME}`, 'g'), (_m, idx: string) => {
const c = list[Number(idx)];
return c ? `<office:annotation-end office:name="${escapeXml(c.name)}"/>` : '';
if (!c) return '';
return `<office:annotation-end office:name="${escapeXml(c.name)}"/>`
+ c.replies.map((_r, i) => `<office:annotation-end office:name="${escapeXml(replyName(c.name, i))}"/>`).join('');
});
// applyRevisions may already have declared dc:; declaring either twice is invalid XML.
+18 -1
View File
@@ -13,6 +13,8 @@ const COMMENT_COLOR = '#c88c00';
export type PrintedComment = {
id: string; n: number; author: string; date: string; text: string; quote: string;
/** The thread under it — a printout that dropped the answers would misreport it. */
replies: { author: string; date: string; text: string }[];
/** The page the anchor sits on, where the caller knows it (the raster paths do). */
page?: number;
};
@@ -38,6 +40,7 @@ export function printedComments(root: ParentNode): PrintedComment[] {
author: el.getAttribute('data-comment-author') ?? '',
date: el.getAttribute('data-comment-date') ?? '',
text: el.getAttribute('data-comment-text') ?? '',
replies: parseReplies(el.getAttribute('data-comment-replies')),
quote: el.textContent ?? '',
};
byId.set(id, entry);
@@ -46,6 +49,14 @@ export function printedComments(root: ParentNode): PrintedComment[] {
return out;
}
function parseReplies(json: string | null): { author: string; date: string; text: string }[] {
if (!json) return [];
try {
const list: unknown = JSON.parse(json);
return Array.isArray(list) ? list as { author: string; date: string; text: string }[] : [];
} catch { return []; }
}
// The bar takes the revision author's colour, handed out as the editor's decoration
// does it: in order of first appearance.
function authorColors(root: ParentNode): Map<string, string> {
@@ -94,9 +105,14 @@ export function commentListHtml(list: PrintedComment[], labels: CommentLabels):
// quote it is what makes an entry findable in a printout nobody can click.
const meta = [c.page ? labels.onPage(c.page) : '', c.author, c.date ? when(c.date) : '']
.filter(Boolean).map(esc).join(' · ');
const replies = c.replies.map((r) => {
const rm = [r.author, r.date ? when(r.date) : ''].filter(Boolean).map(esc).join(' · ');
return `<span class="cl-reply"><span class="cl-meta">${rm}</span>`
+ `<span class="cl-text">${esc(r.text)}</span></span>`;
}).join('');
return `<li><span class="cl-meta">${meta}</span>`
+ (c.quote.trim() ? `<span class="cl-quote">${esc(c.quote)}</span>` : '')
+ `<span class="cl-text">${esc(c.text)}</span></li>`;
+ `<span class="cl-text">${esc(c.text)}</span>${replies}</li>`;
}).join('');
return `<section class="comment-list"><h2>${esc(labels.heading)}</h2><ol>${rows}</ol></section>`;
}
@@ -116,5 +132,6 @@ export function reviewPrintCss(): string {
.cl-meta { display: block; font-size: 9pt; color: #555; }
.cl-quote { display: block; margin: 0.05cm 0; padding-left: 0.2cm; border-left: 2px solid ${COMMENT_COLOR}; color: #555; }
.cl-text { display: block; white-space: pre-wrap; }
.cl-reply { display: block; margin-top: 0.1cm; padding-left: 0.3cm; border-left: 1px solid #bbb; }
`;
}
+29 -9
View File
@@ -2895,17 +2895,19 @@ function docAutoHyphenation(files: Record<string, Uint8Array>): boolean {
const W14 = 'http://schemas.microsoft.com/office/word/2010/wordml';
const W15 = 'http://schemas.microsoft.com/office/word/2012/wordml';
// word/commentsExtended.xml → the w14:paraId set of resolved (w15:done) comments.
function docxResolvedParaIds(files: Record<string, Uint8Array>): Set<string> {
const out = new Set<string>();
// word/commentsExtended.xml → per w14:paraId whether the comment is handled (w15:done)
// and which comment it answers (w15:paraIdParent), the two things w:comment cannot say.
function docxCommentsEx(files: Record<string, Uint8Array>): Map<string, { done: boolean; parent: string | null }> {
const out = new Map<string, { done: boolean; parent: string | null }>();
const bytes = files['word/commentsExtended.xml'];
if (!bytes) return out;
let doc: Document;
try { doc = parseXml(strFromU8(bytes)); } catch { return out; }
for (const ex of Array.from(doc.getElementsByTagNameNS(W15, 'commentEx'))) {
const done = ex.getAttributeNS(W15, 'done');
const pid = ex.getAttributeNS(W15, 'paraId');
if (pid && (done === '1' || done === 'true')) out.add(pid);
if (!pid) continue;
const done = ex.getAttributeNS(W15, 'done');
out.set(pid, { done: done === '1' || done === 'true', parent: ex.getAttributeNS(W15, 'paraIdParent') || null });
}
return out;
}
@@ -2918,19 +2920,37 @@ function docxComments(files: Record<string, Uint8Array>): Map<string, Record<str
if (!bytes) return out;
let doc: Document;
try { doc = parseXml(strFromU8(bytes)); } catch { return out; }
const done = docxResolvedParaIds(files);
const ex = docxCommentsEx(files);
// paraId → the comment it belongs to, so an answer finds the one it hangs under.
const byParaId = new Map<string, Record<string, unknown>>();
const replies: { parent: string; attrs: Record<string, unknown> }[] = [];
for (const c of Array.from(doc.getElementsByTagNameNS(W, 'comment'))) {
const id = c.getAttributeNS(W, 'id');
if (!id) continue;
const paras = fcAll(c, 'p');
const lastPid = paras.length ? paras[paras.length - 1].getAttributeNS(W14, 'paraId') : null;
out.set(id, {
const meta = lastPid ? ex.get(lastPid) : undefined;
const attrs: Record<string, unknown> = {
id: `w${id}`,
author: c.getAttributeNS(W, 'author') ?? '',
date: c.getAttributeNS(W, 'date') ?? '',
text: paras.map((p) => p.textContent ?? '').join('\n').trim(),
resolved: lastPid != null && done.has(lastPid),
});
resolved: meta?.done === true,
};
if (lastPid) byParaId.set(lastPid, attrs);
// An answer is a comment of its own in the part; the thread is what the editor
// shows, so it is folded into its parent and gets no mark of its own.
if (meta?.parent) replies.push({ parent: meta.parent, attrs });
else out.set(id, attrs);
}
// The attr is written only where the file has a thread: an empty one is the mark's
// default, and an editor document carries none.
for (const r of replies) {
const parent = byParaId.get(r.parent);
if (!parent) continue;
const list = (parent.replies as { author: string; date: string; text: string }[]) ?? [];
list.push({ author: String(r.attrs.author), date: String(r.attrs.date), text: String(r.attrs.text) });
parent.replies = list;
}
return out;
}
+26 -2
View File
@@ -157,6 +157,9 @@ type Ctx = {
openBookmarks: Set<string>;
// Ranged comments currently open, by their office:name — same shape as openBookmarks.
openComments: Map<string, Record<string, unknown>>;
// The answers in each comment's thread, by the office:name they point at. Collected
// up front: LibreOffice writes an answer before the comment it belongs to.
commentReplies: Map<string, { author: string; date: string; text: string }[]>;
// Recorded revisions: the <text:tracked-changes> registry by change id, and the
// insertions whose <text:change-start> has been seen but not their end.
revisions: Map<string, { kind: 'insertion' | 'deletion'; author: string; date: string; text: string; paras: Element[] }>;
@@ -773,7 +776,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
const contentWidthCm = geo
? pageDimsCm(geo.format, geo.orientation).w - geo.margins.left - geo.margins.right
: pageDimsCm('A4', 'portrait').w - 2 * 2.12;
const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), usedListStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, contentWidthCm, pageRtl: geo?.rtl ?? false, masterPages: [], masterPageStarts: [], masterBlocks: new Map(), openBookmarks: new Set(), openComments: new Map(), revisions: odfRevisions(body), openInsertions: new Map(), notes: [], foldMarks: false };
const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), usedListStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, contentWidthCm, pageRtl: geo?.rtl ?? false, masterPages: [], masterPageStarts: [], masterBlocks: new Map(), openBookmarks: new Set(), openComments: new Map(), commentReplies: odfCommentReplies(body), revisions: odfRevisions(body), openInsertions: new Map(), notes: [], foldMarks: false };
let blocks = convertBlocks(Array.from(body.children), ctx, 'body');
if (blocks.length === 0) blocks.push({ type: 'paragraph' });
pairAlignedFrames(blocks, Math.floor(cmToPx(contentWidthCm)));
@@ -2271,8 +2274,14 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: Bl
if (name) ctx.openComments.delete(name);
continue;
}
const attrs = odfCommentAttrs(e);
// An answer is an annotation of its own pointing at its comment; it is folded
// into that comment's thread, not shown as a comment beside it.
if (e.getAttributeNS(NS.loext, 'parent-name')) continue;
const name = e.getAttributeNS(NS.office, 'name');
const replies = (name && ctx.commentReplies.get(name)) || [];
// Only where the file has one: an empty thread is the mark's default, and an
// attr written out anyway would differ from what the editor itself holds.
const attrs = { ...odfCommentAttrs(e), ...(replies.length ? { replies } : {}) };
if (name) ctx.openComments.set(name, attrs);
else points.push({ at: out.length, attrs });
continue;
@@ -2291,6 +2300,21 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: Bl
return mergeAdjacentText(out);
}
// Every answer in the file by the comment it answers (loext:parent-name), in document
// order — the annotations themselves may come in any order.
function odfCommentReplies(body: Element): Map<string, { author: string; date: string; text: string }[]> {
const out = new Map<string, { author: string; date: string; text: string }[]>();
for (const el of Array.from(body.getElementsByTagNameNS(NS.office, 'annotation'))) {
const parent = el.getAttributeNS(NS.loext, 'parent-name');
if (!parent) continue;
const a = odfCommentAttrs(el);
const list = out.get(parent) ?? [];
list.push({ author: String(a.author ?? ''), date: String(a.date ?? ''), text: String(a.text ?? '') });
out.set(parent, list);
}
return out;
}
// <office:annotation> → the comment mark's attrs. `office:name` is the id where the file
// has one (a ranged comment); a point one gets a minted id.
function odfCommentAttrs(el: Element): Record<string, unknown> {
+38
View File
@@ -1965,6 +1965,44 @@ describe('Leg 16: comments (office:annotation / w:comment)', () => {
check('neighbours stay uncommented', runs.filter((r: N) => r.marks?.some((x: N) => x.type === 'comment')).length === 1);
});
const threadAttrs = { ...attrs, id: 'c9', replies: [
{ author: 'B. Zweit', date: '2026-08-15T08:00:00.000Z', text: 'Ist geprüft.' },
] };
const tDoc: N = { type: 'doc', content: [P(null, T('vor '), CM('markiert', threadAttrs), T(' nach'))] };
it('ODT: an answer is its own annotation pointing at the comment', async () => {
const bytes = await buildOdt(tDoc, margins, 'portrait');
const content = strFromU8(unzipSync(bytes)['content.xml']);
check('answer names its parent', content.includes('loext:parent-name="c9"'), content.slice(0, 400));
check('answer carries its own author', content.includes('<dc:creator>B. Zweit</dc:creator>'));
check('answer closes its range', content.includes('<office:annotation-end office:name="c9_r1"/>'));
const back = importOdt(bytes).content as N;
const runs = back.content[0].content as N[];
const marks = runs.flatMap((r: N) => (r.marks ?? []).filter((m: N) => m.type === 'comment'));
check('one comment, not two', marks.length === 1, marks);
check('the answer came back on it',
marks[0]?.attrs.replies?.[0]?.text === 'Ist geprüft.' && marks[0]?.attrs.replies?.[0]?.author === 'B. Zweit',
marks[0]?.attrs.replies);
});
it('DOCX: an answer is its own w:comment tied by w15:paraIdParent', async () => {
const bytes = await buildDocx(tDoc, margins, 'portrait');
const files = unzipSync(bytes);
const ex = strFromU8(files['word/commentsExtended.xml'] ?? new Uint8Array());
check('the part exists', !!files['word/commentsExtended.xml'], Object.keys(files).join(' '));
check('the answer names its parent', /w15:paraIdParent="/.test(ex), ex);
check('two comment bodies', (strFromU8(files['word/comments.xml']).match(/<w:comment /g) ?? []).length === 2);
check('the answer is referenced at the anchor',
(strFromU8(files['word/document.xml']).match(/<w:commentReference /g) ?? []).length === 2);
const back = importDocx(bytes).content as N;
const runs = back.content[0].content as N[];
const marks = runs.flatMap((r: N) => (r.marks ?? []).filter((m: N) => m.type === 'comment'));
check('one comment, not two', marks.length === 1, marks);
check('the answer came back on it', marks[0]?.attrs.replies?.[0]?.text === 'Ist geprüft.', marks[0]?.attrs.replies);
});
it('DOCX: the range brackets the runs and word/comments.xml holds the body', async () => {
const bytes = await buildDocx(cDoc, margins, 'portrait');
const files = unzipSync(bytes);