feat: odd/even variants for footer/header
This commit is contained in:
1 parent
da1bcd8794
commit
2a7abed917
16 files changed
+272
-79
No files matched your search
+1
-1
@@ -54,7 +54,7 @@
|
||||
- Page margins (cm) and page orientation (portrait / landscape)
|
||||
- Page format picker (Layout panel): 15 Word/LibreOffice sizes — A3–A6, ISO B4–B6, JIS B4/B5, Letter, Legal, Tabloid, Executive, Folio, Statement — each showing its cm dimensions; drives the on-screen page, pagination, and ODT/DOCX/PDF export, and is detected & adopted on import
|
||||
- Multi-column (newspaper) layout: 1–3 columns with adjustable gap, applied to the whole document (no selection) or to selected paragraphs, Word-style — text fills column 1 to the page bottom, then column 2, and flows across pages with mid-paragraph line breaks; a section followed by other content balances its columns. Round-trips to ODF `text:section`/`style:columns` and DOCX continuous sections with `w:cols`
|
||||
- Headers & footers with page-number / page-count fields and configurable edge distances
|
||||
- Headers & footers: page-number / page-count fields, configurable edge distances, and Word-style variants — different first page and different odd & even pages (edit a page's zone directly; blank first/even zones supported). Press Enter to add blank lines that grow a zone into the page while body text reflows so the page break sits above the footer / below the header, never overlapping. Inline images/logos can be inserted in a zone. Round-trips to ODF (`style:header-first`/`-left`, `draw:frame`) and DOCX (`w:titlePg`/`w:evenAndOddHeaders`, `ImageRun`)
|
||||
- Zoom (20–300 %)
|
||||
- Show formatting marks (spaces, tabs, paragraph marks)
|
||||
|
||||
|
||||
+35
-2
@@ -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, loadDifferentFirstPage, saveDifferentFirstPage, hfIsEmpty, DEFAULT_HF_DISTANCES, type HfDoc, type HfZone, type HfDistances } from './lib/storage/headerFooter';
|
||||
import { loadHfDoc, saveHfDoc, loadHfDistances, saveHfDistances, loadDifferentFirstPage, saveDifferentFirstPage, loadDifferentOddEven, saveDifferentOddEven, 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';
|
||||
@@ -49,6 +49,10 @@
|
||||
let headerFirstDoc: HfDoc = $state(loadHfDoc('header', 'first'));
|
||||
let footerFirstDoc: HfDoc = $state(loadHfDoc('footer', 'first'));
|
||||
let differentFirstPage: boolean = $state(loadDifferentFirstPage());
|
||||
// Even-page header/footer (Word "Different Odd & Even Pages"), shown on even pages when on.
|
||||
let headerEvenDoc: HfDoc = $state(loadHfDoc('header', 'even'));
|
||||
let footerEvenDoc: HfDoc = $state(loadHfDoc('footer', 'even'));
|
||||
let differentOddEven: boolean = $state(loadDifferentOddEven());
|
||||
let hfDistances: HfDistances = $state(loadHfDistances());
|
||||
let hfEditor: Editor | null = $state(null);
|
||||
let hfActive: HfZone | null = $state(null);
|
||||
@@ -167,6 +171,19 @@
|
||||
saveHfDoc('footer', footerFirstDoc, 'first');
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
saveHfDoc('header', headerEvenDoc, 'even');
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
saveHfDoc('footer', footerEvenDoc, 'even');
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
saveDifferentOddEven(differentOddEven);
|
||||
hfActive = null;
|
||||
});
|
||||
|
||||
// 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(() => {
|
||||
@@ -325,6 +342,7 @@
|
||||
return {
|
||||
header: headerDoc, footer: footerDoc,
|
||||
headerFirst: headerFirstDoc, footerFirst: footerFirstDoc, differentFirstPage,
|
||||
headerEven: headerEvenDoc, footerEven: footerEvenDoc, differentOddEven,
|
||||
pageCount: numPages,
|
||||
headerDistanceCm: hfDistances.header, footerDistanceCm: hfDistances.footer,
|
||||
};
|
||||
@@ -333,7 +351,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) || !hfIsEmpty(headerFirstDoc) || !hfIsEmpty(footerFirstDoc);
|
||||
return body || !hfIsEmpty(headerDoc) || !hfIsEmpty(footerDoc) || !hfIsEmpty(headerFirstDoc) || !hfIsEmpty(footerFirstDoc) || !hfIsEmpty(headerEvenDoc) || !hfIsEmpty(footerEvenDoc);
|
||||
}
|
||||
|
||||
function handleNew() {
|
||||
@@ -347,6 +365,9 @@
|
||||
headerFirstDoc = null;
|
||||
footerFirstDoc = null;
|
||||
differentFirstPage = false;
|
||||
headerEvenDoc = null;
|
||||
footerEvenDoc = null;
|
||||
differentOddEven = false;
|
||||
pageMargins = { ...DEFAULT_MARGINS };
|
||||
pageOrientation = 'portrait';
|
||||
pageFormat = 'A4';
|
||||
@@ -412,6 +433,9 @@
|
||||
headerFirstDoc = result.headerFirst;
|
||||
footerFirstDoc = result.footerFirst;
|
||||
differentFirstPage = result.differentFirstPage;
|
||||
headerEvenDoc = result.headerEven;
|
||||
footerEvenDoc = result.footerEven;
|
||||
differentOddEven = result.differentOddEven;
|
||||
hfDistances = {
|
||||
header: result.headerDistanceCm ?? DEFAULT_HF_DISTANCES.header,
|
||||
footer: result.footerDistanceCm ?? DEFAULT_HF_DISTANCES.footer,
|
||||
@@ -426,6 +450,8 @@
|
||||
collectFontFamilies(result.footer, fontSet);
|
||||
collectFontFamilies(result.headerFirst, fontSet);
|
||||
collectFontFamilies(result.footerFirst, fontSet);
|
||||
collectFontFamilies(result.headerEven, fontSet);
|
||||
collectFontFamilies(result.footerEven, fontSet);
|
||||
const missingFonts = await unavailableFonts(fontSet);
|
||||
|
||||
const warnings = result.warnings.map(localizeImportMessage);
|
||||
@@ -582,6 +608,9 @@
|
||||
headerFirstDoc,
|
||||
footerFirstDoc,
|
||||
differentFirstPage,
|
||||
headerEvenDoc,
|
||||
footerEvenDoc,
|
||||
differentOddEven,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[pdf] Print failed:', err);
|
||||
@@ -892,6 +921,7 @@
|
||||
bind:pageFormat
|
||||
bind:hfDistances
|
||||
bind:differentFirstPage
|
||||
bind:differentOddEven
|
||||
hfActive={hfActive}
|
||||
onEditZone={(zone) => (hfActive = zone)}
|
||||
onDebugDump={handleDebugDump}
|
||||
@@ -913,6 +943,9 @@
|
||||
bind:headerFirstDoc
|
||||
bind:footerFirstDoc
|
||||
{differentFirstPage}
|
||||
bind:headerEvenDoc
|
||||
bind:footerEvenDoc
|
||||
{differentOddEven}
|
||||
bind:hfEditor
|
||||
bind:hfActive
|
||||
bind:hfTick
|
||||
|
||||
@@ -35,12 +35,14 @@
|
||||
pageFormat = 'A4',
|
||||
headerDoc = $bindable(null), footerDoc = $bindable(null), hfDistances = DEFAULT_HF_DISTANCES,
|
||||
headerFirstDoc = $bindable(null), footerFirstDoc = $bindable(null), differentFirstPage = false,
|
||||
headerEvenDoc = $bindable(null), footerEvenDoc = $bindable(null), differentOddEven = 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;
|
||||
headerEvenDoc?: HfDoc; footerEvenDoc?: HfDoc; differentOddEven?: boolean;
|
||||
hfEditor?: Editor | null; hfActive?: HfZone | null; hfTick?: number;
|
||||
} = $props();
|
||||
|
||||
@@ -70,9 +72,13 @@
|
||||
// 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)));
|
||||
// "rest" = every page ≥ 2; fold the even variant in (max) so even pages never overlap
|
||||
// a taller even zone — pageBreaks uses one --pb-content-*-rest for all of them.
|
||||
let evenTopReach = $derived(differentOddEven ? hfReachPx(headerEvenDoc ?? null, headerDistPx) : 0);
|
||||
let evenBottomReach = $derived(differentOddEven ? hfReachPx(footerEvenDoc ?? null, footerDistPx) : 0);
|
||||
let effTopRest = $derived(Math.max(mTopPx, hfReachPx(headerDoc ?? null, headerDistPx), evenTopReach));
|
||||
let effTopFirst = $derived(Math.max(mTopPx, hfReachPx((differentFirstPage ? headerFirstDoc : headerDoc) ?? null, headerDistPx)));
|
||||
let effBottomRest = $derived(Math.max(mBottomPx, hfReachPx(footerDoc ?? null, footerDistPx)));
|
||||
let effBottomRest = $derived(Math.max(mBottomPx, hfReachPx(footerDoc ?? null, footerDistPx), evenBottomReach));
|
||||
let effBottomFirst = $derived(Math.max(mBottomPx, hfReachPx((differentFirstPage ? footerFirstDoc : footerDoc) ?? null, footerDistPx)));
|
||||
$effect(() => {
|
||||
const s = document.documentElement.style;
|
||||
@@ -733,6 +739,9 @@
|
||||
bind:headerFirstDoc
|
||||
bind:footerFirstDoc
|
||||
{differentFirstPage}
|
||||
bind:headerEvenDoc
|
||||
bind:footerEvenDoc
|
||||
{differentOddEven}
|
||||
bind:hfEditor
|
||||
bind:hfActive
|
||||
bind:hfTick
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Editor, generateHTML, type Content } from '@tiptap/core';
|
||||
import { hfExtensions } from '../editor/extensions/headerFooter';
|
||||
import { hfIsEmpty, DEFAULT_HF_DISTANCES, type HfDoc, type HfZone, type HfDistances } from '../storage/headerFooter';
|
||||
import { hfIsEmpty, DEFAULT_HF_DISTANCES, type HfDoc, type HfZone, type HfVariant, type HfDistances } from '../storage/headerFooter';
|
||||
import { cmToPx, PX_PER_CM, type PageMargins } from '../storage/pageMargins';
|
||||
import { type Orientation } from '../storage/pageOrientation';
|
||||
import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
|
||||
@@ -13,6 +13,9 @@
|
||||
headerFirstDoc = $bindable(),
|
||||
footerFirstDoc = $bindable(),
|
||||
differentFirstPage = false,
|
||||
headerEvenDoc = $bindable(),
|
||||
footerEvenDoc = $bindable(),
|
||||
differentOddEven = false,
|
||||
numPages,
|
||||
currentPage,
|
||||
pageMargins,
|
||||
@@ -28,6 +31,9 @@
|
||||
headerFirstDoc: HfDoc;
|
||||
footerFirstDoc: HfDoc;
|
||||
differentFirstPage?: boolean;
|
||||
headerEvenDoc: HfDoc;
|
||||
footerEvenDoc: HfDoc;
|
||||
differentOddEven?: boolean;
|
||||
numPages: number;
|
||||
currentPage: number;
|
||||
pageMargins: PageMargins;
|
||||
@@ -107,13 +113,21 @@
|
||||
let footerHtml = $derived(staticHtml(footerDoc));
|
||||
let headerFirstHtml = $derived(staticHtml(headerFirstDoc));
|
||||
let footerFirstHtml = $derived(staticHtml(footerFirstDoc));
|
||||
let headerEvenHtml = $derived(staticHtml(headerEvenDoc));
|
||||
let footerEvenHtml = $derived(staticHtml(footerEvenDoc));
|
||||
|
||||
// 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;
|
||||
// Which variant a page shows (Word precedence): page 1 → first (if on), other even
|
||||
// pages → even (if on), everything else → default/odd. Each variant, when on, always
|
||||
// shows its own (possibly empty) zone, never the default.
|
||||
function variantFor(page: number): HfVariant {
|
||||
if (differentFirstPage && page === 1) return 'first';
|
||||
if (differentOddEven && page % 2 === 0) return 'even';
|
||||
return 'default';
|
||||
}
|
||||
function zoneHtml(zone: HfZone, page: number): string {
|
||||
if (zone === 'header') return isFirstVariant(page) ? headerFirstHtml : headerHtml;
|
||||
return isFirstVariant(page) ? footerFirstHtml : footerHtml;
|
||||
const v = variantFor(page);
|
||||
if (zone === 'header') return v === 'first' ? headerFirstHtml : v === 'even' ? headerEvenHtml : headerHtml;
|
||||
return v === 'first' ? footerFirstHtml : v === 'even' ? footerEvenHtml : footerHtml;
|
||||
}
|
||||
|
||||
// Replace the placeholder text in every page-field span with the real value:
|
||||
@@ -138,9 +152,10 @@
|
||||
let pendingPage: number | null = null;
|
||||
let liveZone: HfZone | null = null;
|
||||
|
||||
// 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;
|
||||
// The doc for a zone + which variant is being edited.
|
||||
function zoneDoc(zone: HfZone, variant: HfVariant): HfDoc {
|
||||
if (variant === 'first') return zone === 'header' ? headerFirstDoc : footerFirstDoc;
|
||||
if (variant === 'even') return zone === 'header' ? headerEvenDoc : footerEvenDoc;
|
||||
return zone === 'header' ? headerDoc : footerDoc;
|
||||
}
|
||||
function emptyDoc(): HfDoc {
|
||||
@@ -174,13 +189,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);
|
||||
// Which variant this edit session targets — fixed for its lifetime (the edited page's
|
||||
// variant; App ends the edit when a flag toggles).
|
||||
const editingVariant = variantFor(editingPage);
|
||||
const ed = new Editor({
|
||||
element: mount,
|
||||
extensions: hfExtensions(zone === 'header' ? t().hf.headerPlaceholder : t().hf.footerPlaceholder),
|
||||
content: (zoneDoc(zone, editingFirst) ?? emptyDoc()) as Content,
|
||||
content: (zoneDoc(zone, editingVariant) ?? 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: () => {
|
||||
@@ -188,9 +203,12 @@
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
const json = editor.getJSON() as HfDoc;
|
||||
if (editingFirst) {
|
||||
if (editingVariant === 'first') {
|
||||
if (zone === 'header') headerFirstDoc = json;
|
||||
else footerFirstDoc = json;
|
||||
} else if (editingVariant === 'even') {
|
||||
if (zone === 'header') headerEvenDoc = json;
|
||||
else footerEvenDoc = json;
|
||||
} else if (zone === 'header') headerDoc = json;
|
||||
else footerDoc = json;
|
||||
},
|
||||
@@ -256,12 +274,12 @@
|
||||
|
||||
{#if hfActive}
|
||||
{@const box = activeZoneBox(hfActive, editingPage)}
|
||||
{@const first = isFirstVariant(editingPage)}
|
||||
{@const variant = variantFor(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)}
|
||||
? (variant === 'first' ? t().hf.firstPageHeader : variant === 'even' ? t().hf.evenPageHeader : t().hf.headerLabel)
|
||||
: (variant === 'first' ? t().hf.firstPageFooter : variant === 'even' ? t().hf.evenPageFooter : 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>
|
||||
|
||||
@@ -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), 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();
|
||||
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), differentOddEven = $bindable(false), hfActive = null, onEditZone, onDebugDump }:
|
||||
{ editor: Editor | null; tick: number; showFormattingMarks: boolean; pageMargins?: PageMargins; pageOrientation?: Orientation; pageFormat?: PageFormat; hfDistances?: HfDistances; differentFirstPage?: boolean; differentOddEven?: boolean; hfActive?: 'header' | 'footer' | null; onEditZone?: (zone: 'header' | 'footer') => void; onDebugDump?: () => void } = $props();
|
||||
|
||||
const PAGE_FORMATS = Object.keys(PAGE_FORMAT_CM) as PageFormat[];
|
||||
|
||||
@@ -1274,6 +1274,10 @@
|
||||
<input type="checkbox" bind:checked={differentFirstPage} />
|
||||
<span>{t().toolbarExpanded.differentFirstPage}</span>
|
||||
</label>
|
||||
<label class="hf-firstpage-row" title={t().toolbarExpanded.differentOddEvenHint}>
|
||||
<input type="checkbox" bind:checked={differentOddEven} />
|
||||
<span>{t().toolbarExpanded.differentOddEven}</span>
|
||||
</label>
|
||||
|
||||
<div class="lh-section-label">{t().toolbarExpanded.position}</div>
|
||||
<div class="margin-grid">
|
||||
|
||||
+11
-4
@@ -856,6 +856,10 @@ export async function buildDocx(
|
||||
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;
|
||||
// Different odd & even pages (Word w:evenAndOddHeaders): even pages get their own zone.
|
||||
const differentOddEven = !!hf?.differentOddEven;
|
||||
const evenHeaderPara = differentOddEven && !hfIsEmpty(hf?.headerEven ?? null) ? (hf!.headerEven!.content![0] as TiptapNode) : null;
|
||||
const evenFooterPara = differentOddEven && !hfIsEmpty(hf?.footerEven ?? null) ? (hf!.footerEven!.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);
|
||||
@@ -875,23 +879,26 @@ export async function buildDocx(
|
||||
// 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 && !firstHeaderPara && !evenHeaderPara) return undefined;
|
||||
const h: { default?: Header; first?: Header; even?: Header } = {};
|
||||
if (headerPara) h.default = new Header({ children: [paragraphToDocx(headerPara)] });
|
||||
if (firstHeaderPara) h.first = new Header({ children: [paragraphToDocx(firstHeaderPara)] });
|
||||
if (evenHeaderPara) h.even = new Header({ children: [paragraphToDocx(evenHeaderPara)] });
|
||||
return h;
|
||||
};
|
||||
const mkFooters = () => {
|
||||
if (!footerPara && !firstFooterPara) return undefined;
|
||||
const f: { default?: Footer; first?: Footer } = {};
|
||||
if (!footerPara && !firstFooterPara && !evenFooterPara) return undefined;
|
||||
const f: { default?: Footer; first?: Footer; even?: Footer } = {};
|
||||
if (footerPara) f.default = new Footer({ children: [paragraphToDocx(footerPara)] });
|
||||
if (firstFooterPara) f.first = new Footer({ children: [paragraphToDocx(firstFooterPara)] });
|
||||
if (evenFooterPara) f.even = new Footer({ children: [paragraphToDocx(evenFooterPara)] });
|
||||
return f;
|
||||
};
|
||||
|
||||
const doc = new Document({
|
||||
creator: 'Web ODF Editor',
|
||||
defaultTabStop: cmToTwip(1.25),
|
||||
...(differentOddEven ? { evenAndOddHeaderAndFooters: true } : {}),
|
||||
...(hasToc ? { features: { updateFields: true } } : {}),
|
||||
styles: buildStyles(language),
|
||||
numbering: { config: num.config },
|
||||
|
||||
+40
-19
@@ -2078,6 +2078,11 @@ export type HfExport = {
|
||||
headerFirst?: HfDoc;
|
||||
footerFirst?: HfDoc;
|
||||
differentFirstPage?: boolean;
|
||||
// Even-page overrides (Word evenAndOddHeaders / ODF header-left); only used when
|
||||
// differentOddEven is set. null = the even pages' zone is blank.
|
||||
headerEven?: HfDoc;
|
||||
footerEven?: HfDoc;
|
||||
differentOddEven?: boolean;
|
||||
pageCount: number;
|
||||
headerDistanceCm?: number;
|
||||
footerDistanceCm?: number;
|
||||
@@ -2102,6 +2107,10 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
|
||||
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;
|
||||
// Different odd & even pages (ODF header-left): even pages get their own zone.
|
||||
const differentOddEven = !!hf?.differentOddEven;
|
||||
let evenHeaderPara = differentOddEven && hf && !hfIsEmpty(hf.headerEven ?? null) ? (hf.headerEven!.content![0] as TiptapNode) : null;
|
||||
let evenFooterPara = differentOddEven && hf && !hfIsEmpty(hf.footerEven ?? null) ? (hf.footerEven!.content![0] as TiptapNode) : null;
|
||||
// Hoist header/footer inline images out to HFIMG sentinels before odf-kit serializes
|
||||
// the zones; applyHfPostProcess rewrites them to <draw:frame> in styles.xml.
|
||||
const hfImages: ImageExport[] = [];
|
||||
@@ -2109,6 +2118,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
|
||||
footerPara = replaceHfImages(footerPara, hfImages);
|
||||
firstHeaderPara = replaceHfImages(firstHeaderPara, hfImages);
|
||||
firstFooterPara = replaceHfImages(firstFooterPara, hfImages);
|
||||
evenHeaderPara = replaceHfImages(evenHeaderPara, hfImages);
|
||||
evenFooterPara = replaceHfImages(evenFooterPara, hfImages);
|
||||
// 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) {
|
||||
@@ -2116,6 +2127,12 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
|
||||
if (headerPara || firstHeaderPara) { headerPara ??= empty(); firstHeaderPara ??= empty(); }
|
||||
if (footerPara || firstFooterPara) { footerPara ??= empty(); firstFooterPara ??= empty(); }
|
||||
}
|
||||
// Same for even pages: an empty even zone blanks it while the default fills odd pages.
|
||||
if (differentOddEven) {
|
||||
const empty = (): TiptapNode => ({ type: 'paragraph', content: [] });
|
||||
if (headerPara || evenHeaderPara) { headerPara ??= empty(); evenHeaderPara ??= empty(); }
|
||||
if (footerPara || evenFooterPara) { footerPara ??= empty(); evenFooterPara ??= 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);
|
||||
@@ -2248,7 +2265,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, firstHeaderPara, firstFooterPara, hf?.pageCount ?? 1, hfImages);
|
||||
return applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist, firstHeaderPara, firstFooterPara, hf?.pageCount ?? 1, hfImages, evenHeaderPara, evenFooterPara);
|
||||
}
|
||||
|
||||
function hfAlign(para: TiptapNode): AlignValue | null {
|
||||
@@ -2259,8 +2276,8 @@ 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, firstHeaderPara: TiptapNode | null = null, firstFooterPara: TiptapNode | null = null, pageCount = 1, hfImages: ImageExport[] = []): Uint8Array {
|
||||
if (!headerPara && !footerPara && !firstHeaderPara && !firstFooterPara) return odtBytes;
|
||||
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, hfImages: ImageExport[] = [], evenHeaderPara: TiptapNode | null = null, evenFooterPara: TiptapNode | null = null): Uint8Array {
|
||||
if (!headerPara && !footerPara && !firstHeaderPara && !firstFooterPara && !evenHeaderPara && !evenFooterPara) return odtBytes;
|
||||
|
||||
const files = unzipSync(odtBytes);
|
||||
const stylesBytes = files['styles.xml'];
|
||||
@@ -2296,24 +2313,26 @@ 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
|
||||
// Different first page / odd-even: inject <style:{header,footer}-{first,left}> 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) {
|
||||
if (firstHeaderPara || firstFooterPara || evenHeaderPara || evenFooterPara) {
|
||||
const mintedStyles: string[] = [];
|
||||
const mint = (n: string) => { mintedStyles.push(n); };
|
||||
const injectVariant = (kind: 'header' | 'footer', para: TiptapNode | null) => {
|
||||
const injectVariant = (kind: 'header' | 'footer', suffix: 'first' | 'left', 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 xml = hfVariantZoneXml(kind, suffix, para, pageCount, mint);
|
||||
// Insert after the matching default zone (always present when a variant exists,
|
||||
// since buildOdt emits an empty default alongside it), 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);
|
||||
injectVariant('header', 'first', firstHeaderPara);
|
||||
injectVariant('header', 'left', evenHeaderPara);
|
||||
injectVariant('footer', 'first', firstFooterPara);
|
||||
injectVariant('footer', 'left', evenFooterPara);
|
||||
if (mintedStyles.length) {
|
||||
const defs = mintedStyles.join('');
|
||||
if (styles.includes('</office:automatic-styles>')) styles = styles.replace('</office:automatic-styles>', `${defs}</office:automatic-styles>`);
|
||||
@@ -2355,16 +2374,18 @@ function ensureDrawNamespaces(styles: string): string {
|
||||
return missing.length ? styles.replace(/<office:document-styles\b/, `<office:document-styles ${missing.join(' ')}`) : styles;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Serialize a variant header/footer paragraph to <style:{header,footer}-{first,left}>
|
||||
// XML (first = page 1, left = even pages). Runs and page fields become <text:span>
|
||||
// referencing minted automatic text styles (pushed via `mint`); hardBreak → line-break.
|
||||
function hfVariantZoneXml(kind: 'header' | 'footer', suffix: 'first' | 'left', para: TiptapNode, pageCount: number, mint: (styleXml: string) => void): string {
|
||||
let styleSeq = 0;
|
||||
// Distinct minted-style prefix per variant so first + even styles never collide.
|
||||
const pfx = `HF${suffix === 'first' ? 'F' : 'L'}${kind[0].toUpperCase()}`;
|
||||
// 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}`;
|
||||
const name = `${pfx}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>`;
|
||||
};
|
||||
@@ -2381,10 +2402,10 @@ function hfFirstZoneXml(kind: 'header' | 'footer', para: TiptapNode, pageCount:
|
||||
const align = hfAlign(para);
|
||||
let paraStyle = parent;
|
||||
if (align) {
|
||||
paraStyle = `HFF${kind[0].toUpperCase()}P`;
|
||||
paraStyle = `${pfx}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>`;
|
||||
return `<style:${kind}-${suffix}><text:p text:style-name="${paraStyle}">${inner}</text:p></style:${kind}-${suffix}>`;
|
||||
}
|
||||
|
||||
// A text run's ODF <style:text-properties> attribute string from TipTap marks (mirrors
|
||||
|
||||
@@ -359,6 +359,11 @@ export interface PrintPdfOptions {
|
||||
headerFirstDoc?: HfDoc;
|
||||
footerFirstDoc?: HfDoc;
|
||||
differentFirstPage?: boolean;
|
||||
// Even-page overrides (Word "Different Odd & Even Pages"); rendered via @page :left
|
||||
// (in an LTR document left pages are the even ones), which :first still overrides.
|
||||
headerEvenDoc?: HfDoc;
|
||||
footerEvenDoc?: HfDoc;
|
||||
differentOddEven?: boolean;
|
||||
}
|
||||
|
||||
// First-row column weights from table JSON (honours colspan); mirrors tableView.
|
||||
@@ -465,6 +470,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.differentOddEven ? `@page :left {\n ${marginBoxesFirst(o.headerEvenDoc ?? null, o.footerEvenDoc ?? null)}\n}` : ''}
|
||||
${o.differentFirstPage ? `@page :first {\n ${marginBoxesFirst(o.headerFirstDoc ?? null, o.footerFirstDoc ?? null)}\n}` : ''}
|
||||
html, body { margin: 0; padding: 0; background: #fff; }
|
||||
.paper { width: auto !important; transform: none !important; box-shadow: none !important; background: none !important; }
|
||||
@@ -494,6 +500,9 @@ export function printPdf(opts: PrintPdfOptions): void {
|
||||
headerFirstDoc: opts.headerFirstDoc ?? null,
|
||||
footerFirstDoc: opts.footerFirstDoc ?? null,
|
||||
differentFirstPage: opts.differentFirstPage ?? false,
|
||||
headerEvenDoc: opts.headerEvenDoc ?? null,
|
||||
footerEvenDoc: opts.footerEvenDoc ?? null,
|
||||
differentOddEven: opts.differentOddEven ?? false,
|
||||
};
|
||||
const title = (o.fileName ?? deriveFilename(o.json)).replace(/\.(odt|pdf)$/i, '');
|
||||
const body = buildBodyHtml(o.json);
|
||||
|
||||
@@ -117,6 +117,8 @@ const de: Messages = {
|
||||
editFooter: 'Fußzeile bearbeiten',
|
||||
differentFirstPage: 'Erste Seite anders',
|
||||
differentFirstPageHint: 'Der ersten Seite eine eigene Kopf- und Fußzeile geben',
|
||||
differentOddEven: 'Gerade & ungerade Seiten anders',
|
||||
differentOddEvenHint: 'Geraden Seiten eine eigene Kopf- und Fußzeile geben',
|
||||
position: 'Position (cm)',
|
||||
hfDist: { header: 'Kopfzeile von oben', footer: 'Fußzeile von unten' },
|
||||
hfField: (label) => `${label} (cm)`,
|
||||
@@ -289,6 +291,8 @@ const de: Messages = {
|
||||
footerLabel: 'Fußzeile',
|
||||
firstPageHeader: 'Kopfzeile erste Seite',
|
||||
firstPageFooter: 'Fußzeile erste Seite',
|
||||
evenPageHeader: 'Kopfzeile gerade Seiten',
|
||||
evenPageFooter: 'Fußzeile gerade Seiten',
|
||||
insert: 'Einfügen',
|
||||
pageNumber: 'Seitenzahl',
|
||||
pageNumberTitle: 'Aktuelle Seitenzahl einfügen',
|
||||
@@ -354,7 +358,6 @@ const de: Messages = {
|
||||
imagesSkipped: 'Einige Bilder konnten nicht gelesen werden und wurden übersprungen',
|
||||
shapesRemoved: 'Nicht unterstützte Formen wurden entfernt',
|
||||
drawingsRemoved: 'Zeichnungen wurden entfernt',
|
||||
perPageHf: 'Seitenabhängige Kopf-/Fußzeilen-Varianten (erste/gerade Seiten) werden nicht unterstützt — die Standardvariante wurde verwendet',
|
||||
noDictionary: (tag) => `Für die Rechtschreibsprache „${tag}" ist kein Wörterbuch enthalten — die Rechtschreibprüfung wurde deaktiviert`,
|
||||
hfFlattened: 'Listen/Tabellen in Kopf- oder Fußzeilen wurden in Text umgewandelt',
|
||||
textBoxFlattened: 'In Tabellenzellen oder anderen Textfeldern verschachtelte Textfelder wurden aufgelöst',
|
||||
|
||||
@@ -120,6 +120,8 @@ const en = {
|
||||
editFooter: 'Edit footer',
|
||||
differentFirstPage: 'Different first page',
|
||||
differentFirstPageHint: 'Give the first page its own header and footer',
|
||||
differentOddEven: 'Different odd & even pages',
|
||||
differentOddEvenHint: 'Give even pages their own header and footer',
|
||||
position: 'Position (cm)',
|
||||
hfDist: { header: 'Header from top', footer: 'Footer from bottom' },
|
||||
hfField: (label: string) => `${label} (cm)`,
|
||||
@@ -292,6 +294,8 @@ const en = {
|
||||
footerLabel: 'Footer',
|
||||
firstPageHeader: 'First page header',
|
||||
firstPageFooter: 'First page footer',
|
||||
evenPageHeader: 'Even page header',
|
||||
evenPageFooter: 'Even page footer',
|
||||
insert: 'Insert',
|
||||
pageNumber: 'Page number',
|
||||
pageNumberTitle: 'Insert the current page number',
|
||||
@@ -359,7 +363,6 @@ const en = {
|
||||
imagesSkipped: 'Some images could not be read and were skipped',
|
||||
shapesRemoved: 'Unsupported shapes were removed',
|
||||
drawingsRemoved: 'Drawings were removed',
|
||||
perPageHf: 'Per-page header/footer variants (first/even pages) are not supported — the default one was used',
|
||||
noDictionary: (tag: string) => `Spell-check language "${tag}" has no bundled dictionary — spell check was turned off`,
|
||||
hfFlattened: 'Lists/tables in headers or footers were flattened to text',
|
||||
textBoxFlattened: 'Text boxes nested in table cells or other text boxes were flattened',
|
||||
|
||||
+33
-16
@@ -127,26 +127,29 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
|
||||
});
|
||||
if (blocks.length === 0) blocks.push({ type: 'paragraph' });
|
||||
|
||||
let sect = parseSectPr(finalSectPr, ctx);
|
||||
// Odd/even pages: a document-level setting (settings.xml), not a section property.
|
||||
const oddEven = docHasEvenOddHeaders(files);
|
||||
let sect = parseSectPr(finalSectPr, ctx, oddEven);
|
||||
// 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) {
|
||||
// page/even variants, keeping the different-first-page flag if either section set it.
|
||||
if (!sect.header && !sect.footer && !sect.headerFirst && !sect.footerFirst && !sect.headerEven && !sect.footerEven) {
|
||||
const prev = [...midSectPrs].reverse().find((s) => fc(s, 'headerReference') || fc(s, 'footerReference'));
|
||||
if (prev) {
|
||||
const prevSect = parseSectPr(prev, ctx);
|
||||
const prevSect = parseSectPr(prev, ctx, oddEven);
|
||||
sect = {
|
||||
...sect,
|
||||
header: prevSect.header, footer: prevSect.footer,
|
||||
headerFirst: prevSect.headerFirst, footerFirst: prevSect.footerFirst,
|
||||
differentFirstPage: sect.differentFirstPage || prevSect.differentFirstPage,
|
||||
headerEven: prevSect.headerEven, footerEven: prevSect.footerEven,
|
||||
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);
|
||||
// A first-page/even zone reserves the header/footer band even when its default is empty.
|
||||
const hasHeader = sect.header || (sect.differentFirstPage && sect.headerFirst) || (sect.differentOddEven && sect.headerEven);
|
||||
const hasFooter = sect.footer || (sect.differentFirstPage && sect.footerFirst) || (sect.differentOddEven && sect.footerEven);
|
||||
|
||||
return {
|
||||
content: { type: 'doc', content: blocks },
|
||||
@@ -158,6 +161,9 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
|
||||
headerFirst: sect.differentFirstPage ? sect.headerFirst : null,
|
||||
footerFirst: sect.differentFirstPage ? sect.footerFirst : null,
|
||||
differentFirstPage: sect.differentFirstPage,
|
||||
headerEven: sect.differentOddEven ? sect.headerEven : null,
|
||||
footerEven: sect.differentOddEven ? sect.footerEven : null,
|
||||
differentOddEven: sect.differentOddEven,
|
||||
headerDistanceCm: hasHeader ? sect.headerDistCm : null,
|
||||
footerDistanceCm: hasFooter ? sect.footerDistCm : null,
|
||||
language: documentLanguage(stylesDoc, warnings),
|
||||
@@ -1147,12 +1153,24 @@ function pushColumnRuns(inner: Node[], cols: { count: number; gapCm: number }, o
|
||||
flush();
|
||||
}
|
||||
|
||||
function parseSectPr(sect: Element | null, ctx: Ctx): {
|
||||
// Word's "Different Odd & Even Pages" toggle lives in settings.xml, not the sectPr.
|
||||
function docHasEvenOddHeaders(files: Record<string, Uint8Array>): boolean {
|
||||
const bytes = files['word/settings.xml'];
|
||||
if (!bytes) return false;
|
||||
try {
|
||||
return !!parseXml(strFromU8(bytes)).getElementsByTagNameNS(W, 'evenAndOddHeaders')[0];
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function parseSectPr(sect: Element | null, ctx: Ctx, oddEven = false): {
|
||||
margins: PageMargins | null; orientation: Orientation | null; format: PageFormat | null;
|
||||
header: HfDoc; footer: HfDoc; headerFirst: HfDoc; footerFirst: HfDoc; differentFirstPage: boolean;
|
||||
headerEven: HfDoc; footerEven: HfDoc; differentOddEven: boolean;
|
||||
headerDistCm: number | null; footerDistCm: number | null;
|
||||
} {
|
||||
const empty = { margins: null, orientation: null, format: null, header: null, footer: null, headerFirst: null, footerFirst: null, differentFirstPage: false, headerDistCm: null, footerDistCm: null };
|
||||
const empty = { margins: null, orientation: null, format: null, header: null, footer: null, headerFirst: null, footerFirst: null, differentFirstPage: false, headerEven: null, footerEven: null, differentOddEven: false, headerDistCm: null, footerDistCm: null };
|
||||
if (!sect) return empty;
|
||||
|
||||
const pgSz = fc(sect, 'pgSz');
|
||||
@@ -1169,23 +1187,22 @@ function parseSectPr(sect: Element | null, ctx: Ctx): {
|
||||
|
||||
// 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);
|
||||
const refId = (type: string, variant: 'default' | 'first' | 'even' = 'default') => {
|
||||
const ref = fcAll(sect, `${type}Reference`).find((r) => (r.getAttributeNS(W, 'type') ?? 'default') === variant);
|
||||
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;
|
||||
// Odd/even pages: settings.xml w:evenAndOddHeaders turns on the "even"-type refs.
|
||||
const headerEven = oddEven ? convertHfPart(refId('header', 'even'), ctx) : null;
|
||||
const footerEven = oddEven ? convertHfPart(refId('footer', 'even'), ctx) : null;
|
||||
return {
|
||||
margins, orientation, format, header, footer,
|
||||
headerFirst, footerFirst, differentFirstPage: titlePg,
|
||||
headerEven, footerEven, differentOddEven: oddEven,
|
||||
headerDistCm: clampCm(intAttr(pgMar, W, 'header')),
|
||||
footerDistCm: clampCm(intAttr(pgMar, W, 'footer')),
|
||||
};
|
||||
|
||||
+14
-6
@@ -39,6 +39,11 @@ export interface OdtImportResult {
|
||||
headerFirst: HfDoc;
|
||||
footerFirst: HfDoc;
|
||||
differentFirstPage: boolean;
|
||||
// Even-page variants (Word "Different Odd & Even Pages" / ODF header-left). null when
|
||||
// the file has no even-page override; only used when differentOddEven is set.
|
||||
headerEven: HfDoc;
|
||||
footerEven: HfDoc;
|
||||
differentOddEven: boolean;
|
||||
// Edge→zone distance (cm): header from top, footer from bottom. null = no zone.
|
||||
headerDistanceCm: number | null;
|
||||
footerDistanceCm: number | null;
|
||||
@@ -342,9 +347,6 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
|
||||
}
|
||||
|
||||
const hf = resolver.masterPageHF();
|
||||
if (hf.hasVariants) {
|
||||
warnings.add('Per-page header/footer variants (first/even pages) are not supported — the default one was used');
|
||||
}
|
||||
|
||||
const geometry = resolver.pageGeometry();
|
||||
const edge = resolver.edgeDistancesCm();
|
||||
@@ -373,9 +375,12 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
|
||||
// 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;
|
||||
const headerEven = hf.headerLeft ? convertHfZone(hf.headerLeft, ctx) : null;
|
||||
const footerEven = hf.footerLeft ? convertHfZone(hf.footerLeft, ctx) : null;
|
||||
const differentOddEven = !!(hf.headerLeft || hf.footerLeft);
|
||||
// A first-page/even zone reserves the band even if its default counterpart is empty.
|
||||
const hasHeader = hf.header || headerFirst || headerEven;
|
||||
const hasFooter = hf.footer || footerFirst || footerEven;
|
||||
|
||||
return {
|
||||
content: { type: 'doc', content: blocks },
|
||||
@@ -387,6 +392,9 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
|
||||
headerFirst,
|
||||
footerFirst,
|
||||
differentFirstPage,
|
||||
headerEven,
|
||||
footerEven,
|
||||
differentOddEven,
|
||||
headerDistanceCm: hasHeader ? edge?.top ?? null : null,
|
||||
footerDistanceCm: hasFooter ? edge?.bottom ?? null : null,
|
||||
language,
|
||||
|
||||
@@ -366,9 +366,10 @@ export class StyleResolver {
|
||||
footer: Element | null;
|
||||
headerFirst: Element | null;
|
||||
footerFirst: Element | null;
|
||||
headerLeft: Element | null;
|
||||
footerLeft: Element | null;
|
||||
headerExtraCm: number;
|
||||
footerExtraCm: number;
|
||||
hasVariants: boolean;
|
||||
} {
|
||||
const mp = this.masterPageEl();
|
||||
const layout = this.pageLayoutEl();
|
||||
@@ -411,10 +412,11 @@ export class StyleResolver {
|
||||
footer: zone('footer'),
|
||||
headerFirst: firstZone('header-first'),
|
||||
footerFirst: firstZone('footer-first'),
|
||||
// Even-page variant (Word odd/even). ODF header-left = the left (even) page.
|
||||
headerLeft: firstZone('header-left'),
|
||||
footerLeft: firstZone('footer-left'),
|
||||
headerExtraCm: extraCm('header-style', 'margin-bottom'),
|
||||
footerExtraCm: extraCm('footer-style', 'margin-top'),
|
||||
// Only the left/right (even) variant stays unsupported; -first is handled below.
|
||||
hasVariants: ['header-left', 'footer-left'].some(l => zone(l) != null),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
// Header/footer content: one single-paragraph TipTap doc per zone (hfExtensions
|
||||
// schema). The 'default' variant repeats on every page; the 'first' variant (Word
|
||||
// "Different First Page" / ODF header-first) overrides page 1. null = empty zone.
|
||||
// schema). The 'default' variant repeats on every page (odd pages when odd/even is
|
||||
// on); 'first' (Word "Different First Page" / ODF header-first) overrides page 1;
|
||||
// 'even' (Word "Different Odd & Even Pages" / ODF header-left) overrides even pages.
|
||||
// Precedence: first (page 1) > even (even pages) > default. null = empty zone.
|
||||
|
||||
export type HfZone = 'header' | 'footer';
|
||||
export type HfVariant = 'default' | 'first';
|
||||
export type HfVariant = 'default' | 'first' | 'even';
|
||||
export type HfDoc = { type: 'doc'; content?: unknown[] } | null;
|
||||
|
||||
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' },
|
||||
header: { default: 'odf-editor-header', first: 'odf-editor-header-first', even: 'odf-editor-header-even' },
|
||||
footer: { default: 'odf-editor-footer', first: 'odf-editor-footer-first', even: 'odf-editor-footer-even' },
|
||||
};
|
||||
|
||||
// Whether page 1 uses its own header/footer (Word w:titlePg / ODF header-first).
|
||||
const DIFFERENT_FIRST_KEY = 'odf-editor-hf-different-first';
|
||||
// Whether even pages use their own header/footer (Word w:evenAndOddHeaders / ODF header-left).
|
||||
const DIFFERENT_ODD_EVEN_KEY = 'odf-editor-hf-odd-even';
|
||||
|
||||
export function loadDifferentFirstPage(): boolean {
|
||||
return localStorage.getItem(DIFFERENT_FIRST_KEY) === 'true';
|
||||
@@ -23,6 +27,15 @@ export function saveDifferentFirstPage(on: boolean): void {
|
||||
else localStorage.removeItem(DIFFERENT_FIRST_KEY);
|
||||
}
|
||||
|
||||
export function loadDifferentOddEven(): boolean {
|
||||
return localStorage.getItem(DIFFERENT_ODD_EVEN_KEY) === 'true';
|
||||
}
|
||||
|
||||
export function saveDifferentOddEven(on: boolean): void {
|
||||
if (on) localStorage.setItem(DIFFERENT_ODD_EVEN_KEY, 'true');
|
||||
else localStorage.removeItem(DIFFERENT_ODD_EVEN_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).
|
||||
|
||||
@@ -330,6 +330,26 @@ describe('DOCX different first page (w:titlePg)', () => {
|
||||
expect(Math.abs(fi[0].attrs.height - 48)).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('round-trips odd/even page variants (w:evenAndOddHeaders + even refs)', async () => {
|
||||
const withEven = {
|
||||
...hf,
|
||||
headerEven: { type: 'doc', content: [para('Even header')] },
|
||||
footerEven: { type: 'doc', content: [para('Even footer')] },
|
||||
differentOddEven: true,
|
||||
} as any;
|
||||
const bytes = await buildDocx(fixture, undefined, 'portrait', withEven, { language: 'de', country: 'DE' });
|
||||
const files = unzipSync(bytes);
|
||||
expect(strFromU8(files['word/settings.xml'])).toContain('evenAndOddHeaders');
|
||||
expect(strFromU8(files['word/document.xml'])).toMatch(/<w:headerReference[^>]*w:type="even"/);
|
||||
const res = importDocx(bytes);
|
||||
expect(res.differentOddEven).toBe(true);
|
||||
expect(walk(res.headerEven, 'text')[0].text).toBe('Even header');
|
||||
expect(walk(res.footerEven, 'text')[0].text).toBe('Even footer');
|
||||
// default + first still intact.
|
||||
expect(walk(res.header, 'text')[0].text).toBe('Default header');
|
||||
expect(walk(res.headerFirst, 'text')[0].text).toBe('Cover header');
|
||||
});
|
||||
|
||||
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']);
|
||||
|
||||
@@ -841,6 +841,32 @@ describe('Leg 3b: inline images in header/footer → buildOdt → importOdt', ()
|
||||
});
|
||||
});
|
||||
|
||||
describe('Leg 3c: odd/even page header/footer → buildOdt → importOdt', () => {
|
||||
it('round-trips the even-page variants and the flag alongside default + first', async () => {
|
||||
const header: N = { type: 'doc', content: [P(null, T('Default header'))] };
|
||||
const footer: N = { type: 'doc', content: [P(null, T('Default footer'))] };
|
||||
const headerEven: N = { type: 'doc', content: [P({ textAlign: 'right' }, T('Even header'))] };
|
||||
const footerEven: N = { type: 'doc', content: [P({ textAlign: 'center' }, T('Even footer'))] };
|
||||
const headerFirst: N = { type: 'doc', content: [P(null, T('First header'))] };
|
||||
const bytes = await buildOdt(fixture, margins, 'portrait',
|
||||
{ header, footer, headerEven, footerEven, differentOddEven: true, headerFirst, footerFirst: null, differentFirstPage: true, pageCount: 4 });
|
||||
check('odd/even: styles.xml has <style:header-left>', strFromU8(unzipSync(bytes)['styles.xml']).includes('<style:header-left>'));
|
||||
|
||||
const res = importOdt(bytes);
|
||||
check('odd/even: no warnings', res.warnings.length === 0, res.warnings);
|
||||
check('odd/even: flag round-trips', res.differentOddEven === true, res.differentOddEven);
|
||||
check('odd/even: even header round-trips', firstDiff(normalize(headerEven), normalize(res.headerEven)) === null, res.headerEven);
|
||||
check('odd/even: even footer round-trips', firstDiff(normalize(footerEven), normalize(res.footerEven)) === null, res.footerEven);
|
||||
check('odd/even: default + first still round-trip',
|
||||
firstDiff(normalize(header), normalize(res.header)) === null && firstDiff(normalize(headerFirst), normalize(res.headerFirst)) === null, res.header);
|
||||
|
||||
const hfSchema = getSchema(hfExtensions());
|
||||
let ok = true;
|
||||
for (const z of [res.headerEven, res.footerEven]) { if (!z) continue; try { PMNode.fromJSON(hfSchema, z).check(); } catch { ok = false; } }
|
||||
check('odd/even: even zones valid in hf schema', ok);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Leg 4: foreign header/footer → importOdt', () => {
|
||||
it('parses page-number/count fields, reconstructs body margins, imports the first-page variant', () => {
|
||||
const fStyles = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
Reference in new issue
Block a user