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:
1 parent
5dab73ac3a
commit
044c59d895
2 files changed
+52
-9
No files matched your search
+28
-3
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user