From 9578dea57e61fbcffd72b12764bf9c03016c86bb Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sat, 29 Aug 2026 06:17:36 +0000 Subject: [PATCH] fix: watermark and fold marks on first/even pages, border wraps the hf band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The decor shapes now ride every header variant: DOCX gets an empty w:type=first/even header part for the post-passes to fill, ODT the same splice into style:header-first/-left. The editor's page border now starts at the zone's edge distance where a header/footer exists — both word processors draw the band inside the border (probed). --- src/lib/components/CLAUDE.md | 4 +- src/lib/components/Editor.svelte | 13 ++++++- src/lib/components/PageDecorLayer.svelte | 11 ++++-- src/lib/export/docx.ts | 13 ++++--- src/lib/export/odt.ts | 48 +++++++++++------------- src/lib/import/odt.ts | 2 +- src/lib/import/styleResolver.ts | 2 +- 7 files changed, 54 insertions(+), 39 deletions(-) diff --git a/src/lib/components/CLAUDE.md b/src/lib/components/CLAUDE.md index 3cc2af0..481881b 100644 --- a/src/lib/components/CLAUDE.md +++ b/src/lib/components/CLAUDE.md @@ -195,7 +195,9 @@ toolbar island overlays the row's top, so the pane takes a `margin-top` of Background, border and watermark, all page-level. The layer sits inside the scaled `.paper` at `z-index: -1` like `.hf-bg-layer`, drawing one border box and one watermark -per page in unscaled document px. The watermark is an `` because a +per page in unscaled document px. The border wraps the header/footer band (both word +processors draw it so): where any section has a zone, `hfInsets` replaces the page +margin with the zone's edge distance. The watermark is an `` because a fontwork shape **stretches** its text to its box instead of setting it at a size — a font size alone makes a short word far too small and a long one overflow the page. diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index c87666e..f6f218c 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -122,6 +122,17 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor'; chapterStarts = wantsChapters ? collectChapterStarts() : []; }); + // The page border wraps the header/footer band too (both word processors draw it + // so), starting at the zone's edge distance wherever any section has a zone. + let decorHfInsets = $derived({ + headerCm: [headerDoc, headerFirstDoc, headerEvenDoc, + ...extraHfSections.flatMap((s) => [s.header, s.headerFirst, s.headerEven])] + .some((d) => d && !hfIsEmpty(d)) ? (hfDistances ?? DEFAULT_HF_DISTANCES).header : null, + footerCm: [footerDoc, footerFirstDoc, footerEvenDoc, + ...extraHfSections.flatMap((s) => [s.footer, s.footerFirst, s.footerEven])] + .some((d) => d && !hfIsEmpty(d)) ? (hfDistances ?? DEFAULT_HF_DISTANCES).footer : null, + }); + // Apply the page margins + orientation to the :root CSS vars (visual padding, // page dimensions, and pagination all read these). DOM-only, safe in effects. $effect(() => { @@ -1452,7 +1463,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor'; {/if} - + {#if foldMarks} {/if} diff --git a/src/lib/components/PageDecorLayer.svelte b/src/lib/components/PageDecorLayer.svelte index dfb0ca9..004915c 100644 --- a/src/lib/components/PageDecorLayer.svelte +++ b/src/lib/components/PageDecorLayer.svelte @@ -5,24 +5,27 @@ // The page's border and watermark, one box per page — the background itself is a // custom property on .paper, which already paints the sheet. Geometry is unscaled // document px: the layer sits inside .paper, so the zoom transform covers it. - let { decor, pageBoxes, pageMargins }: { + let { decor, pageBoxes, pageMargins, hfInsets = { headerCm: null, footerCm: null } }: { decor: PageDecor; /** One box per page (Editor.svelte): a section on its own paper differs in size. */ pageBoxes: { top: number; left: number; height: number; width: number }[]; pageMargins: PageMargins; + /** Zone edge distances where a header/footer exists — the border wraps the band. */ + hfInsets?: { headerCm: number | null; footerCm: number | null }; } = $props(); // The watermark's aspect ratio, measured off LibreOffice's own shape. const WATERMARK_RATIO = 4.487; // The border rings the text area, grown by its own padding — where ODF's fo:padding - // and Word's w:space put it. + // and Word's w:space put it. A header/footer band sits inside the border (both word + // processors wrap it), so its edge distance replaces the margin there. let inset = $derived.by(() => { const pad = cmToPx(decor.border?.paddingCm ?? 0); const swap = (page: number) => pageMargins.mirrored === true && page % 2 === 0; return (page: number) => ({ - top: cmToPx(pageMargins.top) - pad, - bottom: cmToPx(pageMargins.bottom) - pad, + top: cmToPx(hfInsets.headerCm ?? pageMargins.top) - pad, + bottom: cmToPx(hfInsets.footerCm ?? pageMargins.bottom) - pad, left: cmToPx(swap(page) ? pageMargins.right : pageMargins.left) - pad, right: cmToPx(swap(page) ? pageMargins.left : pageMargins.right) - pad, }); diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 8e07f6e..ce6ca42 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -2605,15 +2605,18 @@ export async function buildDocx( const mkHeaders = (i: number) => { const s = setAt(i); const d = para(s.header), f = s.differentFirstPage ? para(s.headerFirst) : null, e = s.differentOddEven ? para(s.headerEven) : null; - // A watermark (and the fold marks) lives in a header part, so a document without - // one still needs the (empty) default header the post-passes inject into. - if (!d && !f && !e) return decor.watermark?.text || foldMarks ? { default: new Header({ children: [new Paragraph({})] }) } : undefined; + // A watermark (and the fold marks) lives in a header part, so every page variant + // needs one — empty where the zone has no text — for the post-passes to inject + // into; a variant without its own part would blank the decor on those pages. + const decorated = Boolean(decor.watermark?.text) || foldMarks; const h: { default?: Header; first?: Header; even?: Header } = {}; if (d) h.default = new Header({ children: [paragraphToDocx(d)] }); - else if (decor.watermark?.text || foldMarks) h.default = new Header({ children: [new Paragraph({})] }); + else if (decorated) h.default = new Header({ children: [new Paragraph({})] }); if (f) h.first = new Header({ children: [paragraphToDocx(f)] }); + else if (s.differentFirstPage && decorated) h.first = new Header({ children: [new Paragraph({})] }); if (e) h.even = new Header({ children: [paragraphToDocx(e)] }); - return h; + else if (s.differentOddEven && decorated) h.even = new Header({ children: [new Paragraph({})] }); + return Object.keys(h).length ? h : undefined; }; const mkFooters = (i: number) => { const s = setAt(i); diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 4386c49..74c98d5 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -2958,23 +2958,29 @@ function applyWatermarkOdf(odtBytes: Uint8Array, wm: Watermark | null): Uint8Arr ? styles : styles.replace(/')) { - out = out.replace(/(\s*]*>)?/, (m) => - m.includes('${shape}`); - } else { - // No header: give the master page one that reserves no band of its own. LibreOffice - // still floors the band at 0.499cm, so the body shifts by that much — its own - // watermark does the same. - const zone = `${shape}`; - out = out.replace(//, ''); - out = /]*\/>/.test(out) + files['styles.xml'] = strToU8(injectIntoHeaderZones(out, shape)); + return rezipOdt(files); +} + +// Splice out-of-flow decor shapes into EVERY header zone of the master page — the +// first-page and even-page variants show them too, so a zone left out would blank the +// decor on those pages. A document with no header gets one that reserves no band of +// its own (LibreOffice still floors the band at 0.499cm, as for its own watermark). +function injectIntoHeaderZones(styles: string, shapes: string): string { + if (!/]/.test(styles)) { + const zone = `${shapes}`; + const out = styles.replace(//, ''); + return /]*\/>/.test(out) ? out.replace(/(]*)\/>/, `$1>${zone}`) : out.replace(/(]*>)/, `$1${zone}`); } - files['styles.xml'] = strToU8(out); - return rezipOdt(files); + return styles.replace(/|(\s*]*>)?/g, (m) => { + if (m.endsWith('/>')) { + const tag = /style:header(?:-first|-left)?/.exec(m)![0]; + return `<${tag}>${shapes}`; + } + return m.includes('${shapes}`; + }); } // Fold + punch marks as named s in the master page's header — the one place @@ -3005,23 +3011,13 @@ function applyFoldMarksOdf(odtBytes: Uint8Array, on: boolean): Uint8Array { + line(`${FOLD_MARK_NAME}${i + 1}R`, rightX, mm, FOLD_MARK_LEN_MM)).join('') + line(`${FOLD_MARK_NAME}Punch`, cm(MARK_START_MM), PUNCH_MARK_MM, PUNCH_MARK_LEN_MM); - // Same splice as the watermark: declare draw:, mint the style, ride the header — + // Same splice as the watermark: declare draw:, mint the style, ride every header zone — // or give the master page a zero-height one. let out = styles.includes('xmlns:draw=') ? styles : styles.replace(/')) { - out = out.replace(/(\s*]*>)?/, (m) => - m.includes('${shapes}`); - } else { - const zone = `${shapes}`; - out = out.replace(//, ''); - out = /]*\/>/.test(out) - ? out.replace(/(]*)\/>/, `$1>${zone}`) - : out.replace(/(]*>)/, `$1${zone}`); - } - files['styles.xml'] = strToU8(out); + files['styles.xml'] = strToU8(injectIntoHeaderZones(out, shapes)); return rezipOdt(files); } diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index a5f519f..134fd3d 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -637,7 +637,7 @@ function convertLine(el: Element, ctx: Ctx): Node | null { function convertDrawElement(e: Element, ctx: Ctx): { inline?: Node; block?: Node } | null { // The watermark is not a drawing: it rides the page decoration instead // (storage/pageDecor.ts), so it must not also arrive as a shape in the header. - if (e.getAttributeNS(NS.draw, 'name') === WATERMARK_NAME) return null; + if (e.getAttributeNS(NS.draw, 'name')?.startsWith(WATERMARK_NAME)) return null; // Fold marks ride the flag (storage/foldMarks.ts), not the document, same rule. if (e.getAttributeNS(NS.draw, 'name')?.startsWith(FOLD_MARK_NAME)) { ctx.foldMarks = true; diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index c746d79..5d1ba94 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -787,7 +787,7 @@ export class StyleResolver { private watermark(pageName: string | null): Watermark | null { const master = this.masterPageEl(pageName); const shapes = master?.getElementsByTagNameNS(NS.draw, 'custom-shape') ?? []; - const shape = Array.from(shapes).find((s) => s.getAttributeNS(NS.draw, 'name') === WATERMARK_NAME); + const shape = Array.from(shapes).find((s) => s.getAttributeNS(NS.draw, 'name')?.startsWith(WATERMARK_NAME)); if (!shape) return null; const text = (shape.textContent ?? '').trim(); if (!text) return null;