diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 3ea0761..50f4a6f 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -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(); +let docNoteIds = new Map(); // 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 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 = `${escapeXml(n.label!)}`; + patch('word/document.xml', + new RegExp(``), + `${mark}`); + patch(`word/${n.kind}s.xml`, + new RegExp(`((?:(?!)[\\s\\S])*?)`), + `$1${mark}`); + } + const out: Record = {}; + 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 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; diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index ce39bcb..2f85201 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -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>; - 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 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; }