fix: a note opens with a tab into LibreOffice's hanging pair in DOCX

FootnoteText/EndnoteText ride the factory slots with the probed 0.6cm
start/hanging pair, and the note body puts a tab run after the marker,
matching LibreOffice's own DOCX export; Word no longer glues the number
to the text. The importer strips the tab (also behind a custom mark on
a run of its own) and counts an equal start/hanging pair as the stock
note look, so neither a styleName nor an indent accretes on re-import.
This commit is contained in:
Steffen Becker committed 2026-08-30 10:06:41 +00:00
1 parent 7ce97b1d50
commit aac93e22e8
3 files changed
+42 -9

No files matched your search

+14 -1
View File
@@ -2441,9 +2441,17 @@ function buildStyles(sheet: StyleSheet, used: Set<string>, 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');
+17 -8
View File
@@ -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 <w:footnoteRef/> 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[] {
+11
View File
@@ -172,6 +172,7 @@ export class DocxStyles {
private ownAlign = new Map<string, string>(); // style's own w:pPr/w:jc
private ownSpacing = new Map<string, ParaSpacing>(); // style's own w:pPr/w:spacing
private ownIndentTwip = new Map<string, number>(); // style's own w:pPr/w:ind left
private ownHangingTwip = new Map<string, number>(); // style's own w:pPr/w:ind hanging
private paraStyleNames = new Map<string, string>(); // paragraph styleId → w:name
private charStyleNames = new Map<string, string>(); // character styleId → w:name
private tableStyleNames = new Map<string, string>(); // 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<string>()): 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;