From 13e026fb8b13ced0a2b969b0456b2200ff7126a1 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Mon, 10 Aug 2026 21:31:38 +0000 Subject: [PATCH] fix: a zone's own margins survive the ODF round trip The Header/Footer style carries them, and the band that grows to hold them is rebuilt from there on the way back in. --- src/lib/export/odt.ts | 5 +++++ tests/unit/hf-zone-margins.test.ts | 34 ++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 tests/unit/hf-zone-margins.test.ts diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index cf283d5..c95a13b 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -3637,6 +3637,11 @@ function hfParaPropsXml(para: TiptapNode): string { if (align) props.push(`fo:text-align="${align}"`); const s = paraStyleFromAttrs(para.attrs); if (s.background) props.push(`fo:background-color="${s.background}"`); + // The zone's own margins, which grow the band on the way back in (import/CLAUDE.md). + for (const [attr, side] of [['spaceBefore', 'top'], ['spaceAfter', 'bottom']] as const) { + const v = para.attrs?.[attr]; + if (typeof v === 'number' && v) props.push(`fo:margin-${side}="${v}pt"`); + } for (const [attr, side] of [ ['borderTop', 'top'], ['borderRight', 'right'], ['borderBottom', 'bottom'], ['borderLeft', 'left'], ] as const) { diff --git a/tests/unit/hf-zone-margins.test.ts b/tests/unit/hf-zone-margins.test.ts new file mode 100644 index 0000000..296722a --- /dev/null +++ b/tests/unit/hf-zone-margins.test.ts @@ -0,0 +1,34 @@ +// A header/footer zone's own vertical margins: LibreOffice keeps them on the Header / +// Footer paragraph style, and a footer's space above is what grows the band. +import { describe, it, expect } from 'vitest'; +import { unzipSync, strFromU8 } from 'fflate'; +import { buildOdt } from '../../src/lib/export/odt'; +import { importOdt } from '../../src/lib/import/odt'; + +type N = any; + +const doc: N = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Body' }] }] }; +const zone = (attrs: N, text: string): N => ({ + type: 'doc', + content: [{ type: 'paragraph', attrs, content: [{ type: 'text', text }] }], +}); +const hf = { + header: zone({ spaceAfter: 34 }, 'Kopf'), + footer: zone({ spaceBefore: 24 }, 'Fuss'), + headerFirst: null, footerFirst: null, differentFirstPage: false, + headerEven: null, footerEven: null, differentOddEven: false, + pageCount: 1, +}; + +describe('header/footer zone margins', () => { + it('ride the ODF zone styles and come back', async () => { + const bytes = await buildOdt(doc, undefined, 'portrait', hf); + const styles = strFromU8(unzipSync(bytes)['styles.xml']); + expect(styles).toContain('fo:margin-bottom="34pt"'); + expect(styles).toContain('fo:margin-top="24pt"'); + + const back = await importOdt(bytes); + expect(back.header?.content?.[0]?.attrs?.spaceAfter).toBeCloseTo(34, 1); + expect(back.footer?.content?.[0]?.attrs?.spaceBefore).toBeCloseTo(24, 1); + }); +});