fix: header and footer editor

This commit is contained in:
Steffen Becker committed 2026-07-18 11:49:49 +02:00
1 parent cf665e25a0
commit dce22ab3f4
18 files changed
+713 -93

No files matched your search

+40 -4
View File
@@ -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
+37 -1
View File
@@ -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 @@
<HeaderFooterLayer
bind:headerDoc
bind:footerDoc
bind:headerFirstDoc
bind:footerFirstDoc
{differentFirstPage}
bind:hfEditor
bind:hfActive
bind:hfTick
+128 -19
View File
@@ -10,6 +10,9 @@
let {
headerDoc = $bindable(),
footerDoc = $bindable(),
headerFirstDoc = $bindable(),
footerFirstDoc = $bindable(),
differentFirstPage = false,
numPages,
currentPage,
pageMargins,
@@ -22,6 +25,9 @@
}: {
headerDoc: HfDoc;
footerDoc: HfDoc;
headerFirstDoc: HfDoc;
footerFirstDoc: HfDoc;
differentFirstPage?: boolean;
numPages: number;
currentPage: number;
pageMargins: PageMargins;
@@ -34,6 +40,9 @@
} = $props();
const PAGE_GAP = 20;
// Minimum zone height (~one 12pt line), so a thin margin band (footer distance ≥
// bottom margin, as some Word docs have) still renders instead of collapsing to 0.
const MIN_ZONE_PX = 20;
// Schema for static (read-only) rendering of the inactive zones.
const renderExts = hfExtensions();
@@ -47,10 +56,10 @@
let mLeft = $derived(cmToPx(pageMargins.left));
let mRight = $derived(cmToPx(pageMargins.right));
let contentWidth = $derived(Math.max(0, pageWidthPx - mLeft - mRight));
// Edge→zone distance in px, clamped below the body margin so the zone fits within
// the margin band (matches the export's Math.min(dist, margin) clamp).
let headerDistPx = $derived(Math.min(cmToPx(hfDistances.header), mTop));
let footerDistPx = $derived(Math.min(cmToPx(hfDistances.footer), mBottom));
// Edge→zone distance in px. The footer may sit farther from the edge than the body
// bottom margin (Word's w:footer > 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<typeof generateHTML>[0], renderExts);
const html = generateHTML(doc as Parameters<typeof generateHTML>[0], renderExts);
// A paragraph ending in a hardBreak loses its last blank line: a bare trailing <br>
// 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(/(<br\s*\/?>)((?:<\/[a-zA-Z]+>)*<\/p>)/g, '$1<br>$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<HTMLDivElement | null>(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)}
<div
class="hf-zone hf-{zone}"
class:hf-empty={!html}
@@ -198,8 +255,14 @@
{/each}
{#if hfActive}
{@const box = zoneBox(hfActive, editingPage)}
<div class="hf-zone hf-{hfActive} hf-active" style={boxStyle(box)} bind:this={liveMount}></div>
{@const box = activeZoneBox(hfActive, editingPage)}
{@const first = isFirstVariant(editingPage)}
<div class="hf-zone hf-{hfActive} hf-active" style={boxStyle(box) + ` --hf-tb-offset: ${-activeTrailingPx}px;`} bind:this={liveMount}></div>
<div class="hf-tag" style="top: {box.top}px; left: {box.left}px;">
{hfActive === 'header'
? (first ? t().hf.firstPageHeader : t().hf.headerLabel)
: (first ? t().hf.firstPageFooter : t().hf.footerLabel)}
</div>
<div class="hf-bar" style="top: {box.top}px; left: {box.left + box.width}px;">
<span class="hf-bar-label">{t().hf.insert}</span>
<button class="hf-bar-btn" title={t().hf.pageNumberTitle} onmousedown={(e) => e.preventDefault()} onclick={() => insertField('pageNumber')}>{t().hf.pageNumber}</button>
@@ -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 `<p>` 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 <br> the
static <p> lacks; ProseMirror marks that case with a separator <img>, 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;
}
+21 -2
View File
@@ -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<Orientation>('portrait'), pageFormat = $bindable<PageFormat>('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<Orientation>('portrait'), pageFormat = $bindable<PageFormat>('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}</button>
</div>
<label class="hf-firstpage-row" title={t().toolbarExpanded.differentFirstPageHint}>
<input type="checkbox" bind:checked={differentFirstPage} />
<span>{t().toolbarExpanded.differentFirstPage}</span>
</label>
<div class="lh-section-label">{t().toolbarExpanded.position}</div>
<div class="margin-grid">
@@ -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;
+13 -1
View File
@@ -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,
+23 -5
View File
@@ -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,
+23 -4
View File
@@ -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 } }
+119 -4
View File
@@ -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 <style:header-first>/<style:footer-first> 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 = `</style:${kind}>`;
if (styles.includes(closeTag)) styles = styles.replace(closeTag, `${closeTag}${xml}`);
else if (kind === 'header') styles = styles.replace(/(<style:master-page\b[^>]*>)/, `$1${xml}`);
else styles = styles.replace('</style:master-page>', `${xml}</style:master-page>`);
};
injectVariant('header', firstHeaderPara);
injectVariant('footer', firstFooterPara);
if (mintedStyles.length) {
const defs = mintedStyles.join('');
if (styles.includes('</office:automatic-styles>')) styles = styles.replace('</office:automatic-styles>', `${defs}</office:automatic-styles>`);
else styles = styles.replace('<office:automatic-styles/>', `<office:automatic-styles>${defs}</office:automatic-styles>`);
}
}
files['styles.xml'] = strToU8(styles);
return rezipOdt(files);
}
// Serialize a first-page header/footer paragraph to <style:header-first>/<style:footer-first>
// XML. Runs and page fields become <text:span> 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(`<style:style style:name="${name}" style:family="text"><style:text-properties ${props}/></style:style>`);
return `<text:span text:style-name="${name}">${innerXml}</text:span>`;
};
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 += '<text:line-break/>';
else if (node.type === 'pageNumber') inner += styled('<text:page-number text:select-page="current">1</text:page-number>', node.marks);
else if (node.type === 'pageCount') inner += styled(`<text:page-count>${pageCount}</text:page-count>`, node.marks);
}
const parent = kind === 'header' ? 'Header' : 'Footer';
const align = hfAlign(para);
let paraStyle = parent;
if (align) {
paraStyle = `HFF${kind[0].toUpperCase()}P`;
mint(`<style:style style:name="${paraStyle}" style:family="paragraph" style:parent-style-name="${parent}"><style:paragraph-properties fo:text-align="${align}"/></style:style>`);
}
return `<style:${kind}-first><text:p text:style-name="${paraStyle}">${inner}</text:p></style:${kind}-first>`;
}
// A text run's ODF <style:text-properties> 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 → <text:s text:c>, tabs → <text:tab/>,
// 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 += '<text:tab/>'; i++; continue; }
if (ch === ' ') {
let n = 1;
while (s[i + n] === ' ') n++;
out += n === 1 ? ' ' : `<text:s text:c="${n}"/>`;
i += n;
continue;
}
out += ch === '&' ? '&amp;' : ch === '<' ? '&lt;' : ch === '>' ? '&gt;' : 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 {
+29 -3
View File
@@ -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);
+6
View File
@@ -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',
+6
View File
@@ -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',
+48 -25
View File
@@ -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 };
+28 -12
View File
@@ -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
+15 -1
View File
@@ -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),
};
}
+23 -9
View File
@@ -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<HfZone, string> = {
header: 'odf-editor-header',
footer: 'odf-editor-footer',
const KEYS: Record<HfZone, Record<HfVariant, string>> = {
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
+55
View File
@@ -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:titlePg');
expect(documentXml).toMatch(/<w:headerReference[^>]*w:type="first"/);
expect(documentXml).toMatch(/<w:footerReference[^>]*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('<w:titlePg');
const res = importDocx(bytes);
expect(res.differentFirstPage).toBe(false);
expect(res.headerFirst).toBeNull();
expect(res.footerFirst).toBeNull();
});
});
describe('DOCX date/time fields', () => {
const dtf = (kind: string, format: string, fixed: boolean, value: string): N =>
({ type: 'dateTimeField', attrs: { kind, format, fixed, value } });
+61 -3
View File
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0">
<office:styles>
@@ -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);
});
});
+38
View File
@@ -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();
});
});