From dce22ab3f47765981d448e03fb319892bfd6f3fe Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sat, 18 Jul 2026 11:49:49 +0200 Subject: [PATCH] fix: header and footer editor --- src/App.svelte | 44 +++++- src/lib/components/Editor.svelte | 38 ++++- src/lib/components/HeaderFooterLayer.svelte | 147 +++++++++++++++++--- src/lib/components/ToolbarExpanded.svelte | 23 ++- src/lib/editor/extensions/headerFooter.ts | 14 +- src/lib/editor/extensions/pageBreaks.ts | 28 +++- src/lib/export/docx.ts | 27 +++- src/lib/export/odt.ts | 123 +++++++++++++++- src/lib/export/pdf.ts | 32 ++++- src/lib/i18n/locales/de.ts | 6 + src/lib/i18n/locales/en.ts | 6 + src/lib/import/docx.ts | 73 ++++++---- src/lib/import/odt.ts | 40 ++++-- src/lib/import/styleResolver.ts | 16 ++- src/lib/storage/headerFooter.ts | 32 +++-- tests/docx-roundtrip.test.ts | 55 ++++++++ tests/roundtrip.test.ts | 64 ++++++++- tests/unit/hf-enter.test.ts | 38 +++++ 18 files changed, 713 insertions(+), 93 deletions(-) create mode 100644 tests/unit/hf-enter.test.ts diff --git a/src/App.svelte b/src/App.svelte index d6f0b63..da93d3d 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -21,7 +21,7 @@ import { loadPageMargins, savePageMargins, DEFAULT_MARGINS, type PageMargins } from './lib/storage/pageMargins'; import { loadOrientation, saveOrientation, type Orientation } from './lib/storage/pageOrientation'; import { loadPageFormat, savePageFormat, type PageFormat } from './lib/storage/pageFormat'; - import { loadHfDoc, saveHfDoc, loadHfDistances, saveHfDistances, hfIsEmpty, DEFAULT_HF_DISTANCES, type HfDoc, type HfZone, type HfDistances } from './lib/storage/headerFooter'; + import { loadHfDoc, saveHfDoc, loadHfDistances, saveHfDistances, loadDifferentFirstPage, saveDifferentFirstPage, hfIsEmpty, DEFAULT_HF_DISTANCES, type HfDoc, type HfZone, type HfDistances } from './lib/storage/headerFooter'; import { loadDocName, saveDocName, stripOdtExtension, sanitizeNameForFile } from './lib/storage/documentName'; import { loadDocumentLanguage, saveDocumentLanguage, odfFromLanguage, type DocumentLanguage } from './lib/storage/documentLanguage'; import { spellController } from './lib/spell/controller'; @@ -45,6 +45,10 @@ // top toolbars target hfEditor instead of the body editor (activeEditor below). let headerDoc: HfDoc = $state(loadHfDoc('header')); let footerDoc: HfDoc = $state(loadHfDoc('footer')); + // First-page header/footer (Word "Different First Page"), shown on page 1 when on. + let headerFirstDoc: HfDoc = $state(loadHfDoc('header', 'first')); + let footerFirstDoc: HfDoc = $state(loadHfDoc('footer', 'first')); + let differentFirstPage: boolean = $state(loadDifferentFirstPage()); let hfDistances: HfDistances = $state(loadHfDistances()); let hfEditor: Editor | null = $state(null); let hfActive: HfZone | null = $state(null); @@ -155,6 +159,21 @@ saveHfDoc('footer', footerDoc); }); + $effect(() => { + saveHfDoc('header', headerFirstDoc, 'first'); + }); + + $effect(() => { + saveHfDoc('footer', footerFirstDoc, 'first'); + }); + + // Persist the flag and end any active header/footer edit when it flips (the live + // editor is bound to one variant for its lifetime). + $effect(() => { + saveDifferentFirstPage(differentFirstPage); + hfActive = null; + }); + $effect(() => { saveHfDistances(hfDistances); }); @@ -304,7 +323,9 @@ // odf-kit export options for the current header/footer + page geometry. function hfOpts() { return { - header: headerDoc, footer: footerDoc, pageCount: numPages, + header: headerDoc, footer: footerDoc, + headerFirst: headerFirstDoc, footerFirst: footerFirstDoc, differentFirstPage, + pageCount: numPages, headerDistanceCm: hfDistances.header, footerDistanceCm: hfDistances.footer, }; } @@ -312,7 +333,7 @@ function isDocNonEmpty(): boolean { if (!editor) return false; const body = editor.state.doc.textContent.length > 0 || editor.state.doc.childCount > 1; - return body || !hfIsEmpty(headerDoc) || !hfIsEmpty(footerDoc); + return body || !hfIsEmpty(headerDoc) || !hfIsEmpty(footerDoc) || !hfIsEmpty(headerFirstDoc) || !hfIsEmpty(footerFirstDoc); } function handleNew() { @@ -323,6 +344,9 @@ hfActive = null; headerDoc = null; footerDoc = null; + headerFirstDoc = null; + footerFirstDoc = null; + differentFirstPage = false; pageMargins = { ...DEFAULT_MARGINS }; pageOrientation = 'portrait'; pageFormat = 'A4'; @@ -381,10 +405,13 @@ // Adopt the document's spell-check language (the $effect switches the // controller + loads its dictionary). null = file declared none; keep ours. if (result.language) documentLanguage = result.language; - // Adopt header/footer (null clears the zone); end any active edit. + // Adopt header/footer + first-page variants (null clears the zone); end any edit. hfActive = null; headerDoc = result.header; footerDoc = result.footer; + headerFirstDoc = result.headerFirst; + footerFirstDoc = result.footerFirst; + differentFirstPage = result.differentFirstPage; hfDistances = { header: result.headerDistanceCm ?? DEFAULT_HF_DISTANCES.header, footer: result.footerDistanceCm ?? DEFAULT_HF_DISTANCES.footer, @@ -397,6 +424,8 @@ collectFontFamilies(result.content, fontSet); collectFontFamilies(result.header, fontSet); collectFontFamilies(result.footer, fontSet); + collectFontFamilies(result.headerFirst, fontSet); + collectFontFamilies(result.footerFirst, fontSet); const missingFonts = await unavailableFonts(fontSet); const warnings = result.warnings.map(localizeImportMessage); @@ -550,6 +579,9 @@ pageFormat, headerDoc, footerDoc, + headerFirstDoc, + footerFirstDoc, + differentFirstPage, }); } catch (err) { console.error('[pdf] Print failed:', err); @@ -859,6 +891,7 @@ bind:pageOrientation bind:pageFormat bind:hfDistances + bind:differentFirstPage hfActive={hfActive} onEditZone={(zone) => (hfActive = zone)} onDebugDump={handleDebugDump} @@ -877,6 +910,9 @@ bind:numPages bind:headerDoc bind:footerDoc + bind:headerFirstDoc + bind:footerFirstDoc + {differentFirstPage} bind:hfEditor bind:hfActive bind:hfTick diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index b6c7780..768523f 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -20,7 +20,7 @@ import { applyMarginVars, cmToPx, DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins'; import { type Orientation } from '../storage/pageOrientation'; import { applyPageSizeVars, type PageFormat } from '../storage/pageFormat'; - import { DEFAULT_HF_DISTANCES, type HfDoc, type HfZone, type HfDistances } from '../storage/headerFooter'; + import { DEFAULT_HF_DISTANCES, hfIsEmpty, type HfDoc, type HfZone, type HfDistances } from '../storage/headerFooter'; import { FORCE_PAGE_RECALC, type TableBreakBand } from '../editor/extensions/pageBreaks'; import { recordTransaction, resetHistoryLog } from '../utils/historyLog.svelte'; import { t } from '../i18n/i18n.svelte'; @@ -34,11 +34,13 @@ zoom = 100, showFormattingMarks = false, pageMargins = DEFAULT_MARGINS, orientation = 'portrait', pageFormat = 'A4', headerDoc = $bindable(null), footerDoc = $bindable(null), hfDistances = DEFAULT_HF_DISTANCES, + headerFirstDoc = $bindable(null), footerFirstDoc = $bindable(null), differentFirstPage = false, hfEditor = $bindable(null), hfActive = $bindable(null), hfTick = $bindable(0), }: { editor: Editor | null; tick: number; currentPage: number; numPages: number; zoom: number; showFormattingMarks?: boolean; pageMargins?: PageMargins; orientation?: Orientation; pageFormat?: PageFormat; headerDoc?: HfDoc; footerDoc?: HfDoc; hfDistances?: HfDistances; + headerFirstDoc?: HfDoc; footerFirstDoc?: HfDoc; differentFirstPage?: boolean; hfEditor?: Editor | null; hfActive?: HfZone | null; hfTick?: number; } = $props(); @@ -51,6 +53,35 @@ applyPageSizeVars(pageFormat, orientation); }); + // A header/footer that reaches past the body's margin (its distance from the edge plus + // its line count) pushes the body's content area in so text doesn't overlap it — Word + // grows the margin to fit the zone. ~18.4px = one 12pt line. + 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 }[] } | undefined)?.content ?? []); + const lines = 1 + inline.filter((n) => n.type === 'hardBreak').length; + return distPx + lines * HF_LINE_PX; + } + let footerDistPx = $derived(cmToPx((hfDistances ?? DEFAULT_HF_DISTANCES).footer)); + let headerDistPx = $derived(cmToPx((hfDistances ?? DEFAULT_HF_DISTANCES).header)); + let mBottomPx = $derived(cmToPx(pageMargins.bottom)); + let mTopPx = $derived(cmToPx(pageMargins.top)); + // Effective top/bottom margins (px) for other pages vs. page 1's own header/footer + // (different first page); pageBreaks reads these to start each page's content below + // its header and end it above its footer. + let effTopRest = $derived(Math.max(mTopPx, hfReachPx(headerDoc ?? null, headerDistPx))); + let effTopFirst = $derived(Math.max(mTopPx, hfReachPx((differentFirstPage ? headerFirstDoc : headerDoc) ?? null, headerDistPx))); + let effBottomRest = $derived(Math.max(mBottomPx, hfReachPx(footerDoc ?? null, footerDistPx))); + let effBottomFirst = $derived(Math.max(mBottomPx, hfReachPx((differentFirstPage ? footerFirstDoc : footerDoc) ?? null, footerDistPx))); + $effect(() => { + const s = document.documentElement.style; + s.setProperty('--pb-content-top-rest', `${effTopRest}px`); + s.setProperty('--pb-content-top-first', `${effTopFirst}px`); + s.setProperty('--pb-content-bottom-rest', `${effBottomRest}px`); + s.setProperty('--pb-content-bottom-first', `${effBottomFirst}px`); + }); + // Nudge the pageBreaks plugin to recompute with the new content area. The dispatch // bumps the tick/numPages bindings, so requestAnimationFrame defers it past the // Svelte effect flush (re-entering it would trip effect_update_depth_exceeded). @@ -60,6 +91,8 @@ void (pageMargins.top + pageMargins.bottom + pageMargins.left + pageMargins.right); void orientation; void pageFormat; + // …and the header/footer-driven effective margins, so growing a zone re-paginates. + void (effTopRest + effTopFirst + effBottomRest + effBottomFirst); const ed = editor; if (!ed) return; cancelAnimationFrame(marginRecalcRaf); @@ -697,6 +730,9 @@ w:bottom); the zone then grows up into the margin. + let headerDistPx = $derived(Math.min(cmToPx(hfDistances.header), pageHeightPx)); + let footerDistPx = $derived(Math.min(cmToPx(hfDistances.footer), pageHeightPx)); let pages = $derived(Array.from({ length: Math.max(1, numPages) }, (_, i) => i + 1)); @@ -59,10 +68,25 @@ const width = contentWidth; if (zone === 'header') { const top = (page - 1) * cycle + headerDistPx; - return { top, left, width, height: Math.max(0, mTop - headerDistPx) }; + return { top, left, width, height: Math.max(MIN_ZONE_PX, mTop - headerDistPx) }; } - const top = (page - 1) * cycle + pageHeightPx - mBottom; - return { top, left, width, height: Math.max(0, mBottom - footerDistPx) }; + // Footer anchors its bottom edge at footerDistPx from the page bottom and grows up. + const height = Math.max(MIN_ZONE_PX, mBottom - footerDistPx); + const top = (page - 1) * cycle + pageHeightPx - footerDistPx - height; + return { top, left, width, height }; + } + // The active (edited) zone grows to fit its content, keeping the anchored edge fixed + // (footer bottom at footerDistPx, header top at headerDistPx) so the Word-style + // boundary line sits exactly at the content's edge toward the body. + function activeZoneBox(zone: HfZone, page: number) { + const b = zoneBox(zone, page); + // A visible ProseMirror trailing break (a caret line past the real content) is + // discounted: the frame tracks the real content and the break overflows past the + // anchored edge, so entering edit never shifts the text. + const contentPx = Math.max(0, activeContentPx - activeTrailingPx); + const height = Math.max(b.height, contentPx); + if (zone === 'footer') return { ...b, top: b.top + b.height - height, height }; + return { ...b, height }; } const boxStyle = (b: { top: number; left: number; width: number; height: number }) => `top: ${b.top}px; left: ${b.left}px; width: ${b.width}px; height: ${b.height}px;`; @@ -70,13 +94,27 @@ function staticHtml(doc: HfDoc): string { if (hfIsEmpty(doc)) return ''; try { - return generateHTML(doc as Parameters[0], renderExts); + const html = generateHTML(doc as Parameters[0], renderExts); + // A paragraph ending in a hardBreak loses its last blank line: a bare trailing
+ // collapses, so the live editor adds a ProseMirror trailing break but generateHTML + // doesn't. Append one (past any mark close-tags) so static matches editing. + return html.replace(/()((?:<\/[a-zA-Z]+>)*<\/p>)/g, '$1
$2'); } catch { return ''; } } let headerHtml = $derived(staticHtml(headerDoc)); let footerHtml = $derived(staticHtml(footerDoc)); + let headerFirstHtml = $derived(staticHtml(headerFirstDoc)); + let footerFirstHtml = $derived(staticHtml(footerFirstDoc)); + + // Page 1 uses the first-page variant when the feature is on. Word: the first page + // then always shows its own (possibly empty) zone, never the default. + let isFirstVariant = (page: number) => differentFirstPage && page === 1; + function zoneHtml(zone: HfZone, page: number): string { + if (zone === 'header') return isFirstVariant(page) ? headerFirstHtml : headerHtml; + return isFirstVariant(page) ? footerFirstHtml : footerHtml; + } // Replace the placeholder text in every page-field span with the real value: // current page number, or the total page count. Re-runs when its param changes. @@ -92,13 +130,17 @@ // --- live editing of one zone --- let liveMount = $state(null); + let activeContentPx = $state(0); + let activeTrailingPx = $state(0); let editingPage = $state(1); // Set by a double-click (that page); null when editing is triggered externally // (Layout-panel buttons), where the current page is used instead. let pendingPage: number | null = null; let liveZone: HfZone | null = null; - function zoneDoc(zone: HfZone): HfDoc { + // The doc for a zone + whether page 1's own variant is being edited. + function zoneDoc(zone: HfZone, first: boolean): HfDoc { + if (first) return zone === 'header' ? headerFirstDoc : footerFirstDoc; return zone === 'header' ? headerDoc : footerDoc; } function emptyDoc(): HfDoc { @@ -132,10 +174,13 @@ editingPage = pendingPage ?? currentPage; pendingPage = null; liveZone = zone; + // Which variant this edit session targets — fixed for its lifetime (page 1 with the + // feature on edits the first-page doc; App ends the edit when the flag toggles). + const editingFirst = isFirstVariant(editingPage); const ed = new Editor({ element: mount, extensions: hfExtensions(zone === 'header' ? t().hf.headerPlaceholder : t().hf.footerPlaceholder), - content: (zoneDoc(zone) ?? emptyDoc()) as Content, + content: (zoneDoc(zone, editingFirst) ?? emptyDoc()) as Content, // No autofocus: its scrollIntoView nudges the page so the just-clicked zone // appears to jump. Focus the zone explicitly without scrolling instead. onTransaction: () => { @@ -143,7 +188,10 @@ }, onUpdate: ({ editor }) => { const json = editor.getJSON() as HfDoc; - if (zone === 'header') headerDoc = json; + if (editingFirst) { + if (zone === 'header') headerFirstDoc = json; + else footerFirstDoc = json; + } else if (zone === 'header') headerDoc = json; else footerDoc = json; }, editorProps: { @@ -169,6 +217,15 @@ for (const el of Array.from(liveMount.querySelectorAll('[data-page-field]'))) { el.textContent = String(el.getAttribute('data-page-field') === 'count' ? numPages : editingPage); } + // Content height (unscaled by the zoom transform) drives the active zone's frame. + const tt = liveMount.querySelector('.tiptap') as HTMLElement | null; + activeContentPx = tt ? tt.offsetHeight : 0; + // A rendered trailing break adds one caret line past the real content; measure that + // line height (the CSS var below shifts the editor down so it overflows the anchor). + const p = tt?.querySelector('p') as HTMLElement | null; + const tb = p?.querySelector(':scope > br.ProseMirror-trailingBreak') as HTMLElement | null; + const lineH = p ? parseFloat(getComputedStyle(p).lineHeight) : 0; + activeTrailingPx = tb && getComputedStyle(tb).display !== 'none' && Number.isFinite(lineH) ? lineH : 0; }); function insertField(kind: 'pageNumber' | 'pageCount') { @@ -180,7 +237,7 @@ {#each pages as p} {#each ['header', 'footer'] as const as zone} {#if !(hfActive === zone && editingPage === p)} - {@const html = zone === 'header' ? headerHtml : footerHtml} + {@const html = zoneHtml(zone, p)}
+ {@const box = activeZoneBox(hfActive, editingPage)} + {@const first = isFirstVariant(editingPage)} +
+
+ {hfActive === 'header' + ? (first ? t().hf.firstPageHeader : t().hf.headerLabel) + : (first ? t().hf.firstPageFooter : t().hf.footerLabel)} +
{t().hf.insert} @@ -225,7 +288,9 @@ display: flex; flex-direction: column; pointer-events: auto; - overflow: hidden; + /* Content taller than the margin band spills into the margin (footer up, header + down), like Word's auto-growing zones; the anchored edge stays put. */ + overflow: visible; font-family: var(--font-serif); font-size: 12pt; color: var(--color-page-text); @@ -255,15 +320,47 @@ opacity: 0.6; } - .hf-active { - outline: 1px dashed var(--color-primary); - outline-offset: 2px; + /* Word-style affordance: a single dashed line at the edge facing the page body + (footer top, header bottom) instead of a rectangle around the content. */ + .hf-active::after { + content: ''; + position: absolute; + left: 0; + right: 0; + border-top: 1px dashed var(--color-primary); + pointer-events: none; + } + .hf-active.hf-footer::after { + top: 0; + } + .hf-active.hf-header::after { + bottom: 0; } - /* Strip the page margins from the rendered header/footer paragraph. */ + /* Word-style zone label tab, pinned to the zone's top-left corner. */ + .hf-tag { + position: absolute; + transform: translateY(calc(-100% - 2px)); + z-index: 150; + padding: 1px 6px; + background: var(--color-primary); + color: #fff; + font-family: var(--font-sans); + font-size: 0.68rem; + letter-spacing: 0.02em; + border-radius: calc(var(--radius) - 3px) calc(var(--radius) - 3px) 0 0; + pointer-events: none; + white-space: nowrap; + user-select: none; + } + + /* Strip the page margins from the rendered header/footer paragraph. pre-wrap keeps + runs of spaces (used to push a right-side field over) that HTML would collapse — + matching the live editor's ProseMirror rendering. */ .hf-zone :global(p) { margin: 0; line-height: 1.15; + white-space: pre-wrap; } /* The live editor's editable root is also a `.tiptap`, so the global `.paper .tiptap` rules (96px padding, 1123px min-height, page-background @@ -277,6 +374,12 @@ font-size: 12pt; line-height: 1.15; outline: none; + /* Keep the editable root at its content height (min-height:0 would otherwise let + the flex column shrink it to the band, hiding lines and misreporting height). */ + flex-shrink: 0; + /* Drop the editor by the trailing-break line so the caret line overflows past the + anchored edge while the real content stays put (set per active zone). */ + margin-bottom: var(--hf-tb-offset, 0px); } /* The live editor's paragraph also matches `.paper .tiptap p` (margin-bottom 0.212cm), which the static `

` doesn't — with flex-end alignment that gap @@ -285,6 +388,12 @@ .hf-layer .hf-zone :global(.tiptap p) { margin: 0; } + /* A paragraph ending in an inline atom (a page field) gets a phantom trailing
the + static

lacks; ProseMirror marks that case with a separator , so hide the + break only then. A real Enter-made line has no separator and keeps its caret. */ + .hf-layer .hf-zone :global(.tiptap p:has(img.ProseMirror-separator) > br.ProseMirror-trailingBreak) { + display: none; + } .hf-zone :global([data-page-field]) { white-space: pre; } diff --git a/src/lib/components/ToolbarExpanded.svelte b/src/lib/components/ToolbarExpanded.svelte index 1f92787..206fa81 100644 --- a/src/lib/components/ToolbarExpanded.svelte +++ b/src/lib/components/ToolbarExpanded.svelte @@ -22,8 +22,8 @@ import { t } from '../i18n/i18n.svelte'; import { withShortcut } from '../i18n/shortcut'; - let { editor, tick, showFormattingMarks = $bindable(), pageMargins = $bindable(DEFAULT_MARGINS), pageOrientation = $bindable('portrait'), pageFormat = $bindable('A4'), hfDistances = $bindable(DEFAULT_HF_DISTANCES), hfActive = null, onEditZone, onDebugDump }: - { editor: Editor | null; tick: number; showFormattingMarks: boolean; pageMargins?: PageMargins; pageOrientation?: Orientation; pageFormat?: PageFormat; hfDistances?: HfDistances; hfActive?: 'header' | 'footer' | null; onEditZone?: (zone: 'header' | 'footer') => void; onDebugDump?: () => void } = $props(); + let { editor, tick, showFormattingMarks = $bindable(), pageMargins = $bindable(DEFAULT_MARGINS), pageOrientation = $bindable('portrait'), pageFormat = $bindable('A4'), hfDistances = $bindable(DEFAULT_HF_DISTANCES), differentFirstPage = $bindable(false), hfActive = null, onEditZone, onDebugDump }: + { editor: Editor | null; tick: number; showFormattingMarks: boolean; pageMargins?: PageMargins; pageOrientation?: Orientation; pageFormat?: PageFormat; hfDistances?: HfDistances; differentFirstPage?: boolean; hfActive?: 'header' | 'footer' | null; onEditZone?: (zone: 'header' | 'footer') => void; onDebugDump?: () => void } = $props(); const PAGE_FORMATS = Object.keys(PAGE_FORMAT_CM) as PageFormat[]; @@ -1270,6 +1270,10 @@ onclick={() => onEditZone?.('footer')} >{t().toolbarExpanded.editFooter}

+
@@ -2031,6 +2035,21 @@ color: #fff; } + .hf-firstpage-row { + display: flex; + align-items: center; + gap: 6px; + padding: 0 6px 4px; + color: var(--color-text); + font-family: var(--font-sans); + font-size: 0.78rem; + cursor: pointer; + user-select: none; + } + .hf-firstpage-row input { + cursor: pointer; + } + .margin-field { display: flex; flex-direction: column; diff --git a/src/lib/editor/extensions/headerFooter.ts b/src/lib/editor/extensions/headerFooter.ts index 76440cc..c98cdf9 100644 --- a/src/lib/editor/extensions/headerFooter.ts +++ b/src/lib/editor/extensions/headerFooter.ts @@ -23,6 +23,18 @@ import { PageNumber, PageCount } from './pageField'; const HfDocument = Document.extend({ content: 'paragraph' }); +// The single-paragraph schema can't split, so plain Enter inserts a line break instead +// (Word: Enter in a footer adds a blank line, growing the zone into the page). Shift-/ +// Mod-Enter keep their default break binding. +const HfHardBreak = HardBreak.extend({ + addKeyboardShortcuts() { + return { + ...this.parent?.(), + Enter: () => this.editor.commands.setHardBreak(), + }; + }, +}); + export function hfExtensions(placeholder = '') { return [ HfDocument, @@ -40,7 +52,7 @@ export function hfExtensions(placeholder = '') { FontWeight, FontColor, Highlight.configure({ multicolor: true }), - HardBreak, + HfHardBreak, PageNumber, PageCount, History, diff --git a/src/lib/editor/extensions/pageBreaks.ts b/src/lib/editor/extensions/pageBreaks.ts index 5ad3a6b..50da618 100644 --- a/src/lib/editor/extensions/pageBreaks.ts +++ b/src/lib/editor/extensions/pageBreaks.ts @@ -134,8 +134,13 @@ export type VMargins = { // are ignored (they affect line wrapping, not pagination). Shared with columnsFlow.ts. export function readVerticalMargins(dom: HTMLElement): VMargins { const cs = getComputedStyle(dom); - const top = parseFloat(cs.getPropertyValue('--user-margin-top')); - const bottom = parseFloat(cs.getPropertyValue('--user-margin-bottom')); + // Header/footer-aware effective margins (Editor.svelte): equal the raw page margins + // unless a header/footer grows past them, in which case the content area shrinks so + // body text starts below the header / ends above the footer. + const topEff = parseFloat(cs.getPropertyValue('--pb-content-top-rest')); + const top = Number.isFinite(topEff) ? topEff : parseFloat(cs.getPropertyValue('--user-margin-top')); + const bottomEff = parseFloat(cs.getPropertyValue('--pb-content-bottom-rest')); + const bottom = Number.isFinite(bottomEff) ? bottomEff : parseFloat(cs.getPropertyValue('--user-margin-bottom')); const ph = parseFloat(cs.getPropertyValue('--user-page-height')); const mt = Number.isFinite(top) ? top : DEFAULT_MARGIN_TOP; const mb = Number.isFinite(bottom) ? bottom : DEFAULT_MARGIN_BOTTOM; @@ -756,6 +761,13 @@ export const PageBreaks = Extension.create({ const pwRaw = parseFloat(csRoot.getPropertyValue('--user-page-width')); const marginLeft = Number.isFinite(mlRaw) ? mlRaw : 80; const contentWidth = (Number.isFinite(pwRaw) ? pwRaw : 794) - marginLeft - (Number.isFinite(mrRaw) ? mrRaw : 80); + // Page 1's own header/footer (different first page) may reach further than the + // others, so its content starts lower / ends sooner; each falls back to the + // shared effective margin. + const ctFirst = parseFloat(csRoot.getPropertyValue('--pb-content-top-first')); + const effTopFirst = Number.isFinite(ctFirst) ? ctFirst : vm.top; + const cbFirst = parseFloat(csRoot.getPropertyValue('--pb-content-bottom-first')); + const effBottomFirst = Number.isFinite(cbFirst) ? cbFirst : vm.bottom; const scale = getScaleFactor(); const leaves = collectLeaves(CONTENT_HEIGHT, scale); @@ -800,8 +812,11 @@ export const PageBreaks = Extension.create({ const effectiveTop = leaf.naturalTop + cumulativeShift; const effectiveBottom = effectiveTop + leaf.naturalHeight; const page = getPageForY(effectiveTop, CYCLE_PX); - const contentStart = pageContentStart(page, vm.top, CYCLE_PX); - const contentEnd = pageContentEnd(page, vm.top, CONTENT_HEIGHT, CYCLE_PX); + const contentStart = page === 1 ? effTopFirst : pageContentStart(page, vm.top, CYCLE_PX); + const contentEnd = + page === 1 + ? vm.pageHeight - effBottomFirst + : pageContentEnd(page, vm.top, CONTENT_HEIGHT, CYCLE_PX); // A leaf may need several spacers: a splittable block taller than one // page crosses multiple boundaries, one break each. @@ -878,7 +893,10 @@ export const PageBreaks = Extension.create({ bandOpenY: target, reason: 'forced-page-break', }); - } else if (effectiveTop < contentStart && i > 0) { + } else if (effectiveTop < contentStart && (i > 0 || effectiveTop < contentStart - 1)) { + // i === 0 only reaches here when a tall header on page 1 pushes the very + // first block down below its content start (a >1px gap); otherwise the + // first block already sits at the content start. const { docPos, row } = leafSpacer(leaf); breaks.push({ height: contentStart - effectiveTop, diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 455d718..be11d35 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -248,9 +248,9 @@ function inlineToRuns(content: TiptapNode[] = [], forceBold = false): Inline[] { const img = imageRun(node); if (img) out.push(img); } else if (node.type === 'pageNumber') { - out.push(new TextRun({ children: [PageNumber.CURRENT] })); + out.push(new TextRun({ children: [PageNumber.CURRENT], ...runPropsFromMarks(node.marks) })); } else if (node.type === 'pageCount') { - out.push(new TextRun({ children: [PageNumber.TOTAL_PAGES] })); + out.push(new TextRun({ children: [PageNumber.TOTAL_PAGES], ...runPropsFromMarks(node.marks) })); } else if (node.type === 'dateTimeField') { out.push(dateTimeRun(node)); } @@ -852,6 +852,10 @@ export async function buildDocx( const headerPara = hf && !hfIsEmpty(hf.header) ? (hf.header!.content![0] as TiptapNode) : null; const footerPara = hf && !hfIsEmpty(hf.footer) ? (hf.footer!.content![0] as TiptapNode) : null; + // Different first page (Word w:titlePg): page 1 gets its own header/footer. + const differentFirstPage = !!hf?.differentFirstPage; + const firstHeaderPara = differentFirstPage && !hfIsEmpty(hf?.headerFirst ?? null) ? (hf!.headerFirst!.content![0] as TiptapNode) : null; + const firstFooterPara = differentFirstPage && !hfIsEmpty(hf?.footerFirst ?? null) ? (hf!.footerFirst!.content![0] as TiptapNode) : null; // Word's model: header/footer distance is from the page edge; the body still starts // at the body margin. Clamp the distance below the margin (matches odt.ts). const headerDist = Math.min(hf?.headerDistanceCm ?? HF_DISTANCE_CM, margins.top); @@ -868,8 +872,22 @@ export async function buildDocx( header: cmToTwip(headerDist), footer: cmToTwip(footerDist), }, }; - const mkHeaders = () => headerPara ? { default: new Header({ children: [paragraphToDocx(headerPara)] }) } : undefined; - const mkFooters = () => footerPara ? { default: new Footer({ children: [paragraphToDocx(footerPara)] }) } : undefined; + // Fresh instances per section (Word's per-sectPr references). A first-page variant + // rides `first:` and is activated by properties.titlePage below. + const mkHeaders = () => { + if (!headerPara && !firstHeaderPara) return undefined; + const h: { default?: Header; first?: Header } = {}; + if (headerPara) h.default = new Header({ children: [paragraphToDocx(headerPara)] }); + if (firstHeaderPara) h.first = new Header({ children: [paragraphToDocx(firstHeaderPara)] }); + return h; + }; + const mkFooters = () => { + if (!footerPara && !firstFooterPara) return undefined; + const f: { default?: Footer; first?: Footer } = {}; + if (footerPara) f.default = new Footer({ children: [paragraphToDocx(footerPara)] }); + if (firstFooterPara) f.first = new Footer({ children: [paragraphToDocx(firstFooterPara)] }); + return f; + }; const doc = new Document({ creator: 'Web ODF Editor', @@ -882,6 +900,7 @@ export async function buildDocx( sections: groups.map((g, i) => ({ properties: { page: pageProps, + ...(differentFirstPage ? { titlePage: true } : {}), ...(i > 0 ? { type: SectionType.CONTINUOUS } : {}), ...(g.columns ? { column: { count: g.columns.count, space: cmToTwip(g.columns.gapCm), equalWidth: true } } diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index da365ed..45b7701 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -2035,6 +2035,11 @@ function applyToc(odtBytes: Uint8Array, tocs: TocExport[], contentWidthCm: numbe export type HfExport = { header: HfDoc; footer: HfDoc; + // First-page overrides (Word titlePg / ODF header-first); only used when + // differentFirstPage is set. null = the first page's zone is blank. + headerFirst?: HfDoc; + footerFirst?: HfDoc; + differentFirstPage?: boolean; pageCount: number; headerDistanceCm?: number; footerDistanceCm?: number; @@ -2053,8 +2058,19 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU const columns: ColumnsExport[] = []; const dateFields: DateTimeFieldExport[] = []; const raw = replaceDateTimeFields(replaceImages(replaceTabs(replaceHardBreaks(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns))), images), dateFields); - const headerPara = hf && !hfIsEmpty(hf.header) ? (hf.header!.content![0] as TiptapNode) : null; - const footerPara = hf && !hfIsEmpty(hf.footer) ? (hf.footer!.content![0] as TiptapNode) : null; + let headerPara = hf && !hfIsEmpty(hf.header) ? (hf.header!.content![0] as TiptapNode) : null; + let footerPara = hf && !hfIsEmpty(hf.footer) ? (hf.footer!.content![0] as TiptapNode) : null; + // Different first page (ODF header-first): page 1 gets its own zone content. + const differentFirstPage = !!hf?.differentFirstPage; + let firstHeaderPara = differentFirstPage && hf && !hfIsEmpty(hf.headerFirst ?? null) ? (hf.headerFirst!.content![0] as TiptapNode) : null; + let firstFooterPara = differentFirstPage && hf && !hfIsEmpty(hf.footerFirst ?? null) ? (hf.footerFirst!.content![0] as TiptapNode) : null; + // With the flag on, page 1 is independent: whenever a side has a zone on either + // variant, emit both (an empty one blanks its side, matching the editor and Word). + if (differentFirstPage) { + const empty = (): TiptapNode => ({ type: 'paragraph', content: [] }); + if (headerPara || firstHeaderPara) { headerPara ??= empty(); firstHeaderPara ??= empty(); } + if (footerPara || firstFooterPara) { footerPara ??= empty(); firstFooterPara ??= empty(); } + } // Distance from the page edge to the header (top) / footer (bottom). Becomes the // ODF page margin; clamped below the body margin so the body still starts at it. const headerDist = Math.min(hf?.headerDistanceCm ?? HF_DISTANCE_CM, margins.top); @@ -2187,7 +2203,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU const withPageBreaks = applyPageBreaks(withToc); const withEmptyFontSizes = applyEmptyLineFontSizes(withPageBreaks); const withStyles = rewriteStylesXml(withEmptyFontSizes, language ?? null, pageFormat, orientation); - return applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist); + return applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist, firstHeaderPara, firstFooterPara, hf?.pageCount ?? 1); } function hfAlign(para: TiptapNode): AlignValue | null { @@ -2198,7 +2214,7 @@ function hfAlign(para: TiptapNode): AlignValue | null { // Header/footer post-processing on styles.xml: resolve LBR/PGC sentinels, apply the // paragraph alignment to the Header/Footer styles, and rewrite the geometry to the // Word-style mapping (page margin = HF distance, min-height fills up to the body margin). -function applyHfPostProcess(odtBytes: Uint8Array, margins: PageMargins, headerPara: TiptapNode | null, footerPara: TiptapNode | null, headerDist: number, footerDist: number): Uint8Array { +function applyHfPostProcess(odtBytes: Uint8Array, margins: PageMargins, headerPara: TiptapNode | null, footerPara: TiptapNode | null, headerDist: number, footerDist: number, firstHeaderPara: TiptapNode | null = null, firstFooterPara: TiptapNode | null = null, pageCount = 1): Uint8Array { if (!headerPara && !footerPara) return odtBytes; const files = unzipSync(odtBytes); @@ -2235,10 +2251,109 @@ function applyHfPostProcess(odtBytes: Uint8Array, margins: PageMargins, headerPa if (headerPara) zone('header', headerPara, margins.top, headerDist); if (footerPara) zone('footer', footerPara, margins.bottom, footerDist); + // Different first page: inject / content into + // the master page (ODF 1.3; LibreOffice reads them). The page-layout header/footer + // geometry above is shared across variants, so only the content differs here. + if (firstHeaderPara || firstFooterPara) { + const mintedStyles: string[] = []; + const mint = (n: string) => { mintedStyles.push(n); }; + const injectVariant = (kind: 'header' | 'footer', para: TiptapNode | null) => { + if (!para) return; + const xml = hfFirstZoneXml(kind, para, pageCount, mint); + // Order inside master-page: header, header-first, footer, footer-first. Insert + // after the matching default zone if present, else at the master-page bounds. + const closeTag = ``; + if (styles.includes(closeTag)) styles = styles.replace(closeTag, `${closeTag}${xml}`); + else if (kind === 'header') styles = styles.replace(/(]*>)/, `$1${xml}`); + else styles = styles.replace('', `${xml}`); + }; + injectVariant('header', firstHeaderPara); + injectVariant('footer', firstFooterPara); + if (mintedStyles.length) { + const defs = mintedStyles.join(''); + if (styles.includes('')) styles = styles.replace('', `${defs}`); + else styles = styles.replace('', `${defs}`); + } + } + files['styles.xml'] = strToU8(styles); return rezipOdt(files); } +// Serialize a first-page header/footer paragraph to / +// XML. Runs and page fields become referencing minted automatic text styles +// (pushed via `mint`), so both keep the run's font; hardBreak → line-break. +function hfFirstZoneXml(kind: 'header' | 'footer', para: TiptapNode, pageCount: number, mint: (styleXml: string) => void): string { + let styleSeq = 0; + // Wrap inline XML in a minted text style span when the run carries formatting. + const styled = (innerXml: string, marks: TiptapNode['marks']): string => { + const props = odfTextPropsFromMarks(marks); + if (!props) return innerXml; + const name = `HFF${kind[0].toUpperCase()}T${++styleSeq}`; + mint(``); + return `${innerXml}`; + }; + + let inner = ''; + for (const node of para.content ?? []) { + if (node.type === 'text' && node.text) inner += styled(odfEncodeInline(node.text), node.marks); + else if (node.type === 'hardBreak') inner += ''; + else if (node.type === 'pageNumber') inner += styled('1', node.marks); + else if (node.type === 'pageCount') inner += styled(`${pageCount}`, node.marks); + } + + const parent = kind === 'header' ? 'Header' : 'Footer'; + const align = hfAlign(para); + let paraStyle = parent; + if (align) { + paraStyle = `HFF${kind[0].toUpperCase()}P`; + mint(``); + } + return `${inner}`; +} + +// A text run's ODF attribute string from TipTap marks (mirrors +// formattingFromMarks). Empty string → no styling needed (returned as null). +function odfTextPropsFromMarks(marks: TiptapNode['marks']): string | null { + const fmt = formattingFromMarks(marks); + const a: string[] = []; + if (fmt.fontWeight != null) a.push(`fo:font-weight="${fmt.fontWeight}"`); + else if (fmt.bold) a.push('fo:font-weight="bold"'); + if (fmt.italic) a.push('fo:font-style="italic"'); + if (fmt.underline) a.push('style:text-underline-style="solid" style:text-underline-width="auto" style:text-underline-color="font-color"'); + if (fmt.strikethrough) a.push('style:text-line-through-style="solid"'); + if (fmt.superscript) a.push('style:text-position="super 58%"'); + else if (fmt.subscript) a.push('style:text-position="sub 58%"'); + if (fmt.fontFamily) { + const q = /\s/.test(fmt.fontFamily) ? `'${fmt.fontFamily}'` : fmt.fontFamily; + a.push(`style:font-name="${fmt.fontFamily}" fo:font-family="${q}"`); + } + if (fmt.fontSize) a.push(`fo:font-size="${fmt.fontSize}"`); + if (fmt.color) a.push(`fo:color="${fmt.color}"`); + if (fmt.highlightColor) a.push(`fo:background-color="${fmt.highlightColor}"`); + return a.length ? a.join(' ') : null; +} + +// Encode inline text as ODF: runs of ≥2 spaces → , tabs → , +// the rest XML-escaped (so LibreOffice preserves significant whitespace). +function odfEncodeInline(s: string): string { + let out = ''; + for (let i = 0; i < s.length; ) { + const ch = s[i]; + if (ch === '\t') { out += ''; i++; continue; } + if (ch === ' ') { + let n = 1; + while (s[i + n] === ' ') n++; + out += n === 1 ? ' ' : ``; + i += n; + continue; + } + out += ch === '&' ? '&' : ch === '<' ? '<' : ch === '>' ? '>' : ch; + i++; + } + return out; +} + // Document filename derived from the first non-empty heading (sanitized, max 50 // chars), falling back to document.odt. export function deriveFilename(json: TiptapNode): string { diff --git a/src/lib/export/pdf.ts b/src/lib/export/pdf.ts index 11bb27f..e636685 100644 --- a/src/lib/export/pdf.ts +++ b/src/lib/export/pdf.ts @@ -355,6 +355,10 @@ export interface PrintPdfOptions { pageFormat?: PageFormat; headerDoc: HfDoc; footerDoc: HfDoc; + // First-page overrides (Word "Different First Page"); rendered via @page :first. + headerFirstDoc?: HfDoc; + footerFirstDoc?: HfDoc; + differentFirstPage?: boolean; } // First-row column weights from table JSON (honours colspan); mirrors tableView. @@ -421,16 +425,34 @@ function hfContent(doc: HfDoc): { content: string; align: 'left' | 'center' | 'r return { content: parts.join(' '), align }; } +function boxName(edge: 'top' | 'bottom', align: 'left' | 'center' | 'right'): string { + return `@${edge}-${align}`; +} +function filledBox(edge: 'top' | 'bottom', hf: { content: string; align: 'left' | 'center' | 'right' }): string { + return `${boxName(edge, hf.align)} { content: ${hf.content}; font-family: var(--font-serif); font-size: 11pt; color: #000; }`; +} + function marginBoxes(headerDoc: HfDoc, footerDoc: HfDoc): string { const box = (edge: 'top' | 'bottom', doc: HfDoc): string => { const hf = hfContent(doc); - if (!hf) return ''; - const name = `@${edge}-${hf.align === 'left' ? 'left' : hf.align === 'right' ? 'right' : 'center'}`; - return `${name} { content: ${hf.content}; font-family: var(--font-serif); font-size: 11pt; color: #000; }`; + return hf ? filledBox(edge, hf) : ''; }; return box('top', headerDoc) + '\n' + box('bottom', footerDoc); } +// First-page @page :first boxes: set the first-page zone's box and clear the other +// alignment boxes on that edge, so page 1 fully overrides the default @page boxes +// (an empty first-page zone leaves the first page blank there). +function marginBoxesFirst(headerDoc: HfDoc, footerDoc: HfDoc): string { + const edge = (e: 'top' | 'bottom', doc: HfDoc): string => { + const hf = hfContent(doc); + return (['left', 'center', 'right'] as const) + .map((al) => (hf && hf.align === al ? filledBox(e, hf) : `${boxName(e, al)} { content: normal; }`)) + .join('\n'); + }; + return edge('top', headerDoc) + '\n' + edge('bottom', footerDoc); +} + function printCss(o: PrintPdfOptions): string { const m = o.margins; // Explicit cm size (orientation baked into w/h) works for any format, unlike a CSS @@ -443,6 +465,7 @@ function printCss(o: PrintPdfOptions): string { margin: ${m.top}cm ${m.right}cm ${m.bottom}cm ${m.left}cm; ${marginBoxes(o.headerDoc, o.footerDoc)} } +${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; } @@ -468,6 +491,9 @@ export function printPdf(opts: PrintPdfOptions): void { pageFormat: opts.pageFormat ?? 'A4', headerDoc: opts.headerDoc ?? null, footerDoc: opts.footerDoc ?? null, + headerFirstDoc: opts.headerFirstDoc ?? null, + footerFirstDoc: opts.footerFirstDoc ?? null, + differentFirstPage: opts.differentFirstPage ?? false, }; const title = (o.fileName ?? deriveFilename(o.json)).replace(/\.(odt|pdf)$/i, ''); const body = buildBodyHtml(o.json); diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index 2b869ac..4e00633 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -115,6 +115,8 @@ const de: Messages = { headerFooter: 'Kopf- & Fußzeile', editHeader: 'Kopfzeile bearbeiten', editFooter: 'Fußzeile bearbeiten', + differentFirstPage: 'Erste Seite anders', + differentFirstPageHint: 'Der ersten Seite eine eigene Kopf- und Fußzeile geben', position: 'Position (cm)', hfDist: { header: 'Kopfzeile von oben', footer: 'Fußzeile von unten' }, hfField: (label) => `${label} (cm)`, @@ -284,6 +286,10 @@ const de: Messages = { addFooterHint: 'Zum Hinzufügen einer Fußzeile doppelklicken', headerPlaceholder: 'Kopfzeile…', footerPlaceholder: 'Fußzeile…', + headerLabel: 'Kopfzeile', + footerLabel: 'Fußzeile', + firstPageHeader: 'Kopfzeile erste Seite', + firstPageFooter: 'Fußzeile erste Seite', insert: 'Einfügen', pageNumber: 'Seitenzahl', pageNumberTitle: 'Aktuelle Seitenzahl einfügen', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 0353142..7057ebb 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -118,6 +118,8 @@ const en = { headerFooter: 'Header & footer', editHeader: 'Edit header', editFooter: 'Edit footer', + differentFirstPage: 'Different first page', + differentFirstPageHint: 'Give the first page its own header and footer', position: 'Position (cm)', hfDist: { header: 'Header from top', footer: 'Footer from bottom' }, hfField: (label: string) => `${label} (cm)`, @@ -287,6 +289,10 @@ const en = { addFooterHint: 'Double-click to add a footer', headerPlaceholder: 'Header…', footerPlaceholder: 'Footer…', + headerLabel: 'Header', + footerLabel: 'Footer', + firstPageHeader: 'First page header', + firstPageFooter: 'First page footer', insert: 'Insert', pageNumber: 'Page number', pageNumberTitle: 'Insert the current page number', diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index ef23972..1e94155 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -128,15 +128,25 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages = if (blocks.length === 0) blocks.push({ type: 'paragraph' }); let sect = parseSectPr(finalSectPr, ctx); - // LibreOffice-produced multi-section files may reference headers/footers only on - // an earlier section; page geometry still comes from the body-final sectPr. - if (!sect.header && !sect.footer) { + // Multi-section files may reference headers/footers only on an earlier section (page + // geometry still comes from the body-final sectPr). Adopt its header/footer + first- + // page variants, keeping the different-first-page flag if either section set it. + if (!sect.header && !sect.footer && !sect.headerFirst && !sect.footerFirst) { const prev = [...midSectPrs].reverse().find((s) => fc(s, 'headerReference') || fc(s, 'footerReference')); if (prev) { const prevSect = parseSectPr(prev, ctx); - sect = { ...sect, header: prevSect.header, footer: prevSect.footer, headerDistCm: prevSect.headerDistCm, footerDistCm: prevSect.footerDistCm }; + sect = { + ...sect, + header: prevSect.header, footer: prevSect.footer, + headerFirst: prevSect.headerFirst, footerFirst: prevSect.footerFirst, + differentFirstPage: sect.differentFirstPage || prevSect.differentFirstPage, + headerDistCm: prevSect.headerDistCm, footerDistCm: prevSect.footerDistCm, + }; } } + // A first-page zone reserves the header/footer band even when its default is empty. + const hasHeader = sect.header || (sect.differentFirstPage && sect.headerFirst); + const hasFooter = sect.footer || (sect.differentFirstPage && sect.footerFirst); return { content: { type: 'doc', content: blocks }, @@ -145,8 +155,11 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages = format: sect.format, header: sect.header, footer: sect.footer, - headerDistanceCm: sect.header ? sect.headerDistCm : null, - footerDistanceCm: sect.footer ? sect.footerDistCm : null, + headerFirst: sect.differentFirstPage ? sect.headerFirst : null, + footerFirst: sect.differentFirstPage ? sect.footerFirst : null, + differentFirstPage: sect.differentFirstPage, + headerDistanceCm: hasHeader ? sect.headerDistCm : null, + footerDistanceCm: hasFooter ? sect.footerDistCm : null, language: documentLanguage(stylesDoc, warnings), fonts: extractDocxFonts(files), warnings: [...warnings], @@ -614,12 +627,13 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, headingLevel: nu const skipResult = () => fieldMode === 'result' && (hfFields || !!fieldDateTime); // Route a drawing/pict result: an image is inline; a text box is a block node - // riding ctx.pendingBlocks (dropped in the one-paragraph header/footer schema). + // riding ctx.pendingBlocks. The one-paragraph header/footer schema holds neither, + // so drawings there are dropped with a warning. const pushDrawn = (n: Node | null) => { if (!n) return; + if (hfFields) { ctx.warnings.add('Drawings were removed'); return; } if (n.type !== 'textBox') { out.push(n); return; } - if (hfFields) ctx.warnings.add('Drawings were removed'); - else ctx.pendingBlocks.push(n); + ctx.pendingBlocks.push(n); }; for (const child of Array.from(r.children)) { @@ -652,7 +666,7 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, headingLevel: nu } } else if (t === 'end') { if (fieldDateTime) out.push(fieldDateTime); - else emitField(out, fieldInstr, hfFields); + else emitField(out, fieldInstr, hfFields, marks); fieldMode = 'none'; fieldDateTime = null; } @@ -681,7 +695,7 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, headingLevel: nu } case 'fldSimple': { const instr = el.getAttributeNS(W, 'instr') ?? ''; - if (hfFields) { emitField(out, instr, true); break; } + if (hfFields) { const first = fcAll(el, 'r')[0]; emitField(out, instr, true, first ? runMarks(first) : []); break; } const field = dateTimeFieldFromInstr(instr); if (field) { const first = fcAll(el, 'r')[0]; @@ -704,10 +718,12 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, headingLevel: nu return mergeAdjacentText(out); } -function emitField(out: Node[], instr: string, hfFields: boolean): void { +function emitField(out: Node[], instr: string, hfFields: boolean, marks: Mark[] = []): void { if (!hfFields) return; - if (/\bNUMPAGES\b/.test(instr)) out.push({ type: 'pageCount' }); - else if (/\bPAGE\b/.test(instr)) out.push({ type: 'pageNumber' }); + // The atom carries the field run's marks so its digits render in the run's font/size. + const push = (type: string) => out.push(marks.length ? { type, marks } : { type }); + if (/\bNUMPAGES\b/.test(instr)) push('pageCount'); + else if (/\bPAGE\b/.test(instr)) push('pageNumber'); } // A DATE/TIME field instruction with a picture switch we recognize → a live @@ -1129,9 +1145,10 @@ function pushColumnRuns(inner: Node[], cols: { count: number; gapCm: number }, o function parseSectPr(sect: Element | null, ctx: Ctx): { margins: PageMargins | null; orientation: Orientation | null; format: PageFormat | null; - header: HfDoc; footer: HfDoc; headerDistCm: number | null; footerDistCm: number | null; + header: HfDoc; footer: HfDoc; headerFirst: HfDoc; footerFirst: HfDoc; differentFirstPage: boolean; + headerDistCm: number | null; footerDistCm: number | null; } { - const empty = { margins: null, orientation: null, format: null, header: null, footer: null, headerDistCm: null, footerDistCm: null }; + const empty = { margins: null, orientation: null, format: null, header: null, footer: null, headerFirst: null, footerFirst: null, differentFirstPage: false, headerDistCm: null, footerDistCm: null }; if (!sect) return empty; const pgSz = fc(sect, 'pgSz'); @@ -1146,19 +1163,25 @@ function parseSectPr(sect: Element | null, ctx: Ctx): { ? { top: clampCm(intAttr(pgMar, W, 'top')) ?? 2.54, bottom: clampCm(intAttr(pgMar, W, 'bottom')) ?? 2.54, left: clampCm(intAttr(pgMar, W, 'left')) ?? 2.12, right: clampCm(intAttr(pgMar, W, 'right')) ?? 2.12 } : null; - const refId = (type: string) => { - for (const ref of fcAll(sect, `${type}Reference`)) { - const t = ref.getAttributeNS(W, 'type') ?? 'default'; - if (t === 'first' || t === 'even') ctx.warnings.add('Per-page header/footer variants (first/even pages) are not supported — the default one was used'); - } - const def = fcAll(sect, `${type}Reference`).find((r) => (r.getAttributeNS(W, 'type') ?? 'default') === 'default'); - return def?.getAttributeNS(R, 'id') ?? null; + // Different first page: w:titlePg turns on the "first"-type refs for page 1. + const titlePg = !!fc(sect, 'titlePg'); + const refId = (type: string, variant: 'default' | 'first' = 'default') => { + const want = variant === 'first' ? 'first' : 'default'; + const ref = fcAll(sect, `${type}Reference`).find((r) => (r.getAttributeNS(W, 'type') ?? 'default') === want); + return ref?.getAttributeNS(R, 'id') ?? null; }; + // The even/left variant stays unsupported (later pages fall back to the default). + if (fcAll(sect, 'headerReference').concat(fcAll(sect, 'footerReference')).some((r) => r.getAttributeNS(W, 'type') === 'even')) { + ctx.warnings.add('Per-page header/footer variants (first/even pages) are not supported — the default one was used'); + } const header = convertHfPart(refId('header'), ctx); const footer = convertHfPart(refId('footer'), ctx); + const headerFirst = titlePg ? convertHfPart(refId('header', 'first'), ctx) : null; + const footerFirst = titlePg ? convertHfPart(refId('footer', 'first'), ctx) : null; return { margins, orientation, format, header, footer, + headerFirst, footerFirst, differentFirstPage: titlePg, headerDistCm: clampCm(intAttr(pgMar, W, 'header')), footerDistCm: clampCm(intAttr(pgMar, W, 'footer')), }; @@ -1191,8 +1214,8 @@ function convertHfPart(relId: string | null, ctx: Ctx): HfDoc { const baseRun = hfCtx.styles.paragraphRun(fc(ppr, 'pStyle') ? wVal(fc(ppr, 'pStyle')!) : null); inline.push(...convertInline(p, hfCtx, baseRun, null, true, false).filter((n) => n.type !== PB_MARKER)); } - while (inline[0]?.type === 'hardBreak') inline.shift(); - while (inline[inline.length - 1]?.type === 'hardBreak') inline.pop(); + // Empty source paragraphs become blank lines (hardBreaks); an all-empty zone has no + // runs and no inter-paragraph break, so inline stays empty and the zone is dropped. if (inline.length === 0) return null; const para: Node = { type: 'paragraph', content: inline }; diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 4e4c6a9..4d38c41 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -34,6 +34,11 @@ export interface OdtImportResult { // Single-paragraph docs in the hfExtensions schema; null = no zone. header: HfDoc; footer: HfDoc; + // First-page variants (Word "Different First Page" / ODF header-first). null when + // the file has no first-page override; only used when differentFirstPage is set. + headerFirst: HfDoc; + footerFirst: HfDoc; + differentFirstPage: boolean; // Edge→zone distance (cm): header from top, footer from bottom. null = no zone. headerDistanceCm: number | null; footerDistanceCm: number | null; @@ -363,6 +368,15 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = } } + const headerFirst = hf.headerFirst ? convertHfZone(hf.headerFirst, ctx) : null; + const footerFirst = hf.footerFirst ? convertHfZone(hf.footerFirst, ctx) : null; + // The presence of a first-page element is the flag, even when it's empty (an empty + // first-page zone deliberately blanks page 1 while the default fills later pages). + const differentFirstPage = !!(hf.headerFirst || hf.footerFirst); + // A first-page zone reserves the band even if its default counterpart is empty. + const hasHeader = hf.header || headerFirst; + const hasFooter = hf.footer || footerFirst; + return { content: { type: 'doc', content: blocks }, margins: geometry?.margins ?? null, @@ -370,8 +384,11 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = format: geometry?.format ?? null, header: hf.header ? convertHfZone(hf.header, ctx) : null, footer: hf.footer ? convertHfZone(hf.footer, ctx) : null, - headerDistanceCm: hf.header ? edge?.top ?? null : null, - footerDistanceCm: hf.footer ? edge?.bottom ?? null : null, + headerFirst, + footerFirst, + differentFirstPage, + headerDistanceCm: hasHeader ? edge?.top ?? null : null, + footerDistanceCm: hasFooter ? edge?.bottom ?? null : null, language, fonts, warnings: [...warnings], @@ -404,9 +421,8 @@ function convertHfZone(zoneEl: Element, ctx: Ctx): HfDoc { for (const p of Array.from(child.getElementsByTagNameNS(NS.text, 'p'))) addPara(p); } } - // Trim leading/trailing breaks from empty source paragraphs. - while (inline[0]?.type === 'hardBreak') inline.shift(); - while (inline[inline.length - 1]?.type === 'hardBreak') inline.pop(); + // Empty source paragraphs become blank lines (hardBreaks); an all-empty zone has no + // runs and no inter-paragraph break, so inline stays empty and the zone is dropped. if (inline.length === 0) return null; const para: Node = { type: 'paragraph', content: inline }; @@ -799,13 +815,13 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, headingLevel case 'change-end': continue; default: - // In headers/footers, page fields stay live fields (pageField.ts). - if (hfFields && e.localName === 'page-number') { - out.push({ type: 'pageNumber' }); - continue; - } - if (hfFields && e.localName === 'page-count') { - out.push({ type: 'pageCount' }); + // In headers/footers, page fields stay live fields (pageField.ts). The atom + // carries the run's marks so its digits render in the run's font/size. + if (hfFields && (e.localName === 'page-number' || e.localName === 'page-count')) { + const field: Node = { type: e.localName === 'page-count' ? 'pageCount' : 'pageNumber' }; + const marks = marksFor(props, ctx.resolver, headingLevel, boldByDefault); + if (marks.length) field.marks = marks; + out.push(field); continue; } // Date/time fields become live dateTimeField nodes in the body (a known diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index 3b1863b..1382f9a 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -364,6 +364,8 @@ export class StyleResolver { masterPageHF(): { header: Element | null; footer: Element | null; + headerFirst: Element | null; + footerFirst: Element | null; headerExtraCm: number; footerExtraCm: number; hasVariants: boolean; @@ -378,6 +380,15 @@ export class StyleResolver { } return null; }; + // header-first/footer-first (ODF 1.3 style: or older LibreOffice loext:) — match + // by local name in either namespace so both producers round-trip. + const firstZone = (local: string): Element | null => { + if (!mp) return null; + for (const child of Array.from(mp.children)) { + if (child.localName === local && (child.namespaceURI === NS.style || child.namespaceURI === NS.loext)) return child; + } + return null; + }; const extraCm = (local: 'header-style' | 'footer-style', spacingAttr: 'margin-bottom' | 'margin-top'): number => { if (!layout) return 0; let props: Element | null = null; @@ -398,9 +409,12 @@ export class StyleResolver { return { header: zone('header'), footer: zone('footer'), + headerFirst: firstZone('header-first'), + footerFirst: firstZone('footer-first'), headerExtraCm: extraCm('header-style', 'margin-bottom'), footerExtraCm: extraCm('footer-style', 'margin-top'), - hasVariants: ['header-first', 'footer-first', 'header-left', 'footer-left'].some(l => zone(l) != null), + // Only the left/right (even) variant stays unsupported; -first is handled below. + hasVariants: ['header-left', 'footer-left'].some(l => zone(l) != null), }; } diff --git a/src/lib/storage/headerFooter.ts b/src/lib/storage/headerFooter.ts index 6a86926..4ffe16d 100644 --- a/src/lib/storage/headerFooter.ts +++ b/src/lib/storage/headerFooter.ts @@ -1,14 +1,28 @@ // Header/footer content: one single-paragraph TipTap doc per zone (hfExtensions -// schema), identical on every page. null = zone empty → not exported. +// schema). The 'default' variant repeats on every page; the 'first' variant (Word +// "Different First Page" / ODF header-first) overrides page 1. null = empty zone. export type HfZone = 'header' | 'footer'; +export type HfVariant = 'default' | 'first'; export type HfDoc = { type: 'doc'; content?: unknown[] } | null; -const KEYS: Record = { - header: 'odf-editor-header', - footer: 'odf-editor-footer', +const KEYS: Record> = { + header: { default: 'odf-editor-header', first: 'odf-editor-header-first' }, + footer: { default: 'odf-editor-footer', first: 'odf-editor-footer-first' }, }; +// Whether page 1 uses its own header/footer (Word w:titlePg / ODF header-first). +const DIFFERENT_FIRST_KEY = 'odf-editor-hf-different-first'; + +export function loadDifferentFirstPage(): boolean { + return localStorage.getItem(DIFFERENT_FIRST_KEY) === 'true'; +} + +export function saveDifferentFirstPage(on: boolean): void { + if (on) localStorage.setItem(DIFFERENT_FIRST_KEY, 'true'); + else localStorage.removeItem(DIFFERENT_FIRST_KEY); +} + // Word's default distance from the page edge to the header/footer text. The body // margin stays the body margin (Word semantics); export/import convert to ODF's // margin-to-header model (see export/odt.ts applyHfPostProcess). @@ -47,8 +61,8 @@ export function saveHfDistances(d: HfDistances): void { localStorage.setItem(DIST_KEY, JSON.stringify(d)); } -export function loadHfDoc(zone: HfZone): HfDoc { - const raw = localStorage.getItem(KEYS[zone]); +export function loadHfDoc(zone: HfZone, variant: HfVariant = 'default'): HfDoc { + const raw = localStorage.getItem(KEYS[zone][variant]); if (!raw) return null; try { const parsed = JSON.parse(raw); @@ -58,9 +72,9 @@ export function loadHfDoc(zone: HfZone): HfDoc { } } -export function saveHfDoc(zone: HfZone, doc: HfDoc): void { - if (hfIsEmpty(doc)) localStorage.removeItem(KEYS[zone]); - else localStorage.setItem(KEYS[zone], JSON.stringify(doc)); +export function saveHfDoc(zone: HfZone, doc: HfDoc, variant: HfVariant = 'default'): void { + if (hfIsEmpty(doc)) localStorage.removeItem(KEYS[zone][variant]); + else localStorage.setItem(KEYS[zone][variant], JSON.stringify(doc)); } // Empty = null or a single paragraph without inline content. Empty zones render diff --git a/tests/docx-roundtrip.test.ts b/tests/docx-roundtrip.test.ts index 0a41495..38b3bb3 100644 --- a/tests/docx-roundtrip.test.ts +++ b/tests/docx-roundtrip.test.ts @@ -268,6 +268,61 @@ describe('DOCX export → import round trip', () => { }); }); +describe('DOCX different first page (w:titlePg)', () => { + const fixture: N = { type: 'doc', content: [para('Body')] }; + // Arial 10pt on the runs and the page-field atoms, so the field digits keep the font. + const arial = [{ type: 'textStyle', attrs: { fontFamily: 'Arial', fontSize: '10pt' } }]; + const hf = { + header: { type: 'doc', content: [para('Default header')] }, + footer: { type: 'doc', content: [para([text('Seite ', arial), { type: 'pageNumber', marks: arial }, text(' von ', arial), { type: 'pageCount', marks: arial }])] }, + headerFirst: { type: 'doc', content: [para('Cover header')] }, + footerFirst: { type: 'doc', content: [para('Stand 2025')] }, + differentFirstPage: true, + pageCount: 3, + } as any; + + it('emits w:titlePg + first-type header/footer refs and round-trips the variants', async () => { + const bytes = await buildDocx(fixture, undefined, 'portrait', hf, { language: 'de', country: 'DE' }); + const documentXml = strFromU8(unzipSync(bytes)['word/document.xml']); + expect(documentXml).toContain(']*w:type="first"/); + expect(documentXml).toMatch(/]*w:type="first"/); + + const res = importDocx(bytes); + expect(res.differentFirstPage).toBe(true); + expect(walk(res.header, 'text')[0].text).toBe('Default header'); + expect(walk(res.footer, 'pageNumber').length).toBe(1); + expect(walk(res.headerFirst, 'text')[0].text).toBe('Cover header'); + expect(walk(res.footerFirst, 'text')[0].text).toBe('Stand 2025'); + // The page-field atom keeps the run's font (Arial 10pt) so its digits match the text. + const pn = walk(res.footer, 'pageNumber')[0]; + expect(markAttrs(pn, 'textStyle')).toMatchObject({ fontFamily: 'Arial', fontSize: '10pt' }); + }); + + it('keeps trailing blank lines (empty footer paragraphs) as hardBreaks', async () => { + const br = (): N => ({ type: 'hardBreak' }); + const withBlanks = { + ...hf, + footerFirst: { type: 'doc', content: [para([text('Stand 2025'), br(), br()])] }, + } as any; + const bytes = await buildDocx(fixture, undefined, 'portrait', withBlanks, { language: 'de', country: 'DE' }); + const res = importDocx(bytes); + const inline = res.footerFirst.content[0].content as N[]; + expect(inline.filter((n) => n.type === 'hardBreak').length).toBe(2); + expect(inline[inline.length - 1].type).toBe('hardBreak'); + }); + + it('omits titlePg and first-page zones when the flag is off', async () => { + const bytes = await buildDocx(fixture, undefined, 'portrait', { ...hf, differentFirstPage: false }, { language: 'de', country: 'DE' }); + const documentXml = strFromU8(unzipSync(bytes)['word/document.xml']); + expect(documentXml).not.toContain(' { const dtf = (kind: string, format: string, fixed: boolean, value: string): N => ({ type: 'dateTimeField', attrs: { kind, format, fixed, value } }); diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index adae1a0..df981cf 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -758,8 +758,64 @@ describe('Leg 3: header/footer → buildOdt → importOdt', () => { }); }); +describe('Leg 3a: different first page header/footer → buildOdt → importOdt', () => { + it('round-trips the first-page variants and the flag alongside the defaults', async () => { + // Arial 10pt on the runs AND the page-field atoms, so the field digits keep the font. + const arial = [{ type: 'textStyle', attrs: { fontFamily: 'Arial', fontSize: '10pt' } }]; + const header: N = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Default header' }] }] }; + const footer: N = { type: 'doc', content: [{ type: 'paragraph', attrs: { textAlign: 'center' }, content: [ + { type: 'text', text: 'Seite ', marks: arial }, { type: 'pageNumber', marks: arial }, { type: 'text', text: ' von ', marks: arial }, { type: 'pageCount', marks: arial }, + ] }] }; + const headerFirst: N = { type: 'doc', content: [{ type: 'paragraph', content: [ + { type: 'text', text: 'Cover', marks: [{ type: 'bold' }, ...arial] }, + ] }] }; + const footerFirst: N = { type: 'doc', content: [{ type: 'paragraph', content: [ + { type: 'text', text: 'Stand: x', marks: arial }, { type: 'text', text: ' ' }, { type: 'pageNumber', marks: arial }, + { type: 'hardBreak' }, { type: 'hardBreak' }, + ] }] }; + + const bytes = await buildOdt(fixture, margins, 'portrait', + { header, footer, headerFirst, footerFirst, differentFirstPage: true, pageCount: 3 }); + const res = importOdt(bytes); + + check('dfp: no warnings', res.warnings.length === 0, res.warnings); + check('dfp: flag round-trips', res.differentFirstPage === true, res.differentFirstPage); + check('dfp: default header round-trips', firstDiff(normalize(header), normalize(res.header)) === null, firstDiff(normalize(header), normalize(res.header))); + check('dfp: default footer round-trips', firstDiff(normalize(footer), normalize(res.footer)) === null, firstDiff(normalize(footer), normalize(res.footer))); + check('dfp: first-page header round-trips (incl. marks)', firstDiff(normalize(headerFirst), normalize(res.headerFirst)) === null, firstDiff(normalize(headerFirst), normalize(res.headerFirst))); + check('dfp: first-page footer preserves spacing', res.footerFirst?.content?.[0]?.content?.[0]?.text === 'Stand: x', res.footerFirst); + const ffInline = res.footerFirst?.content?.[0]?.content ?? []; + const ffBreaks = ffInline.filter((n: N) => n.type === 'hardBreak').length; + check('dfp: first-page footer keeps trailing blank lines', ffBreaks === 2 && ffInline[ffInline.length - 1]?.type === 'hardBreak', ffInline); + + // Every variant must be valid in the header/footer editor schema. + const hfSchema = getSchema(hfExtensions()); + let ok = true; + for (const z of [res.header, res.footer, res.headerFirst, res.footerFirst]) { + if (!z) continue; + try { PMNode.fromJSON(hfSchema, z).check(); } catch { ok = false; } + } + check('dfp: all variants valid in hf schema', ok); + + // Flag off ⇒ first-page zones aren't exported. + const offBytes = await buildOdt(fixture, margins, 'portrait', + { header, footer, headerFirst, footerFirst, differentFirstPage: false, pageCount: 3 }); + const offRes = importOdt(offBytes); + check('dfp: first-page zones skipped when flag off', offRes.differentFirstPage === false && offRes.headerFirst === null && offRes.footerFirst === null, offRes); + + // Flag on with an empty first-page footer (default footer present): page 1's footer + // is deliberately blank, and the flag still round-trips (element presence = flag). + const blankFirst = await buildOdt(fixture, margins, 'portrait', + { header: null, footer, headerFirst: null, footerFirst: null, differentFirstPage: true, pageCount: 3 }); + const blankRes = importOdt(blankFirst); + check('dfp: flag survives an empty first-page zone', blankRes.differentFirstPage === true, blankRes.differentFirstPage); + check('dfp: empty first-page footer stays blank', blankRes.footerFirst === null, blankRes.footerFirst); + check('dfp: default footer still present', firstDiff(normalize(footer), normalize(blankRes.footer)) === null, blankRes.footer); + }); +}); + describe('Leg 4: foreign header/footer → importOdt', () => { - it('parses page-number/count fields, reconstructs body margins, warns on per-page variants', () => { + it('parses page-number/count fields, reconstructs body margins, imports the first-page variant', () => { const fStyles = ` @@ -800,8 +856,10 @@ describe('Leg 4: foreign header/footer → importOdt', () => { // Body top margin = page margin (1.5) + header height (0.8) + spacing (0.3) = 2.6. check('foreign hf: body top margin reconstructed', Math.abs((fhf.margins?.top ?? 0) - 2.6) < 0.02, fhf.margins); check('foreign hf: body bottom margin reconstructed', Math.abs((fhf.margins?.bottom ?? 0) - 2.4) < 0.02, fhf.margins); - check('foreign hf: first-page variant warning', - fhf.warnings.some(w => /per-page/i.test(w)), fhf.warnings); + // header-first is a supported variant (Word "Different First Page" / ODF header-first). + check('foreign hf: first-page header parsed', fhf.headerFirst?.content?.[0]?.content?.[0]?.text === 'Cover', fhf.headerFirst); + check('foreign hf: different-first-page flag set', fhf.differentFirstPage === true, fhf.differentFirstPage); + check('foreign hf: no unsupported-variant warning', !fhf.warnings.some(w => /per-page/i.test(w)), fhf.warnings); }); }); diff --git a/tests/unit/hf-enter.test.ts b/tests/unit/hf-enter.test.ts new file mode 100644 index 0000000..575b4c6 --- /dev/null +++ b/tests/unit/hf-enter.test.ts @@ -0,0 +1,38 @@ +// The header/footer schema holds exactly one paragraph, so Enter can't split a block. +// It must instead insert a line break (Word: Enter in a footer adds a blank line that +// grows the zone), while the doc stays a single paragraph. Drives a real HF editor. +import { describe, it, expect } from 'vitest'; +import { Editor } from '@tiptap/core'; +import { hfExtensions } from '../../src/lib/editor/extensions/headerFooter'; + +type N = any; + +function makeEditor(content: N) { + const el = document.createElement('div'); + document.body.appendChild(el); + return new Editor({ element: el, extensions: hfExtensions(), content }); +} +const pressEnter = (editor: Editor) => + editor.view.dom.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); + +describe('header/footer Enter inserts a line break', () => { + it('adds a hardBreak and keeps one paragraph', () => { + const editor = makeEditor({ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Seite 1' }] }] }); + editor.commands.focus('end'); + pressEnter(editor); + const json = editor.getJSON(); + expect(json.content!.length).toBe(1); + expect((json.content![0].content as N[]).filter((n) => n.type === 'hardBreak').length).toBe(1); + editor.destroy(); + }); + + it('adds one line break per Enter, growing the content', () => { + const editor = makeEditor({ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'x' }] }] }); + editor.commands.focus('end'); + pressEnter(editor); + pressEnter(editor); + const inline = editor.getJSON().content![0].content as N[]; + expect(inline.filter((n) => n.type === 'hardBreak').length).toBe(2); + editor.destroy(); + }); +});