fix: header and footer editor
This commit is contained in:
1 parent
cf665e25a0
commit
dce22ab3f4
18 files changed
+713
-93
No files matched your search
+40
-4
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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 === '&' ? '&' : 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 {
|
||||
|
||||
+29
-3
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user