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:
1 parent
7ce97b1d50
commit
aac93e22e8
3 files changed
+42
-9
No files matched your search
+14
-1
@@ -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
@@ -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[] {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user