diff --git a/src/lib/components/CLAUDE.md b/src/lib/components/CLAUDE.md index 8dff2c2..5450cf2 100644 --- a/src/lib/components/CLAUDE.md +++ b/src/lib/components/CLAUDE.md @@ -47,7 +47,7 @@ Both anchors differ: a wheel zoom holds the **point under the cursor** fixed (`p ## Headers & footers (`HeaderFooterLayer.svelte`, `storage/headerFooter.ts`) One header and one footer (`HfDoc` = a single-paragraph TipTap doc per zone, persisted to `odf-editor-header`/`-footer`), repeated on every page. Double-clicking a page margin — or the Layout-panel "Edit header/footer" buttons — opens the zone for editing; clicking back into the body (`onFocus`) or Escape ends it. The edge→zone distance (header from top, footer from bottom) is user-configurable in cm via the Layout panel (`HfDistances`, persisted to `odf-editor-hf-distances`, default `HF_DISTANCE_CM` 1.27cm, clamped 0–10 and below the body margin). -- **Rendering:** `HeaderFooterLayer.svelte` mounts inside the scaled `.paper` (like `.band-layer`), positioning per-page zone boxes in unscaled doc px. Inactive zones render as static HTML (`generateHTML` + `hfExtensions`); the active zone hosts the single live TipTap editor. `App.svelte`'s `activeEditor`/`activeTick` route the top toolbars to `hfEditor` while a zone is active, so all body formatting works on the header/footer with no toolbar changes. +- **Rendering:** `HeaderFooterLayer.svelte` mounts inside the scaled `.paper` (like `.band-layer`), positioning per-page zone boxes in unscaled doc px. A zone's out-of-flow frame (`wrap` ≠ inline) is hidden in the box and painted per page by `.hf-bg-layer` at `z-index: -1` — hence `.paper`, not `.tiptap`, paints the page surface, so the image lands between the sheet and the body text. Inactive zones render as static HTML (`generateHTML` + `hfExtensions`); the active zone hosts the single live TipTap editor. `App.svelte`'s `activeEditor`/`activeTick` route the top toolbars to `hfEditor` while a zone is active, so all body formatting works on the header/footer with no toolbar changes. ## Debug tooling (dev only) diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index ea7a9fe..b005825 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -74,14 +74,15 @@ const HF_LINE_PX = 16 * 1.15; function hfReachPx(doc: HfDoc, distPx: number): number { if (!doc || hfIsEmpty(doc)) return 0; - const inline = ((doc.content?.[0] as { content?: { type?: string; attrs?: { height?: number } }[] } | undefined)?.content ?? []); + const inline = ((doc.content?.[0] as { content?: { type?: string; attrs?: { height?: number; wrap?: string } }[] } | undefined)?.content ?? []); // Per line, since an as-character image (a letterhead logo) makes its own line - // as tall as it is; the others are one text line each. + // as tall as it is; the others are one text line each. A positioned frame is out + // of flow — a page-sized background would otherwise reserve the whole page. let total = 0; let image = 0; for (const n of inline) { if (n.type === 'hardBreak') { total += Math.max(HF_LINE_PX, image); image = 0; } - else if (n.type === 'image' && typeof n.attrs?.height === 'number') image = Math.max(image, n.attrs.height); + else if (n.type === 'image' && typeof n.attrs?.height === 'number' && (n.attrs.wrap ?? 'inline') === 'inline') image = Math.max(image, n.attrs.height); } return distPx + total + Math.max(HF_LINE_PX, image); } diff --git a/src/lib/components/HeaderFooterLayer.svelte b/src/lib/components/HeaderFooterLayer.svelte index 487ad1a..cb064c6 100644 --- a/src/lib/components/HeaderFooterLayer.svelte +++ b/src/lib/components/HeaderFooterLayer.svelte @@ -114,6 +114,25 @@ return ''; } } + // A positioned frame in a zone is out of flow: the page-anchored letterhead or + // watermark a title page is made of. It paints once per page from the page corner, + // behind the body, and the zone's own box never sees it (editor.css hides it there). + type PageBg = { src: string; x: number; y: number; width: number; height: number }; + function backgrounds(doc: HfDoc): PageBg[] { + const inline = ((doc?.content?.[0] as { content?: { type?: string; attrs?: Record }[] } | undefined)?.content ?? []); + const out: PageBg[] = []; + for (const n of inline) { + if (n.type !== 'image' || typeof n.attrs?.src !== 'string' || (n.attrs.wrap ?? 'inline') === 'inline') continue; + out.push({ + src: n.attrs.src, + x: cmToPx(Number(n.attrs.wrapOffset) || 0), + y: cmToPx(Number(n.attrs.wrapOffsetY) || 0), + width: Number(n.attrs.width) || pageWidthPx, + height: Number(n.attrs.height) || pageHeightPx, + }); + } + return out; + } // Section 1 is the app's own editable state; the rest live in extraHfSections. let sets = $derived([ { @@ -130,6 +149,11 @@ headerFirst: staticHtml(s.headerFirst), footerFirst: staticHtml(s.footerFirst), headerEven: staticHtml(s.headerEven), footerEven: staticHtml(s.footerEven), }))); + let setBg = $derived(sets.map((s) => ({ + header: backgrounds(s.header), footer: backgrounds(s.footer), + headerFirst: backgrounds(s.headerFirst), footerFirst: backgrounds(s.footerFirst), + headerEven: backgrounds(s.headerEven), footerEven: backgrounds(s.footerEven), + }))); // Which section a page belongs to: the count of section starts at or before it, // clamped to what the document actually carries. @@ -161,6 +185,13 @@ if (zone === 'header') return v === 'first' ? h.headerFirst : v === 'even' ? h.headerEven : h.header; return v === 'first' ? h.footerFirst : v === 'even' ? h.footerEven : h.footer; } + function zoneBackgrounds(zone: HfZone, page: number): PageBg[] { + const index = sectionOf(page); + const v = variantFor(page, index); + const b = setBg[index] ?? setBg[0]; + if (zone === 'header') return v === 'first' ? b.headerFirst : v === 'even' ? b.headerEven : b.header; + return v === 'first' ? b.footerFirst : v === 'even' ? b.footerEven : b.footer; + } // Lay out the zone's tabs: static HTML no ProseMirror plugin reaches. The advances are // layout px, so only a content change invalidates them — not the zoom transform. @@ -299,6 +330,23 @@ } + +
+ {#each pages as p} + {#each ['header', 'footer'] as const as zone} + {#each zoneBackgrounds(zone, p) as bg} + + {/each} + {/each} + {/each} +
+
{#each pages as p} {#each ['header', 'footer'] as const as zone} @@ -354,6 +402,21 @@ pointer-events: none; } + .hf-bg-layer { + position: absolute; + inset: 0; + z-index: -1; + pointer-events: none; + } + .hf-page-bg { + position: absolute; + } + /* The frame is painted by .hf-bg-layer; inside the zone it would grow the box and, + while editing, flow as a line. Selecting it still works from the keyboard. */ + .hf-zone :global([data-wrap]) { + display: none; + } + .hf-zone { position: absolute; display: flex; diff --git a/src/lib/editor/extensions/image.ts b/src/lib/editor/extensions/image.ts index 1f2ca39..a427b9d 100644 --- a/src/lib/editor/extensions/image.ts +++ b/src/lib/editor/extensions/image.ts @@ -305,6 +305,9 @@ class ImageView { private applyWrap(): void { const d = this.dom; const wrap = this.attrWrap(); + // Mirrors renderHTML's data-wrap, so one selector reaches a floating frame in the + // live view and in generated static HTML alike. + if (wrap === 'inline') delete d.dataset.wrap; else d.dataset.wrap = wrap; d.style.float = ''; d.style.display = ''; d.style.clear = ''; diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md index f041150..26b661b 100644 --- a/src/lib/export/CLAUDE.md +++ b/src/lib/export/CLAUDE.md @@ -38,7 +38,7 @@ The filename is derived from the first non-empty heading (max 50 chars, sanitize ## Header/footer export -- **Export:** a synthetic `__cust_hf__` node routes to `unknownNodeHandler`, which calls odf-kit's `setHeader`/`setFooter` (`applyHfRuns`); `hardBreak`→`LBR`, `pageNumber`→`addPageNumber`, `pageCount`→`PGC` sentinel. `applyHfPostProcess` (on styles.xml) rewrites the sentinels to ``/``, applies the paragraph alignment to the `Header`/`Footer` styles, and converts geometry to Word's model: page margin = the zone's edge distance (`headerDistanceCm`/`footerDistanceCm`), header/footer `min-height` = body margin − distance, so the **body** still starts at the editor's margin. +- **Export:** a synthetic `__cust_hf__` node routes to `unknownNodeHandler`, which calls odf-kit's `setHeader`/`setFooter` (`applyHfRuns`); `hardBreak`→`LBR`, `pageNumber`→`addPageNumber`, `pageCount`→`PGC` sentinel. A zone image is an as-char ``, or — floating — a page-anchored one plus a minted `HfBg*` graphic style (`run-through` behind the text, positioned from the page corner). `applyHfPostProcess` (on styles.xml) rewrites the sentinels to ``/``, applies the paragraph alignment to the `Header`/`Footer` styles, and converts geometry to Word's model: page margin = the zone's edge distance (`headerDistanceCm`/`footerDistanceCm`), header/footer `min-height` = body margin − distance, so the **body** still starts at the editor's margin. Sentinel order matters — the passes assume it. Full list of sentinels in use: `LBR`/`TAB` (inline), `IMG`, `SEG` (cell blocks), `TBX` U+E008 (text boxes), diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 66e2282..73982e4 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -2799,19 +2799,38 @@ function imageFrameXml(img: ImageExport, index: number): string { ); } -// As-character for a header/footer image (always inline; no wrap style or -// rotation). Distinct draw:name so it never collides with a body image's frame. +// for a header/footer image. As-character by default; a positioned frame +// (a page-anchored background) instead rides the zone paragraph at its page corner +// offset, run through behind the text. Distinct draw:name per frame. function hfImageFrameXml(img: ImageExport, index: number): string { const dims = (img.widthCm ? ` svg:width="${img.widthCm}cm"` : '') + (img.heightCm ? ` svg:height="${img.heightCm}cm"` : ''); const title = img.alt ? `${escapeXml(img.alt)}` : ''; + const anchor = + img.wrap === 'inline' + ? `text:anchor-type="as-char"` + : `text:anchor-type="paragraph" draw:style-name="${HF_BG_STYLE}${index + 1}"` + + ` svg:x="${img.wrapOffsetCm ?? 0}cm" svg:y="${img.wrapOffsetYCm ?? 0}cm"`; return ( - `` + + `` + `${title}` ); } +const HF_BG_STYLE = 'HfBg'; + +// The graphic style a page-anchored zone frame points at: positioned from the page's +// top-left corner and run through behind the text, as LibreOffice writes a watermark. +function hfBackgroundStyleXml(index: number): string { + return ( + `` + + `` + ); +} + // Inject automatic styles, tolerating an empty/self-closed or absent section. function injectAutomaticStyles(content: string, styles: string): string { if (!styles) return content; @@ -3580,6 +3599,11 @@ function applyHfPostProcess(odtBytes: Uint8Array, margins: PageMargins, headerPa const img = hfImages[Number(idx)]; return img ? hfImageFrameXml(img, Number(idx)) : ''; }); + const bgStyles = hfImages.map((img, i) => (img.wrap === 'inline' ? '' : hfBackgroundStyleXml(i))).join(''); + if (bgStyles) { + if (styles.includes('')) styles = styles.replace('', `${bgStyles}`); + else styles = styles.replace('', `${bgStyles}`); + } for (const img of hfImages) files[img.path] = img.bytes; const manifestBytes = files['META-INF/manifest.xml']; if (manifestBytes) { diff --git a/src/lib/export/pdf.ts b/src/lib/export/pdf.ts index 1b8e2a4..8050cc7 100644 --- a/src/lib/export/pdf.ts +++ b/src/lib/export/pdf.ts @@ -67,7 +67,8 @@ function buildClone(paper: HTMLElement, pageW: number): { holder: HTMLElement; c const style = document.createElement('style'); style.textContent = ` [data-pdf-export] .paper, [data-pdf-export] .paper .tiptap { box-shadow:none !important; } -[data-pdf-export] .paper .tiptap { background:#fff !important; } +[data-pdf-export] .paper { background:#fff !important; } +[data-pdf-export] .paper .tiptap { background:none !important; } [data-pdf-export] .column-resize-handle, [data-pdf-export] .row-resize-handle { display:none !important; } [data-pdf-export] .selectedCell::after { display:none !important; } [data-pdf-export] .tiptap .is-editor-empty::before { content:none !important; } @@ -471,8 +472,8 @@ function printCss(o: PrintPdfOptions): string { ${o.differentOddEven ? `@page :left {\n ${marginBoxesFirst(o.headerEvenDoc ?? null, o.footerEvenDoc ?? null)}\n}` : ''} ${o.differentFirstPage ? `@page :first {\n ${marginBoxesFirst(o.headerFirstDoc ?? null, o.footerFirstDoc ?? null)}\n}` : ''} html, body { margin: 0; padding: 0; background: #fff; } -.paper { width: auto !important; transform: none !important; box-shadow: none !important; background: none !important; } -.paper .tiptap { padding: 0 !important; min-height: 0 !important; background: #fff !important; box-shadow: none !important; color: #000 !important; } +.paper { width: auto !important; transform: none !important; box-shadow: none !important; background: #fff !important; } +.paper .tiptap { padding: 0 !important; min-height: 0 !important; background: none !important; box-shadow: none !important; color: #000 !important; } /* generateHTML drops TipTap's .tableWrapper; mirror its (zero) margin. */ .paper .tiptap table { margin: 0; } .paper .tiptap tr { break-inside: avoid; } diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index f5be813..4733eaf 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -23,7 +23,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt( ## Header/footer import -- **Import:** `StyleResolver.masterPageHF()` reads `style:header`/`style:footer` + the header/footer-style heights; `pageGeometry()` reconstructs the body margin (page margin + zone height + spacing) and `edgeDistancesCm()` returns the raw page margins as the zone distances. The header↔body spacing is only reserved when `style:dynamic-spacing="false"` — a `true` (LibreOffice's usual) means the gap collapses, so reserving it would shove the body down. `convertHfZone` → single-paragraph doc (extra paragraphs → hard breaks; a text-less zone that carries a background/border rule line is kept, box props merged with the bottom rule from the last paragraph); `convertInline(…, hfFields=true)` keeps `text:page-number`/`-count` as field nodes. A master page whose `style:next-style-name` points at *another* master governs one page only (the "different first page" idiom): its own zones become the first-page variants and the successor's the running ones — read as the running header instead, a title-page master repeats on every page of its section and its height pushes each block onto its own page. As in DOCX, a zone's positioned (non-`as-char`) frames are dropped with a warning: the one-paragraph zone can't place them, and a page-sized background reserves the whole page inline. `hfIsEmpty` counts a rule-/shading-only paragraph as non-empty so it renders and exports. +- **Import:** `StyleResolver.masterPageHF()` reads `style:header`/`style:footer` + the header/footer-style heights; `pageGeometry()` reconstructs the body margin (page margin + zone height + spacing) and `edgeDistancesCm()` returns the raw page margins as the zone distances. The header↔body spacing is only reserved when `style:dynamic-spacing="false"` — a `true` (LibreOffice's usual) means the gap collapses, so reserving it would shove the body down. `convertHfZone` → single-paragraph doc (extra paragraphs → hard breaks; a text-less zone that carries a background/border rule line is kept, box props merged with the bottom rule from the last paragraph); `convertInline(…, hfFields=true)` keeps `text:page-number`/`-count` as field nodes. A master page whose `style:next-style-name` points at *another* master governs one page only (the "different first page" idiom): its own zones become the first-page variants and the successor's the running ones — read as the running header instead, a title-page master repeats on every page of its section and its height pushes each block onto its own page. A zone's positioned (non-`as-char`) frame keeps its wrap and its `svg:x`/`svg:y` from the page corner: it is out of flow (a letterhead or full-page title background), so `HeaderFooterLayer` paints it per page behind the body and the zone's reach ignores it — inline it would reserve a page-tall line of body space. Text boxes there are still dropped with a warning. `hfIsEmpty` counts a rule-/shading-only paragraph as non-empty so it renders and exports. ## Defaults on DOCX import diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index c1382f9..903b3b9 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -1433,13 +1433,17 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: Bl } if (e.namespaceURI === NS.draw) { const conv = convertDrawElement(e, ctx); - // The one-paragraph zone takes as-char images only — boxes and positioned - // frames (a page-sized background reserves the whole page inline) are dropped - // with a warning, as the DOCX importer does. + // The one-paragraph zone flows as-char images; a positioned frame is out of + // flow — the page-anchored letterhead or watermark a title page is made of — + // and keeps its wrap plus its position from the page corner. Boxes are dropped. if (hfFields) { - const wrap = conv?.inline?.attrs?.wrap; - if (conv?.inline?.type === 'image' && (!wrap || wrap === 'inline')) { - out.push({ ...conv.inline, attrs: { ...conv.inline.attrs, wrap: 'inline' } }); + const img = conv?.inline?.type === 'image' ? conv.inline : null; + const wrap = img?.attrs?.wrap; + if (img && (!wrap || wrap === 'inline')) { + out.push({ ...img, attrs: { ...img.attrs, wrap: 'inline' } }); + } else if (img) { + const at = (a: string) => Math.max(0, lengthToCm(e.getAttributeNS(NS.svg, a)) ?? 0); + out.push({ ...img, attrs: { ...img.attrs, wrapOffset: at('x'), wrapOffsetY: at('y') } }); } else if (conv) ctx.warnings.add('Drawings were removed'); continue; } diff --git a/src/styles/editor.css b/src/styles/editor.css index 46fe1e0..5cee549 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -19,6 +19,12 @@ /* A4 paper container (794px = 210mm at 96dpi by default; --user-page-width is set from the Layout panel and swaps to 1123px in landscape — pageOrientation.ts) */ .paper { + /* Driven by --user-page-height (pageOrientation.ts); fallback = A4 portrait. The + page geometry lives here, not on .tiptap, so both inherit the same values. */ + --page-height: var(--user-page-height, 1123px); + --page-gap: 20px; + --page-cycle: calc(var(--page-height) + var(--page-gap)); + width: var(--user-page-width, 794px); /* Zoom is a `transform: scale()` set inline (Editor.svelte); pin the origin so the page scales down/right from the top-left of its reserved footprint. */ @@ -30,19 +36,28 @@ /* A frame or a formula wider than the text area is cut at the sheet's edge, as it is in LibreOffice. `clip` rather than `hidden`: it must not become a scroll box. */ overflow-x: clip; + + /* The page surface (white) alternating with the gap, where .tiptap paints only the + borders on top of it. It sits here so a header's page background (.hf-bg-layer, + z-index -1) can paint between the sheet and the body text. */ + background: repeating-linear-gradient( + to bottom, + var(--color-page-bg) 0px, + var(--color-page-bg) var(--page-height), + transparent var(--page-height), + transparent var(--page-cycle) + ); + background-size: 100% var(--page-cycle); + background-repeat: repeat-y; } .paper .tiptap { - /* Driven by --user-page-height (pageOrientation.ts); fallback = A4 portrait. */ - --page-height: var(--user-page-height, 1123px); - --page-gap: 20px; /* Driven by --user-margin-* set on :root from the Layout panel (pageMargins.ts). Fallbacks preserve the original hardcoded layout when unset. */ --page-margin-top: var(--user-margin-top, 96px); --page-margin-bottom: var(--user-margin-bottom, 96px); --page-margin-left: var(--user-margin-left, 80px); --page-margin-right: var(--user-margin-right, 80px); - --page-cycle: calc(var(--page-height) + var(--page-gap)); position: relative; width: 100%; @@ -60,7 +75,7 @@ line-height: 1.15; color: var(--color-page-text); - /* Multi-page background: page surfaces separated by editor background */ + /* Page borders, drawn over the sheet .paper paints */ background: /* Left border per page */ repeating-linear-gradient( @@ -89,24 +104,14 @@ var(--color-page-border) var(--page-height), transparent var(--page-height), transparent var(--page-cycle) - ), - /* Page surface (white) alternating with gap (transparent = editor bg shows) */ - repeating-linear-gradient( - to bottom, - var(--color-page-bg) 0px, - var(--color-page-bg) var(--page-height), - transparent var(--page-height), - transparent var(--page-cycle) ); background-size: 1px var(--page-cycle), 1px var(--page-cycle), - 100% var(--page-cycle), 100% var(--page-cycle); background-position: left top, right top, - left top, left top; background-repeat: repeat-y; @@ -826,10 +831,11 @@ width: 100%; max-width: none; transform: none; + background: white !important; } .paper .tiptap { - background: white !important; + background: none !important; box-shadow: none; border: none; padding: 0;