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:
1 parent
3d305a9920
commit
9578dea57e
7 files changed
+54
-39
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user