fix: watermark and fold marks on first/even pages, border wraps the hf band

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).
This commit is contained in:
Steffen Becker committed 2026-08-30 10:06:42 +00:00
1 parent 3d305a9920
commit 9578dea57e
7 files changed
+54 -39

No files matched your search

+3 -1
View File
@@ -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 `<svg><text textLength>` 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 `<svg><text textLength>` 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.
+12 -1
View File
@@ -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';
</div>
{/if}
<PageSheetLayer {pageBoxes} />
<PageDecorLayer decor={pageDecor} {pageBoxes} {pageMargins} />
<PageDecorLayer decor={pageDecor} {pageBoxes} {pageMargins} hfInsets={decorHfInsets} />
{#if foldMarks}
<FoldMarkLayer {pageBoxes} />
{/if}
+7 -4
View File
@@ -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,
});
+8 -5
View File
@@ -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);
+22 -26
View File
@@ -2958,23 +2958,29 @@ function applyWatermarkOdf(odtBytes: Uint8Array, wm: Watermark | null): Uint8Arr
? styles
: styles.replace(/<office:document-styles\b/, '<office:document-styles xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"');
out = injectAutomaticStyles(out, minted);
// Into the header the document already has — the shape is out of flow, so it changes
// neither the band nor the zone's text.
if (out.includes('<style:header>')) {
out = out.replace(/<style:header>(\s*<text:p[^>]*>)?/, (m) =>
m.includes('<text:p') ? `${m}${shape}` : `${m}<text:p text:style-name="Header">${shape}</text:p>`);
} 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 = `<style:header><text:p text:style-name="Header">${shape}</text:p></style:header>`;
out = out.replace(/<style:header-style\s*\/>/, '<style:header-style><style:header-footer-properties fo:min-height="0cm" fo:margin-bottom="0cm"/></style:header-style>');
out = /<style:master-page\b[^>]*\/>/.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 (!/<style:header[\s/>]/.test(styles)) {
const zone = `<style:header><text:p text:style-name="Header">${shapes}</text:p></style:header>`;
const out = styles.replace(/<style:header-style\s*\/>/, '<style:header-style><style:header-footer-properties fo:min-height="0cm" fo:margin-bottom="0cm"/></style:header-style>');
return /<style:master-page\b[^>]*\/>/.test(out)
? out.replace(/(<style:master-page\b[^>]*)\/>/, `$1>${zone}</style:master-page>`)
: out.replace(/(<style:master-page\b[^>]*>)/, `$1${zone}`);
}
files['styles.xml'] = strToU8(out);
return rezipOdt(files);
return styles.replace(/<style:header(-first|-left)?\s*\/>|<style:header(?:-first|-left)?>(\s*<text:p[^>]*>)?/g, (m) => {
if (m.endsWith('/>')) {
const tag = /style:header(?:-first|-left)?/.exec(m)![0];
return `<${tag}><text:p text:style-name="Header">${shapes}</text:p></${tag}>`;
}
return m.includes('<text:p') ? `${m}${shapes}` : `${m}<text:p text:style-name="Header">${shapes}</text:p>`;
});
}
// Fold + punch marks as named <draw:line>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(/<office:document-styles\b/, '<office:document-styles xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"');
out = injectAutomaticStyles(out, minted);
if (out.includes('<style:header>')) {
out = out.replace(/<style:header>(\s*<text:p[^>]*>)?/, (m) =>
m.includes('<text:p') ? `${m}${shapes}` : `${m}<text:p text:style-name="Header">${shapes}</text:p>`);
} else {
const zone = `<style:header><text:p text:style-name="Header">${shapes}</text:p></style:header>`;
out = out.replace(/<style:header-style\s*\/>/, '<style:header-style><style:header-footer-properties fo:min-height="0cm" fo:margin-bottom="0cm"/></style:header-style>');
out = /<style:master-page\b[^>]*\/>/.test(out)
? out.replace(/(<style:master-page\b[^>]*)\/>/, `$1>${zone}</style:master-page>`)
: out.replace(/(<style:master-page\b[^>]*>)/, `$1${zone}`);
}
files['styles.xml'] = strToU8(out);
files['styles.xml'] = strToU8(injectIntoHeaderZones(out, shapes));
return rezipOdt(files);
}
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;