feat: custom note marks travel through DOCX (w:customMarkFollows)

The docx package cannot express a custom footnote/endnote mark, so the anchor
always went out as an automatic reference and the label was lost — and the
importer never read one either, so a Word file's custom mark leaked into the
note's body text. A post-pack pass now rewrites the anchor (flag + literal
run) and the note part's <w:footnoteRef/>; the importer reads the flag, takes
the run's text as the label and strips its repeat off the note body.
This commit is contained in:
Steffen Becker committed 2026-08-16 10:30:00 +02:00
1 parent 5dab73ac3a
commit 044c59d895
2 files changed
+52 -9

No files matched your search

+28 -3
View File
@@ -145,7 +145,7 @@ function docCitationStyle(node: TiptapNode): CitationStyle {
// Word numbers footnotes and endnotes in separate files, so each class counts from 1.
// Filled from the note section before the body is walked, then read by the anchor —
// module-level for the same reason as docFormulas.
let docNoteIds = new Map<string, { id: number; kind: NoteKind }>();
let docNoteIds = new Map<string, { id: number; kind: NoteKind; label: string | null }>();
// 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.
@@ -1340,6 +1340,30 @@ function applyNotePrDocx(bytes: Uint8Array, notes: NoteSettings): Uint8Array {
return zipSync(out);
}
// Post-pack pass: a custom note mark (w:customMarkFollows), which the docx package
// cannot express. The anchor's reference gains the flag plus the literal character,
// and the note text shows the same character where <w:footnoteRef/> always sits.
function applyNoteMarksDocx(bytes: Uint8Array): Uint8Array {
const marked = [...docNoteIds.values()].filter((n) => n.label);
if (!marked.length) return bytes;
const files = unzipSync(bytes);
const patch = (path: string, from: RegExp, to: string) => {
if (files[path]) files[path] = strToU8(strFromU8(files[path]).replace(from, to));
};
for (const n of marked) {
const mark = `<w:t>${escapeXml(n.label!)}</w:t>`;
patch('word/document.xml',
new RegExp(`<w:${n.kind}Reference w:id="${n.id}"/>`),
`<w:${n.kind}Reference w:customMarkFollows="1" w:id="${n.id}"/>${mark}`);
patch(`word/${n.kind}s.xml`,
new RegExp(`(<w:${n.kind} w:id="${n.id}">(?:(?!</w:${n.kind}>)[\\s\\S])*?)<w:${n.kind}Ref/>`),
`$1${mark}`);
}
const out: Record<string, [Uint8Array, { level: 6 }]> = {};
for (const [path, data] of Object.entries(files)) out[path] = [data, { level: 6 }];
return zipSync(out);
}
// Post-pack pass: a right-to-left section is <w:bidi/> in every w:sectPr, which the
// docx package does not expose (it has the paragraph-level flag only).
function applyBidiDocx(bytes: Uint8Array): Uint8Array {
@@ -1960,7 +1984,8 @@ export async function buildDocx(
for (const note of noteBlocks) {
const kind: NoteKind = note.attrs?.kind === 'endnote' ? 'endnote' : 'footnote';
const id = Object.keys(notesByClass[kind]).length + 1;
docNoteIds.set(String(note.attrs?.id ?? ''), { id, kind });
const label = typeof note.attrs?.label === 'string' && note.attrs.label ? note.attrs.label : null;
docNoteIds.set(String(note.attrs?.id ?? ''), { id, kind, label });
notesByClass[kind][String(id)] = {
children: [new Paragraph({ style: kind === 'endnote' ? 'EndnoteText' : 'FootnoteText', children: inlineToRuns(note.content ?? []) })],
};
@@ -2103,7 +2128,7 @@ export async function buildDocx(
const blob = await Packer.toBlob(doc);
const packed = applyFormulasDocx(applyTextBoxesDocx(new Uint8Array(await blob.arrayBuffer()), textBoxes), docFormulas);
const cited = applyBibliographyDocx(applyRubyDocx(packed, docRubies), docSources, docCitationStyle(docJson));
const withNotes = docNoteIds.size ? applyNotePrDocx(cited, notesSettings) : cited;
const withNotes = docNoteIds.size ? applyNoteMarksDocx(applyNotePrDocx(cited, notesSettings)) : cited;
const mirrored = margins.mirrored ? applyMirrorMarginsDocx(withNotes) : withNotes;
const bidi = applyNoHyphensDocx(rtl ? applyBidiDocx(mirrored) : mirrored);
if (isEmptyPageDecor(decor)) return bidi;
+24 -6
View File
@@ -92,7 +92,7 @@ type Ctx = {
// word/footnotes.xml and endnotes.xml by w:id, and the notes the body referenced, in
// anchor order — the editor keeps them in one section at the document end (notes.ts).
noteParts: Record<NoteKind, Map<string, Element>>;
notes: { id: string; kind: NoteKind; text: string; content: Node[]; styleName: string | null }[];
notes: { id: string; kind: NoteKind; label: string | null; text: string; content: Node[]; styleName: string | null }[];
};
// The real notes of a part, by w:id: Word's own separator entries carry a w:type and
@@ -224,7 +224,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
if (ctx.notes.length) {
blocks.push({ type: 'noteSection', content: ctx.notes.map((n) => ({
type: 'note',
attrs: { id: n.id, kind: n.kind, label: null, text: n.text, styleName: n.styleName },
attrs: { id: n.id, kind: n.kind, label: n.label, text: n.text, styleName: n.styleName },
...(n.content.length ? { content: n.content } : {}),
})) });
}
@@ -1166,7 +1166,7 @@ function inlineChildren(el: Element): Element[] {
// A w:footnoteReference → the anchor node, with the note's own paragraphs collected
// into ctx for the section the document end gets. Word's numbering is implicit, so the
// citation is counted here; several paragraphs flatten to hard breaks.
function noteRefNode(wid: string | null, kind: NoteKind, ctx: Ctx, baseRun: RunProps, defaults: BlockDefaults): Node | null {
function noteRefNode(wid: string | null, kind: NoteKind, ctx: Ctx, baseRun: RunProps, defaults: BlockDefaults, label: string | null = null): Node | null {
const note = wid == null ? null : ctx.noteParts[kind].get(wid);
if (!note) return null;
const content: Node[] = [];
@@ -1184,7 +1184,14 @@ function noteRefNode(wid: string | null, kind: NoteKind, ctx: Ctx, baseRun: RunP
if (!first.text) content.shift();
}
const seen = ctx.notes.filter((n) => n.kind === kind).length;
const text = formatOrdinal(seen + 1, kind === 'endnote' ? 'i' : '1');
const text = label ?? formatOrdinal(seen + 1, kind === 'endnote' ? 'i' : '1');
// A custom-marked note repeats the literal character where <w:footnoteRef/> would
// sit; the editor draws the mark itself, so strip it off the body.
const bodyFirst = content[0];
if (label && bodyFirst?.type === 'text' && typeof bodyFirst.text === 'string' && bodyFirst.text.startsWith(label)) {
bodyFirst.text = bodyFirst.text.slice(label.length).replace(/^\t+/, '');
if (!bodyFirst.text) content.shift();
}
const id = `${kind}${wid}`;
// The note renders at the file's own size and indent: its first paragraph names the
// style (Word's FootnoteText), and collectStyleSheet only keeps a style in use.
@@ -1195,7 +1202,7 @@ function noteRefNode(wid: string | null, kind: NoteKind, ctx: Ctx, baseRun: RunP
const styleId = pStyle ? wVal(pStyle) : null;
const styleName = styleId && !isStockNoteStyle(ctx, styleId) ? ctx.styleNames.get(styleId) ?? null : null;
if (styleId && styleName) ctx.usedStyles.add(styleId);
ctx.notes.push({ id, kind, text, content, styleName });
ctx.notes.push({ id, kind, label, text, content, styleName });
return { type: 'noteRef', attrs: { id, kind, text } };
}
@@ -1289,6 +1296,9 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
ctx.pendingBlocks.push(n);
};
// Set when this run's w:footnoteReference declares a custom mark: the run's own
// w:t is the mark, consumed by the anchor rather than pushed as text.
let customMark: string | null = null;
for (const child of Array.from(r.children)) {
// Word wraps every shape in mc:AlternateContent; use only the mc:Choice branch
// (the mc:Fallback VML duplicates it and would double-import).
@@ -1349,6 +1359,7 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
// revision is only recorded, not applied.
case 'delText':
case 't':
if (customMark !== null && (child.textContent ?? '') === customMark) { customMark = null; break; }
if ((fieldShown || fieldSeq) && fieldMode === 'result') fieldResultText += child.textContent ?? '';
else if (!skipResult()) pushText(child.textContent ?? '', marks);
break;
@@ -1370,7 +1381,14 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
// The zone schema has no notes, and Word's own separator notes are referenced
// by nothing — only a real anchor reaches here.
const kind: NoteKind = child.localName === 'endnoteReference' ? 'endnote' : 'footnote';
const ref = hfFields ? null : noteRefNode(child.getAttributeNS(W, 'id'), kind, ctx, baseRun, defaults);
// w:customMarkFollows: the literal mark is the rest of this run's text, part
// of the anchor rather than body text.
let label: string | null = null;
if (child.getAttributeNS(W, 'customMarkFollows') === '1' || child.getAttributeNS(W, 'customMarkFollows') === 'true') {
label = (child.parentElement?.getElementsByTagNameNS(W, 't')[0]?.textContent ?? '') || null;
if (label) customMark = label;
}
const ref = hfFields ? null : noteRefNode(child.getAttributeNS(W, 'id'), kind, ctx, baseRun, defaults, label);
if (ref) out.push(ref);
break;
}