diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 33bfb40..8a96300 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -2441,9 +2441,17 @@ function buildStyles(sheet: StyleSheet, used: Set, language?: { language slotted[slot] = rest; return false; }); + // LibreOffice's note-text look (probed off its own DOCX export): 10pt plus the 0.6cm + // hanging pair the marker's tab jumps into. The slot merge is shallow, so the factory's + // spacing must ride along inside the replaced paragraph block. + const noteText = { + paragraph: { spacing: { after: 0, line: 240, lineRule: LineRuleType.AUTO }, indent: { start: 340, hanging: 340 } }, + run: { size: 20 }, + }; return { default: { document: { run, paragraph: { spacing: { after: 0, line: 240, lineRule: LineRuleType.AUTO } } }, + footnoteText: noteText, endnoteText: noteText, ...slotted, }, // The document's named styles, chain intact — Word shows them in its style list. @@ -2502,7 +2510,12 @@ export async function buildDocx( 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 ?? []) })], + // The tab after the marker jumps to the note style's hanging indent (the gap the + // editor draws itself); the importer strips it back off. + children: [new Paragraph({ + style: kind === 'endnote' ? 'EndnoteText' : 'FootnoteText', + children: [new TextRun({ children: [new Tab()] }), ...inlineToRuns(note.content ?? [])], + })], }; } const body = (docJson.content ?? []).filter((n) => n.type !== 'noteSection'); diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 95c1756..e0b0a83 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -1283,19 +1283,24 @@ function noteRefNode(wid: string | null, kind: NoteKind, ctx: Ctx, baseRun: RunP } // Word opens the note with its own marker run and a tab; the editor draws both from // the note's own indent, so the leading tab would be a second one. - const first = content[0]; - if (first?.type === 'text' && typeof first.text === 'string') { - first.text = first.text.replace(/^\t+/, ''); - if (!first.text) content.shift(); - } + const stripLeadingTabs = () => { + const first = content[0]; + if (first?.type === 'text' && typeof first.text === 'string') { + first.text = first.text.replace(/^\t+/, ''); + if (!first.text) content.shift(); + } + }; + stripLeadingTabs(); const seen = ctx.notes.filter((n) => n.kind === kind).length; 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. + // sit; the editor draws the mark itself, so strip it off the body — and the tab + // again, which may ride a run of its own behind the mark. 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+/, ''); + bodyFirst.text = bodyFirst.text.slice(label.length); if (!bodyFirst.text) content.shift(); + stripLeadingTabs(); } const id = `${kind}${wid}`; // The note renders at the file's own size and indent: its first paragraph names the @@ -1320,7 +1325,11 @@ function isStockNoteStyle(ctx: Ctx, styleId: string): boolean { if (!keys.every((k) => (run[k] ?? null) === (base[k] ?? null))) return false; if (ctx.styles.paragraphAlign(styleId) !== ctx.styles.paragraphAlign(null)) return false; const sp = ctx.styles.paragraphSpacing(styleId); - return !sp.before && !sp.after && (ctx.styles.styleIndentTwip(styleId) ?? 0) === 0; + if (sp.before || sp.after) return false; + // LibreOffice's stock hanging pair (0.6cm both ways, the marker tab's jump) nets to + // zero: the editor draws that indent itself. + const ind = ctx.styles.styleIndentTwip(styleId) ?? 0; + return ind === 0 || ind === (ctx.styles.styleHangingTwip(styleId) ?? 0); } function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockDefaults, hfFields: boolean): Node[] { diff --git a/src/lib/import/docxStyles.ts b/src/lib/import/docxStyles.ts index f3866a8..2ed9508 100644 --- a/src/lib/import/docxStyles.ts +++ b/src/lib/import/docxStyles.ts @@ -172,6 +172,7 @@ export class DocxStyles { private ownAlign = new Map(); // style's own w:pPr/w:jc private ownSpacing = new Map(); // style's own w:pPr/w:spacing private ownIndentTwip = new Map(); // style's own w:pPr/w:ind left + private ownHangingTwip = new Map(); // style's own w:pPr/w:ind hanging private paraStyleNames = new Map(); // paragraph styleId → w:name private charStyleNames = new Map(); // character styleId → w:name private tableStyleNames = new Map(); // table styleId → w:name @@ -268,6 +269,8 @@ export class DocxStyles { if (ind) { const left = parseInt(ind.getAttributeNS(W, 'left') ?? ind.getAttributeNS(W, 'start') ?? '', 10); if (Number.isFinite(left)) this.ownIndentTwip.set(id, left); + const hanging = parseInt(ind.getAttributeNS(W, 'hanging') ?? '', 10); + if (Number.isFinite(hanging)) this.ownHangingTwip.set(id, hanging); } const pBdr = ppr && firstChild(ppr, 'pBdr'); if (pBdr) this.ownPBdr.set(id, pBdr); @@ -336,6 +339,14 @@ export class DocxStyles { return this.styleIndentTwip(this.basedOn.get(styleId) ?? null, seen); } + styleHangingTwip(styleId: string | null | undefined, seen = new Set()): number | null { + if (!styleId || seen.has(styleId)) return null; + seen.add(styleId); + const own = this.ownHangingTwip.get(styleId); + if (own != null) return own; + return this.styleHangingTwip(this.basedOn.get(styleId) ?? null, seen); + } + // The style Word applies when a paragraph names none. defaultParagraphStyle(): string | null { return this.defaultParaStyle;