feat: every section keeps its own header and footer

A document had one set for all of it, taken from whichever section
declared one last — so a fixture lost its letterhead entirely
and page 1 got the empty header its final section asked for.

A body block now carries `sectionBreak`, ordinal rather than indexed so
editing cannot desync it. Import resolves Word's "Link to Previous" per
reference type; export writes a set per sectPr, and on the ODF side each
section past the first gets its own master page, which the block switches
to. The layer picks a page's set from where pageBreaks reports the
sections starting, and each section's zone reaches ride
`--pb-section-reach` so a later section's letterhead makes room for
itself: page 10 of a fixture was 24.9mm out, now 1.1mm.

Editing still reaches section 1 only; the rest render read-only.
This commit is contained in:
Steffen Becker committed 2026-08-08 14:02:36 +00:00
1 parent 60980fa4d6
commit 6accc7268f
14 files changed
+567 -109

No files matched your search

+19 -1
View File
@@ -26,7 +26,7 @@
import { loadPageFormat, savePageFormat, type PageFormat } from './lib/storage/pageFormat';
import { setStyleSheet, styleSheet } from './lib/styles/sheet.svelte';
import { builtinStyleSheet, type StyleFamily } from './lib/styles/styleSheet';
import { loadHfDoc, saveHfDoc, loadHfDistances, saveHfDistances, loadDifferentFirstPage, saveDifferentFirstPage, loadDifferentOddEven, saveDifferentOddEven, 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, loadExtraHfSections, saveExtraHfSections, type HfDoc, type HfZone, type HfDistances, type HfSet } 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';
@@ -69,6 +69,8 @@
let footerEvenDoc: HfDoc = $state(loadHfDoc('footer', 'even'));
let differentOddEven: boolean = $state(loadDifferentOddEven());
let hfDistances: HfDistances = $state(loadHfDistances());
// Sections past the first: imported and exported, not editable (see HeaderFooterLayer).
let extraHfSections: HfSet[] = $state(loadExtraHfSections());
let hfEditor: Editor | null = $state(null);
let hfActive: HfZone | null = $state(null);
let hfTick: number = $state(0);
@@ -214,6 +216,9 @@
$effect(() => {
saveHfDistances(hfDistances);
});
$effect(() => {
saveExtraHfSections(extraHfSections);
});
$effect(() => {
saveDocName(documentName);
@@ -377,6 +382,15 @@
let docxBusy = $state(false);
let exportMenuOpen = $state(false);
// The editable zones as one section — section 1 of the export.
function hfSetOfState(): HfSet {
return {
header: headerDoc, footer: footerDoc,
headerFirst: headerFirstDoc, footerFirst: footerFirstDoc, differentFirstPage,
headerEven: headerEvenDoc, footerEven: footerEvenDoc, differentOddEven,
};
}
// odf-kit export options for the current header/footer + page geometry.
function hfOpts() {
return {
@@ -384,6 +398,7 @@
headerFirst: headerFirstDoc, footerFirst: footerFirstDoc, differentFirstPage,
headerEven: headerEvenDoc, footerEven: footerEvenDoc, differentOddEven,
pageCount: numPages,
sections: [hfSetOfState(), ...extraHfSections],
headerDistanceCm: hfDistances.header, footerDistanceCm: hfDistances.footer,
};
}
@@ -426,6 +441,7 @@
pageOrientation = 'portrait';
pageFormat = 'A4';
hfDistances = { ...DEFAULT_HF_DISTANCES };
extraHfSections = [];
documentName = '';
fileHandle = null;
// Styles live in the document, so a new one starts from the built-ins
@@ -497,6 +513,7 @@
headerEvenDoc = result.headerEven;
footerEvenDoc = result.footerEven;
differentOddEven = result.differentOddEven;
extraHfSections = (result.hfSections ?? []).slice(1);
hfDistances = {
header: result.headerDistanceCm ?? DEFAULT_HF_DISTANCES.header,
footer: result.footerDistanceCm ?? DEFAULT_HF_DISTANCES.footer,
@@ -1018,6 +1035,7 @@
bind:hfActive
bind:hfTick
{hfDistances}
{extraHfSections}
{zoom}
onZoom={setZoom}
{showFormattingMarks}
+13
View File
@@ -52,3 +52,16 @@ One header and one footer (`HfDoc` = a single-paragraph TipTap doc per zone, per
## Debug tooling (dev only)
In dev builds a **Debug** button (`App.svelte`) downloads a JSON snapshot combining `getPageBreakDebug(view)` (leaves, placements, rendered spacers, table-break bands, live overlay geometry) and `getColorDebug(editor)` (selection marks, text runs, document colors, DOM spans) — used to diagnose pagination and color round-trip issues.
## Per-section header/footer
A body block carrying `sectionBreak` (pageBreak.ts) opens a section; `HeaderFooterLayer`
picks that section's `HfSet` per page from `sectionStartPages`, which `pageBreaks.ts`
reports on `pm-pagecount`. Section 1 is the app's own editable state, the rest ride along
from the file (`extraHfSections`, persisted whole) and are read-only.
Word's "different first page" is per section, so a later section's first page shows its
own variant. `Editor.svelte` publishes each section's zone reaches as `--pb-section-reach`
("topFirst|topRest|bottomFirst|bottomRest" per section, comma-separated) and pageBreaks
resolves the content area per page from it — without it a tall letterhead on a later
section's first page would sit on top of the body (measured: 24.9mm).
+25 -2
View File
@@ -21,7 +21,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, hfIsEmpty, type HfDoc, type HfZone, type HfDistances } from '../storage/headerFooter';
import { DEFAULT_HF_DISTANCES, hfIsEmpty, type HfDoc, type HfZone, type HfDistances, type HfSet } from '../storage/headerFooter';
import { FORCE_PAGE_RECALC, type TableBreakBand } from '../editor/extensions/pageBreaks';
import { recordTransaction, resetHistoryLog } from '../utils/historyLog.svelte';
import { wheelZoomFactor } from '../utils/zoom';
@@ -41,6 +41,7 @@
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),
extraHfSections = [],
}: {
editor: Editor | null; tick: number; currentPage: number; numPages: number; zoom: number;
onZoom?: (zoom: number) => void;
@@ -49,8 +50,13 @@
headerFirstDoc?: HfDoc; footerFirstDoc?: HfDoc; differentFirstPage?: boolean;
headerEvenDoc?: HfDoc; footerEvenDoc?: HfDoc; differentOddEven?: boolean;
hfEditor?: Editor | null; hfActive?: HfZone | null; hfTick?: number;
extraHfSections?: HfSet[];
} = $props();
// Page each section after the first starts on (pageBreaks reports it); the layer
// turns it into a per-page set.
let sectionStartPages = $state<number[]>([]);
// Apply the page margins + orientation to the :root CSS vars (visual padding,
// page dimensions, and pagination all read these). DOM-only, safe in effects.
$effect(() => {
@@ -90,12 +96,25 @@
let effTopFirst = $derived(Math.max(mTopPx, hfReachPx((differentFirstPage ? headerFirstDoc : headerDoc) ?? null, headerDistPx)));
let effBottomRest = $derived(Math.max(mBottomPx, hfReachPx(footerDoc ?? null, footerDistPx), evenBottomReach));
let effBottomFirst = $derived(Math.max(mBottomPx, hfReachPx((differentFirstPage ? footerFirstDoc : footerDoc) ?? null, footerDistPx)));
// Per-section reaches for pageBreaks: "topFirst|topRest|bottomFirst|bottomRest" in px,
// one group per section, comma-separated. Section 1 repeats the four vars below.
let sectionReach = $derived([
[effTopFirst, effTopRest, effBottomFirst, effBottomRest],
...extraHfSections.map((s) => [
Math.max(mTopPx, hfReachPx((s.differentFirstPage ? s.headerFirst : s.header) ?? null, headerDistPx)),
Math.max(mTopPx, hfReachPx(s.header ?? null, headerDistPx), s.differentOddEven ? hfReachPx(s.headerEven ?? null, headerDistPx) : 0),
Math.max(mBottomPx, hfReachPx((s.differentFirstPage ? s.footerFirst : s.footer) ?? null, footerDistPx)),
Math.max(mBottomPx, hfReachPx(s.footer ?? null, footerDistPx), s.differentOddEven ? hfReachPx(s.footerEven ?? null, footerDistPx) : 0),
]),
].map((g) => g.map((n) => Math.round(n)).join('|')).join(','));
$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`);
s.setProperty('--pb-section-reach', sectionReach);
});
// Nudge the pageBreaks plugin to recompute with the new content area. The dispatch
@@ -109,6 +128,7 @@
void pageFormat;
// …and the header/footer-driven effective margins, so growing a zone re-paginates.
void (effTopRest + effTopFirst + effBottomRest + effBottomFirst);
void sectionReach;
const ed = editor;
if (!ed) return;
cancelAnimationFrame(marginRecalcRaf);
@@ -591,10 +611,11 @@
}
function onPageCount(e: Event) {
const detail = (e as CustomEvent<{ numPages: number; docHeight?: number; tableBreakBands?: TableBreakBand[] }>).detail;
const detail = (e as CustomEvent<{ numPages: number; docHeight?: number; tableBreakBands?: TableBreakBand[]; sectionStartPages?: number[] }>).detail;
numPages = detail.numPages;
if (typeof detail.docHeight === 'number') docHeightDoc = detail.docHeight;
tableBandsDoc = detail.tableBreakBands ?? [];
sectionStartPages = detail.sectionStartPages ?? [];
// The document height changed → resize the scaled scroll footprint.
recomputeScaledSize();
updateCurrentPage();
@@ -829,6 +850,8 @@
{orientation}
{pageFormat}
{hfDistances}
{extraHfSections}
{sectionStartPages}
/>
</div>
</div>
+56 -18
View File
@@ -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 HfVariant, type HfDistances } from '../storage/headerFooter';
import { hfIsEmpty, DEFAULT_HF_DISTANCES, type HfDoc, type HfZone, type HfVariant, type HfDistances, type HfSet } 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';
@@ -25,6 +25,8 @@
hfEditor = $bindable(),
hfActive = $bindable(),
hfTick = $bindable(),
extraHfSections = [],
sectionStartPages = [],
}: {
headerDoc: HfDoc;
footerDoc: HfDoc;
@@ -43,6 +45,8 @@
hfEditor: Editor | null;
hfActive: HfZone | null;
hfTick: number;
extraHfSections?: HfSet[];
sectionStartPages?: number[];
} = $props();
const PAGE_GAP = 20;
@@ -109,25 +113,52 @@
return '';
}
}
let headerHtml = $derived(staticHtml(headerDoc));
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));
// Section 1 is the app's own editable state; the rest ride along from the file.
let sets = $derived<HfSet[]>([
{
header: headerDoc, footer: footerDoc,
headerFirst: headerFirstDoc, footerFirst: footerFirstDoc, differentFirstPage,
headerEven: headerEvenDoc, footerEven: footerEvenDoc, differentOddEven,
},
...extraHfSections,
]);
// Rendered once per set, not per page: a 48-page document would otherwise run
// generateHTML for every page of every zone.
let setHtml = $derived(sets.map((s) => ({
header: staticHtml(s.header), footer: staticHtml(s.footer),
headerFirst: staticHtml(s.headerFirst), footerFirst: staticHtml(s.footerFirst),
headerEven: staticHtml(s.headerEven), footerEven: staticHtml(s.footerEven),
})));
// Which variant a page shows, in precedence order: 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';
// Which section a page belongs to: the count of section starts at or before it,
// clamped to what the document actually carries.
function sectionOf(page: number): number {
let i = 0;
for (const start of sectionStartPages) {
if (page >= start) i++;
else break;
}
return Math.min(i, sets.length - 1);
}
function sectionFirstPage(index: number): number {
return index === 0 ? 1 : sectionStartPages[index - 1] ?? 1;
}
// Which variant a page shows, in precedence order: a section's own first page →
// first (if on), other even pages → even (if on), everything else → default/odd.
// Each variant, when on, always shows its own (possibly empty) zone.
function variantFor(page: number, index = sectionOf(page)): HfVariant {
const s = sets[index] ?? sets[0];
if (s.differentFirstPage && page === sectionFirstPage(index)) return 'first';
if (s.differentOddEven && page % 2 === 0) return 'even';
return 'default';
}
function zoneHtml(zone: HfZone, page: number): string {
const v = variantFor(page);
if (zone === 'header') return v === 'first' ? headerFirstHtml : v === 'even' ? headerEvenHtml : headerHtml;
return v === 'first' ? footerFirstHtml : v === 'even' ? footerEvenHtml : footerHtml;
const index = sectionOf(page);
const v = variantFor(page, index);
const h = setHtml[index] ?? setHtml[0];
if (zone === 'header') return v === 'first' ? h.headerFirst : v === 'even' ? h.headerEven : h.header;
return v === 'first' ? h.footerFirst : v === 'even' ? h.footerEven : h.footer;
}
// Replace the placeholder text in every page-field span with the real value:
@@ -256,12 +287,14 @@
{#each ['header', 'footer'] as const as zone}
{#if !(hfActive === zone && editingPage === p)}
{@const html = zoneHtml(zone, p)}
{@const locked = sectionOf(p) > 0}
<div
class="hf-zone hf-{zone}"
class:hf-empty={!html}
class:hf-empty={!html && !locked}
class:hf-locked={locked}
data-hf-label={zone === 'header' ? t().hf.addHeaderHint : t().hf.addFooterHint}
style={boxStyle(zoneBox(zone, p))}
ondblclick={() => startEdit(zone, p)}
ondblclick={() => { if (!locked) startEdit(zone, p); }}
role="button"
tabindex="-1"
use:patchFields={[p, numPages, html]}
@@ -322,6 +355,11 @@
justify-content: flex-end;
}
/* A section past the first carries the file's zones and has no editing UI yet. */
.hf-locked {
cursor: default;
}
/* Empty zone: invisible until hovered, then show a faint double-click hint. */
.hf-empty::before {
content: attr(data-hf-label);
+15 -1
View File
@@ -6,7 +6,9 @@ import { DEFAULT_SHORTCUTS } from '../shortcuts';
// widow-orphan minimum (w:widowControl, fo:widows/fo:orphans); keepNext keeps the block
// on the page its successor starts on (w:keepNext, fo:keep-with-next) — headings do
// that anyway in both, so the attr only marks the other blocks; keepLines holds all of
// a block's lines on one page (w:keepLines, fo:keep-together). null = default.
// a block's lines on one page (w:keepLines, fo:keep-together). null = default;
// sectionBreak marks the first block of a new section, which carries its own
// header/footer set (storage/headerFooter.ts `HfSet`).
declare module '@tiptap/core' {
interface Commands<ReturnType> {
@@ -69,6 +71,18 @@ export const PageBreak = Extension.create({
return { 'data-keep-lines': 'true' };
},
},
// First block of a new section (w:sectPr, ODF style:master-page-name): what
// gives it its own header/footer. Ordinal, so editing can't desync an index.
sectionBreak: {
default: null,
keepOnSplit: false,
parseHTML: (element: HTMLElement) =>
element.getAttribute('data-section-break') === 'true' ? true : null,
renderHTML: (attributes: Record<string, unknown>) => {
if (attributes.sectionBreak !== true) return {};
return { 'data-section-break': 'true' };
},
},
},
},
];
+40 -7
View File
@@ -195,6 +195,9 @@ type Leaf = {
// Keep with next (DOCX w:keepNext, ODF fo:keep-with-next; pageBreak.ts): the leaf
// moves to the next page when its successor's first line no longer fits below it.
keepNext?: boolean;
// First block of a new section (pageBreak.ts `sectionBreak`): the page it lands on
// is where that section's header/footer starts repeating.
sectionStart?: boolean;
// Flow brackets for a too-tall row's cells (each cell is an independent flow from
// the row top, so placement resets cumulative shift per cell): cellStart = a cell's
// first leaf, rowStart = the row's first cell, rowEnd = last leaf of the last cell.
@@ -741,6 +744,7 @@ export const PageBreaks = Extension.create({
// so their styles carry it and the attr only marks the other blocks.
keepNext: !inTableCell
&& (child.dataset?.keepNext === 'true' || /^H[1-5]$/.test(child.tagName)),
sectionStart: !inTableCell && child.dataset?.sectionBreak === 'true',
});
cumulativeSpacerHeight += intraSpacerHeight;
continue;
@@ -797,6 +801,13 @@ export const PageBreaks = Extension.create({
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;
// Each section's own reaches (Editor.svelte), so a later section's tall header
// clears its own pages. Missing/short → the document-wide pair above.
const reach = csRoot.getPropertyValue('--pb-section-reach').split(',')
.map((g) => g.split('|').map(Number))
.filter((g) => g.length === 4 && g.every(Number.isFinite));
const reachAt = (i: number) => reach[Math.min(i, reach.length - 1)]
?? [effTopFirst, vm.top, effBottomFirst, vm.bottom];
const scale = getScaleFactor();
const leaves = collectLeaves(CONTENT_HEIGHT, scale);
@@ -810,6 +821,11 @@ export const PageBreaks = Extension.create({
// Lowest rendered content bottom across all leaves (incl. parallel cells),
// used for the page count — a single last-leaf reading misses taller cells.
let maxEffectiveBottom = 0;
// Page each section after the first begins on, in body order — the layer picks
// a page's header/footer set from it.
const sectionStartPages: number[] = [];
let sectionIndex = 0;
let sectionFirstPage = 1;
const placements: {
docPos: number;
height: number;
@@ -841,11 +857,21 @@ export const PageBreaks = Extension.create({
const effectiveTop = leaf.naturalTop + cumulativeShift;
const effectiveBottom = effectiveTop + leaf.naturalHeight;
const page = getPageForY(effectiveTop, 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 section's first page uses its "first" reaches, every other page its
// "rest" ones — page 1 is section 1's first page.
if (leaf.sectionStart) {
// Where the section really begins: a forced break moves its first block to
// the next page, and that page is the one its "first" zones belong to.
const prevStart = (page - 1) * CYCLE_PX + reachAt(sectionIndex)[1];
const pushed = !!leaf.forceBreakBefore && i > 0 && effectiveTop > prevStart + 0.5;
sectionIndex++;
sectionFirstPage = pushed ? page + 1 : page;
}
const [topFirst, topRest, bottomFirst, bottomRest] = reachAt(sectionIndex);
const onFirst = page === sectionFirstPage;
const pageTop = (page - 1) * CYCLE_PX;
const contentStart = pageTop + (onFirst ? topFirst : topRest);
const contentEnd = pageTop + vm.pageHeight - (onFirst ? bottomFirst : bottomRest);
// A leaf may need several spacers: a splittable block taller than one
// page crosses multiple boundaries, one break each.
@@ -913,7 +939,10 @@ export const PageBreaks = Extension.create({
const forced = !!leaf.forceBreakBefore && i > 0 && effectiveTop > contentStart + 0.5;
if (forced) {
const target = pageContentStart(page + 1, vm.top, CYCLE_PX);
// The next page's own content start: a section beginning here brings its
// first-page header with it.
const nextTop = page + 1 === sectionFirstPage ? topFirst : topRest;
const target = pageContentStart(page + 1, nextTop, CYCLE_PX);
const { docPos, row } = leafSpacer(leaf);
breaks.push({
height: target - effectiveTop,
@@ -1104,6 +1133,10 @@ export const PageBreaks = Extension.create({
});
}
// Read after this leaf's own spacer, so the page is the one it really lands on.
if (leaf.sectionStart) {
sectionStartPages.push(getPageForY(leaf.naturalTop + cumulativeShift, CYCLE_PX));
}
// Track the lowest rendered content bottom (cumulativeShift now includes
// this leaf's own spacer, so a pushed/split leaf counts at its real spot).
maxEffectiveBottom = Math.max(
@@ -1189,7 +1222,7 @@ export const PageBreaks = Extension.create({
// docHeight (document px) lets Editor.svelte size the scaled scroll footprint.
dom.dispatchEvent(new CustomEvent('pm-pagecount', {
bubbles: true,
detail: { numPages, docHeight: targetHeight, tableBreakBands },
detail: { numPages, docHeight: targetHeight, tableBreakBands, sectionStartPages },
}));
lastSnapshot = {
+54 -30
View File
@@ -17,7 +17,7 @@ import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
import { HF_DISTANCE_CM, hfIsEmpty } from '../storage/headerFooter';
import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc, type HfSet } from '../storage/headerFooter';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { parseBorderAttr, type BorderSide } from '../editor/extensions/tableCellBorders';
import { parseCellPadding, DEFAULT_CELL_PADDING } from '../editor/extensions/tableCellPadding';
@@ -899,30 +899,46 @@ function blocksToDocx(content: TiptapNode[], num: Numbering, contentWidthCm: num
// content. DOCX has no block-level column container — a mid-document multi-column
// region is its own section, delimited by continuous section breaks.
type BodyGroup = {
// Which header/footer set the group belongs to (index into HfExport.sections).
section: number;
columns: { count: number; gapCm: number } | null;
children: (Paragraph | Table | TableOfContents)[];
};
// A block carrying `sectionBreak` opens the next section — on import it may already sit
// inside a columns node, so its first child counts too.
function startsSection(node: TiptapNode): boolean {
if (node.attrs?.sectionBreak === true) return true;
return node.type === 'columns' && node.content?.[0]?.attrs?.sectionBreak === true;
}
function bodyGroups(content: TiptapNode[], num: Numbering, contentWidthCm: number, textBoxes: TextBoxDocx[]): BodyGroup[] {
const groups: BodyGroup[] = [];
let section = 0;
let plain: TiptapNode[] = [];
// Adjacent equal-attr fragments (a columnsFlow page-split chain) = one section;
// their blocks are collected raw so page-split paragraphs merge before serializing.
let cols: { count: number; gapCm: number; blocks: TiptapNode[] } | null = null;
const flushPlain = () => {
if (!plain.length) return;
groups.push({ columns: null, children: blocksToDocx(plain, num, contentWidthCm, textBoxes) });
groups.push({ section, columns: null, children: blocksToDocx(plain, num, contentWidthCm, textBoxes) });
plain = [];
};
const flushCols = () => {
if (!cols) return;
groups.push({
section,
columns: { count: cols.count, gapCm: cols.gapCm },
children: blocksToDocx(mergeJoinedParagraphsJson(cols.blocks), num, contentWidthCm, textBoxes),
});
cols = null;
};
for (const node of content) {
if (startsSection(node)) {
flushCols();
flushPlain();
section++;
}
if (node.type === 'columns') {
const a = node.attrs ?? {};
const count = typeof a.count === 'number' ? Math.min(3, Math.max(2, Math.round(a.count))) : 2;
@@ -941,7 +957,7 @@ function bodyGroups(content: TiptapNode[], num: Numbering, contentWidthCm: numbe
}
flushCols();
flushPlain();
if (!groups.length) groups.push({ columns: null, children: [] });
if (!groups.length) groups.push({ section: 0, columns: null, children: [] });
return groups;
}
@@ -1089,16 +1105,20 @@ export async function buildDocx(
// on open so Word/LibreOffice populate + hyperlink it (standard for TOC fields).
const hasToc = (docJson.content ?? []).some(n => n.type === 'tableOfContents');
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;
// 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;
// One set per section; a document with no section breaks has exactly one. The flat
// fields describe section 1, so they are the fallback when `sections` is absent.
const hfSets: HfSet[] = hf?.sections?.length ? hf.sections : [{
header: hf?.header ?? null, footer: hf?.footer ?? null,
headerFirst: hf?.headerFirst ?? null, footerFirst: hf?.footerFirst ?? null,
differentFirstPage: !!hf?.differentFirstPage,
headerEven: hf?.headerEven ?? null, footerEven: hf?.footerEven ?? null,
differentOddEven: !!hf?.differentOddEven,
}];
const setAt = (i: number) => hfSets[Math.min(i, hfSets.length - 1)];
const para = (d: HfDoc) => (hfIsEmpty(d) ? null : (d!.content![0] as TiptapNode));
// Different odd & even pages is a document setting (w:evenAndOddHeaders), not a
// section one, so any section asking for it turns it on.
const differentOddEven = hfSets.some((s) => s.differentOddEven);
// 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);
@@ -1115,23 +1135,27 @@ export async function buildDocx(
header: cmToTwip(headerDist), footer: cmToTwip(footerDist),
},
};
// 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 && !evenHeaderPara) return undefined;
// Fresh instances per section (Word's per-sectPr references, i.e. no "Link to
// Previous"). A first-page variant rides `first:` and is activated by titlePage below.
const mkHeaders = (i: number) => {
const s = setAt(i);
const d = para(s.header), f = s.differentFirstPage ? para(s.headerFirst) : null, e = s.differentOddEven ? para(s.headerEven) : null;
if (!d && !f && !e) 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)] });
if (d) h.default = new Header({ children: [paragraphToDocx(d)] });
if (f) h.first = new Header({ children: [paragraphToDocx(f)] });
if (e) h.even = new Header({ children: [paragraphToDocx(e)] });
return h;
};
const mkFooters = () => {
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 mkFooters = (i: number) => {
const s = setAt(i);
const d = para(s.footer), f = s.differentFirstPage ? para(s.footerFirst) : null, e = s.differentOddEven ? para(s.footerEven) : null;
if (!d && !f && !e) return undefined;
const fo: { default?: Footer; first?: Footer; even?: Footer } = {};
if (d) fo.default = new Footer({ children: [paragraphToDocx(d)] });
if (f) fo.first = new Footer({ children: [paragraphToDocx(f)] });
if (e) fo.even = new Footer({ children: [paragraphToDocx(e)] });
return fo;
};
const doc = new Document({
@@ -1146,14 +1170,14 @@ export async function buildDocx(
sections: groups.map((g, i) => ({
properties: {
page: pageProps,
...(differentFirstPage ? { titlePage: true } : {}),
...(setAt(g.section).differentFirstPage ? { titlePage: true } : {}),
...(i > 0 ? { type: SectionType.CONTINUOUS } : {}),
...(g.columns
? { column: { count: g.columns.count, space: cmToTwip(g.columns.gapCm), equalWidth: true } }
: {}),
},
headers: mkHeaders(),
footers: mkFooters(),
headers: mkHeaders(g.section),
footers: mkFooters(g.section),
children: g.children.length ? g.children : [new Paragraph({})],
})),
});
+118 -6
View File
@@ -3,7 +3,7 @@ import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate';
import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc } from '../storage/headerFooter';
import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc, type HfSet } from '../storage/headerFooter';
import { builtinStyleSheet, DEFAULT_STYLE, resolveStyle, type StyleSheet, type TextProps } from '../styles/styleSheet';
import {
TABLE_REGIONS, parseTableLook, regionText, type TableLook, type TableRegion,
@@ -130,6 +130,11 @@ const CST = '\uE00E';
// automatic text style. U+E00F.
const TEF = '\uE00F';
// Sentinel wrapping the index of the section a block opens (SEC{i}SEC), emitted as the
// block's first run; applySectionMasterPages points it at that section's master page,
// which is what carries the section's own header/footer. U+E010.
const SEC = '\uE010';
const EXT_BY_MIME: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
@@ -325,6 +330,24 @@ function replacePageBreaks(doc: TiptapNode): TiptapNode {
};
}
// Mark the first block of each section after the first, so applySectionMasterPages can
// point it at that section's master page. Runs after the column hoist, so a section
// starting inside one is top-level by now; the PGB marker keeps its place at the front.
function replaceSectionBreaks(doc: TiptapNode): TiptapNode {
if (!doc.content?.length) return doc;
let index = 0;
return {
...doc,
content: doc.content.map(child => {
if (!(child.type === 'paragraph' || child.type === 'heading') || child.attrs?.sectionBreak !== true) return child;
const inner = child.content ?? [];
const at = inner[0]?.type === 'text' && inner[0].text === PGB ? 1 : 0;
const mark: TiptapNode = { type: 'text', text: `${SEC}${++index}${SEC}` };
return { ...child, content: [...inner.slice(0, at), mark, ...inner.slice(at)] };
}),
};
}
// One embedded picture, collected by replaceImages and emitted by applyImages.
// bytes is ArrayBuffer-backed to match fflate's zip entry map. rotationDeg is CW;
// wrap floats the frame at its anchor paragraph (left/right/top-bottom).
@@ -2973,6 +2996,9 @@ export type HfExport = {
headerEven?: HfDoc;
footerEven?: HfDoc;
differentOddEven?: boolean;
// One set per section, in body order; [0] repeats the fields above. Absent = the
// document has a single section.
sections?: HfSet[];
pageCount: number;
headerDistanceCm?: number;
footerDistanceCm?: number;
@@ -2989,7 +3015,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const textBoxes: TextBoxExport[] = [];
const columns: ColumnsExport[] = [];
const dateFields: DateTimeFieldExport[] = [];
const sentinels = replaceDateTimeFields(replaceImages(replaceTabs(replaceHardBreaks(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns))), images), dateFields);
const sentinels = replaceDateTimeFields(replaceImages(replaceTabs(replaceHardBreaks(replaceSectionBreaks(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns)))), images), dateFields);
const raw = markTextEffects(bakeListCharStyles(sentinels, styles));
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;
@@ -3196,7 +3222,10 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const withNamedStyles = applyCharacterStyles(applyTextEffects(applyParagraphStyles(withParaBoxes)));
const usedTables = new Set(tableStyleNames.filter((t): t is TableStyleRef => !!t).map(t => t.name));
const withStyles = rewriteStylesXml(withNamedStyles, language ?? null, pageFormat, orientation, styles, usedStyleNames(docJson, styles), usedTables);
return applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist, firstHeaderPara, firstFooterPara, hf?.pageCount ?? 1, hfImages, evenHeaderPara, evenFooterPara);
const withHf = applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist, firstHeaderPara, firstFooterPara, hf?.pageCount ?? 1, hfImages, evenHeaderPara, evenFooterPara);
// Sections past the first get their own master page, which is where ODF keeps a
// section's header/footer; the SEC-marked block points at it.
return applySectionMasterPages(withHf, hf?.sections ?? [], hf?.pageCount ?? 1);
}
function hfAlign(para: TiptapNode): AlignValue | null {
@@ -3326,10 +3355,12 @@ function ensureDrawNamespaces(styles: string): 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 {
// suffix null = the section's own <style:header>/<style:footer>; 'first'/'left' are the
// per-page variants ODF hangs beside it.
function hfVariantZoneXml(kind: 'header' | 'footer', suffix: 'first' | 'left' | null, para: TiptapNode, pageCount: number, mint: (styleXml: string) => void, prefix = ''): 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()}`;
const pfx = `${prefix}HF${suffix === 'first' ? 'F' : suffix === 'left' ? 'L' : 'D'}${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);
@@ -3354,7 +3385,88 @@ function hfVariantZoneXml(kind: 'header' | 'footer', suffix: 'first' | 'left', p
paraStyle = `${pfx}P`;
mint(`<style:style style:name="${paraStyle}" style:family="paragraph" style:parent-style-name="${parent}"><style:paragraph-properties ${props.join(' ')}/></style:style>`);
}
return `<style:${kind}-${suffix}><text:p text:style-name="${paraStyle}">${inner}</text:p></style:${kind}-${suffix}>`;
const tag = suffix ? `${kind}-${suffix}` : kind;
return `<style:${tag}><text:p text:style-name="${paraStyle}">${inner}</text:p></style:${tag}>`;
}
// A section's own master page: the Standard one cloned under its own name, carrying that
// section's zones. ODF has no per-section header/footer other than this.
function masterPageXml(name: string, layoutName: string, set: HfSet, pageCount: number, mint: (styleXml: string) => void, pfx: string): string {
const zone = (kind: 'header' | 'footer', suffix: 'first' | 'left' | null, doc: HfDoc): string => {
if (hfIsEmpty(doc)) return '';
return hfVariantZoneXml(kind, suffix, doc!.content![0] as TiptapNode, pageCount, mint, pfx);
};
// An empty default beside a variant blanks its side, as the editor renders it.
const need = (a: HfDoc, b: HfDoc) => (hfIsEmpty(a) && !hfIsEmpty(b) ? { type: 'doc', content: [{ type: 'paragraph', content: [] }] } as HfDoc : a);
const hFirst = set.differentFirstPage ? set.headerFirst : null;
const fFirst = set.differentFirstPage ? set.footerFirst : null;
const hEven = set.differentOddEven ? set.headerEven : null;
const fEven = set.differentOddEven ? set.footerEven : null;
const body = zone('header', null, need(need(set.header, hFirst), hEven))
+ zone('header', 'first', hFirst) + zone('header', 'left', hEven)
+ zone('footer', null, need(need(set.footer, fFirst), fEven))
+ zone('footer', 'first', fFirst) + zone('footer', 'left', fEven);
return `<style:master-page style:name="${name}" style:page-layout-name="${layoutName}">${body}</style:master-page>`;
}
// Point each SEC-marked block at its section's master page (ODF's only per-section
// header/footer), minting the master pages beside the Standard one odf-kit wrote.
function applySectionMasterPages(odtBytes: Uint8Array, sets: HfSet[], pageCount: number): Uint8Array {
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];
const stylesBytes = files['styles.xml'];
if (!contentBytes || !stylesBytes) return odtBytes;
let content = strFromU8(contentBytes);
if (!content.includes(SEC)) return odtBytes;
let styles = strFromU8(stylesBytes);
const minted: string[] = [];
const used = new Set<number>();
let counter = 0;
content = content.replace(
new RegExp(`<text:(p|h)\\b([^>]*)>${SEC}(\\d+)${SEC}`, 'g'),
(_m, tag: string, attrs: string, idx: string) => {
const index = Number(idx);
used.add(index);
const sm = /text:style-name="([^"]*)"/.exec(attrs);
const source = sm?.[1] ?? '';
const def = source ? findAutoStyle(content, source) : null;
const name = `MP${++counter}`;
const master = ` style:master-page-name="Section${index + 1}"`;
minted.push(def
? def.replace(/style:name="[^"]*"/, `style:name="${name}"`).replace(/(<style:style\b[^>]*?)(\/?>)/, `$1${master}$2`)
: `<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="${source || 'Standard'}"${master}/>`);
const rest = attrs.replace(/\s*text:style-name="[^"]*"/, '');
return `<text:${tag}${rest} text:style-name="${name}">`;
},
);
if (minted.length) {
content = content.includes('</office:automatic-styles>')
? content.replace('</office:automatic-styles>', `${minted.join('')}</office:automatic-styles>`)
: content.replace('<office:automatic-styles/>', `<office:automatic-styles>${minted.join('')}</office:automatic-styles>`);
}
// Reuse the page layout odf-kit gave the Standard master page: the geometry is
// document-wide, only the zones differ per section.
const layout = /<style:master-page\b[^>]*style:page-layout-name="([^"]*)"/.exec(styles)?.[1] ?? 'pm1';
const hfStyles: string[] = [];
const pages: string[] = [];
for (const index of [...used].sort((a, b) => a - b)) {
const set = sets[index];
if (!set) continue;
pages.push(masterPageXml(`Section${index + 1}`, layout, set, pageCount, (x) => hfStyles.push(x), `MS${index}`));
}
if (pages.length) styles = styles.replace('</office:master-styles>', `${pages.join('')}</office:master-styles>`);
if (hfStyles.length) {
const defs = hfStyles.join('');
styles = styles.includes('</office:automatic-styles>')
? styles.replace('</office:automatic-styles>', `${defs}</office:automatic-styles>`)
: styles.replace('<office:automatic-styles/>', `<office:automatic-styles>${defs}</office:automatic-styles>`);
}
files['content.xml'] = strToU8(content);
files['styles.xml'] = strToU8(styles);
return rezipOdt(files);
}
// A text run's ODF <style:text-properties> attribute string from TipTap marks (mirrors
+53 -33
View File
@@ -16,7 +16,7 @@ import { PX_PER_CM, cmToPx, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { formatFromCm, type PageFormat } from '../storage/pageFormat';
import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage';
import type { HfDoc } from '../storage/headerFooter';
import { EMPTY_HF_SET, type HfDoc, type HfSet } from '../storage/headerFooter';
import { applyUniformRunFont, type OdtImportResult } from './odt';
import { deobfuscateOdttf, type EmbeddedFont } from '../fonts/embeddedFonts';
import { cellPaddingAttr, type CellPadding } from '../editor/extensions/tableCellPadding';
@@ -145,10 +145,12 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
const inner = convertBlocks(g.els, ctx, 'body');
// A section's own w:type says how it begins: a page-starting break (nextPage/odd/even,
// or the default) puts its first block on a new page; continuous/nextColumn flow on.
if (gi > 0 && inner.length && sectionStartsNewPage(g.sectPr ?? finalSectPr)) {
if (gi > 0 && inner.length) {
const first = inner[0];
if (first.type === 'paragraph' || first.type === 'heading')
first.attrs = { ...(first.attrs ?? {}), breakBefore: 'page' };
if (first.type === 'paragraph' || first.type === 'heading') {
first.attrs = { ...(first.attrs ?? {}), sectionBreak: true };
if (sectionStartsNewPage(g.sectPr ?? finalSectPr)) first.attrs.breakBefore = 'page';
}
}
const cols = sectPrColumns(g.sectPr ?? finalSectPr, ctx);
if (cols) pushColumnRuns(inner, cols, blocks, ctx);
@@ -158,27 +160,15 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
// 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/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, 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/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);
// Page geometry stays document-wide, from the body-final sectPr; only the zones are
// per section. A file whose sections disagree on geometry keeps the last one's.
const sect = parseSectPr(finalSectPr, ctx, oddEven);
const hfSections = sectionHfSets(groups.map((g) => g.sectPr ?? finalSectPr), ctx, oddEven);
const first = hfSections[0];
// A first-page/even zone reserves the header/footer band even when its default is empty;
// the distance is document-wide, so any section having one is enough.
const hasHeader = hfSections.some((s) => s.header || (s.differentFirstPage && s.headerFirst) || (s.differentOddEven && s.headerEven));
const hasFooter = hfSections.some((s) => s.footer || (s.differentFirstPage && s.footerFirst) || (s.differentOddEven && s.footerEven));
return {
content: { type: 'doc', content: blocks },
@@ -186,14 +176,15 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
margins: sect.margins,
orientation: sect.orientation,
format: sect.format,
header: sect.header,
footer: sect.footer,
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,
header: first.header,
footer: first.footer,
headerFirst: first.differentFirstPage ? first.headerFirst : null,
footerFirst: first.differentFirstPage ? first.footerFirst : null,
differentFirstPage: first.differentFirstPage,
headerEven: first.differentOddEven ? first.headerEven : null,
footerEven: first.differentOddEven ? first.footerEven : null,
differentOddEven: first.differentOddEven,
hfSections,
headerDistanceCm: hasHeader ? sect.headerDistCm : null,
footerDistanceCm: hasFooter ? sect.footerDistCm : null,
language: documentLanguage(stylesDoc, warnings),
@@ -1722,6 +1713,35 @@ function parseSectPr(sect: Element | null, ctx: Ctx, oddEven = false): {
};
}
// One HfSet per section, in body order, resolving Word's "Link to Previous": a section
// that declares no reference of a type keeps the previous section's. w:titlePg is per
// section, odd/even is document-wide (settings.xml).
function sectionHfSets(sectPrs: (Element | null)[], ctx: Ctx, oddEven: boolean): HfSet[] {
const out: HfSet[] = [];
for (const sect of sectPrs) {
const prev = out[out.length - 1] ?? EMPTY_HF_SET;
const titlePgEl = sect ? fc(sect, 'titlePg') : null;
const titlePg = !!titlePgEl && onOff(titlePgEl);
const zone = (type: 'header' | 'footer', variant: string, inherited: HfDoc): HfDoc => {
const ref = sect
? fcAll(sect, `${type}Reference`).find((r) => (r.getAttributeNS(W, 'type') ?? 'default') === variant)
: null;
return ref ? convertHfPart(ref.getAttributeNS(R, 'id'), ctx) : inherited;
};
out.push({
header: zone('header', 'default', prev.header),
footer: zone('footer', 'default', prev.footer),
headerFirst: titlePg ? zone('header', 'first', prev.headerFirst) : null,
footerFirst: titlePg ? zone('footer', 'first', prev.footerFirst) : null,
differentFirstPage: titlePg,
headerEven: oddEven ? zone('header', 'even', prev.headerEven) : null,
footerEven: oddEven ? zone('footer', 'even', prev.footerEven) : null,
differentOddEven: oddEven,
});
}
return out.length ? out : [{ ...EMPTY_HF_SET }];
}
function convertHfPart(relId: string | null, ctx: Ctx): HfDoc {
if (!relId) return null;
const target = ctx.rels.get(relId)?.target;
+39 -4
View File
@@ -15,7 +15,7 @@ import { PX_PER_CM, cmToPx, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage';
import type { HfDoc } from '../storage/headerFooter';
import type { HfDoc, HfSet } from '../storage/headerFooter';
import type { EmbeddedFont } from '../fonts/embeddedFonts';
import { cellPaddingAttr, type CellPadding } from '../editor/extensions/tableCellPadding';
@@ -50,6 +50,9 @@ export interface OdtImportResult {
headerEven: HfDoc;
footerEven: HfDoc;
differentOddEven: boolean;
// One set per section, in body order — [0] repeats the fields above (the app's own
// editable zones); the rest belong to blocks after a `sectionBreak`.
hfSections: HfSet[];
// Edge→zone distance (cm): header from top, footer from bottom. null = no zone.
headerDistanceCm: number | null;
footerDistanceCm: number | null;
@@ -86,6 +89,8 @@ type Ctx = {
pendingBlocks: Node[];
// Text width (cm) of the file's page setup; a table's margins are relative to it.
contentWidthCm: number;
// Master pages the body switches to, in order — one section each past the first.
masterPages: string[];
};
// Read a Pictures/ entry into a base64 data-URI; null when it's missing or in a format
@@ -393,7 +398,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
const contentWidthCm = geo
? pageDimsCm(geo.format, geo.orientation).w - geo.margins.left - geo.margins.right
: pageDimsCm('A4', 'portrait').w - 2 * 2.12;
const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [], contentWidthCm };
const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [], contentWidthCm, masterPages: [] };
let blocks = convertBlocks(Array.from(body.children), ctx, 'body');
if (blocks.length === 0) blocks.push({ type: 'paragraph' });
@@ -442,19 +447,26 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
const hasHeader = hf.header || headerFirst || headerEven;
const hasFooter = hf.footer || footerFirst || footerEven;
const header = hf.header ? convertHfZone(hf.header, ctx) : null;
const footer = hf.footer ? convertHfZone(hf.footer, ctx) : null;
return {
content: { type: 'doc', content: blocks },
margins: geometry?.margins ?? null,
orientation: geometry?.orientation ?? null,
format: geometry?.format ?? null,
header: hf.header ? convertHfZone(hf.header, ctx) : null,
footer: hf.footer ? convertHfZone(hf.footer, ctx) : null,
header,
footer,
headerFirst,
footerFirst,
differentFirstPage,
headerEven,
footerEven,
differentOddEven,
hfSections: [
{ header, footer, headerFirst, footerFirst, differentFirstPage, headerEven, footerEven, differentOddEven },
...ctx.masterPages.map((name) => hfSetOfMasterPage(name, ctx)),
],
headerDistanceCm: hasHeader ? edge?.top ?? null : null,
footerDistanceCm: hasFooter ? edge?.bottom ?? null : null,
language,
@@ -464,6 +476,22 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
};
}
// The zones of a named master page — what a section past the first switches to.
function hfSetOfMasterPage(name: string, ctx: Ctx): HfSet {
const hf = ctx.resolver.masterPageHF(name);
const zone = (el: Element | null) => (el ? convertHfZone(el, ctx) : null);
return {
header: zone(hf.header),
footer: zone(hf.footer),
headerFirst: zone(hf.headerFirst),
footerFirst: zone(hf.footerFirst),
differentFirstPage: !!(hf.headerFirst || hf.footerFirst),
headerEven: zone(hf.headerLeft),
footerEven: zone(hf.footerLeft),
differentOddEven: !!(hf.headerLeft || hf.footerLeft),
};
}
// A header/footer zone → one single-paragraph doc (hfExtensions schema). Multiple
// paragraphs collapse to hard line breaks; block structures flatten to their text.
function convertHfZone(zoneEl: Element, ctx: Ctx): HfDoc {
@@ -907,6 +935,13 @@ function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault =
const defaults = blockDefaults(resolver, named, isHeading ? level : null, boldByDefault);
const attrs = blockAttrs(paraProps, baseTextProps, defaults, kind);
// A style:master-page-name switches the page master, which is how ODF gives a section
// its own header/footer; the block that does it opens that section.
const master = kind === 'body' ? resolver.masterPageOf(styleName) : null;
if (master && ctx.masterPages[ctx.masterPages.length - 1] !== master) {
ctx.masterPages.push(master);
attrs.sectionBreak = true;
}
// Tab stops live in a child element of the paragraph properties, so they come from
// the resolver's own walk rather than the flattened paraProps.
const stops = formatTabStops(resolver.tabStops(styleName));
+20 -4
View File
@@ -452,14 +452,30 @@ export class StyleResolver {
return { count, gapCm: Math.round(gapCm * 100) / 100 };
}
// The master page governing the document (prefer "Standard", else the first).
private masterPageEl(): Element | null {
// The master page governing the document (prefer "Standard", else the first), or a
// named one — a section past the first points at its own (style:master-page-name).
private masterPageEl(name: string | null = null): Element | null {
const doc = this.stylesDoc;
if (!doc) return null;
const pages = Array.from(doc.getElementsByTagNameNS(NS.style, 'master-page'));
if (name) return pages.find(p => p.getAttributeNS(NS.style, 'name') === name) ?? null;
return pages.find(p => p.getAttributeNS(NS.style, 'name') === 'Standard') ?? pages[0] ?? null;
}
// The master page a paragraph style switches to, walking style:parent-style-name.
masterPageOf(styleName: string | null): string | null {
const seen = new Set<string>();
let cur = styleName;
while (cur && !seen.has(cur)) {
seen.add(cur);
const el = this.paraStyleEls.get(cur);
const name = el?.getAttributeNS(NS.style, 'master-page-name');
if (name) return name;
cur = this.styles.get(`paragraph\0${cur}`)?.parent ?? null;
}
return null;
}
private pageLayoutEl(): Element | null {
const doc = this.stylesDoc;
const layoutName = this.masterPageEl()?.getAttributeNS(NS.style, 'page-layout-name');
@@ -473,7 +489,7 @@ export class StyleResolver {
// Header/footer of the master page: the content elements, the vertical space the
// zone occupies below/above the page margin (height + body-side spacing, for the
// body-margin reconstruction), and whether per-page variants exist.
masterPageHF(): {
masterPageHF(pageName: string | null = null): {
header: Element | null;
footer: Element | null;
headerFirst: Element | null;
@@ -483,7 +499,7 @@ export class StyleResolver {
headerExtraCm: number;
footerExtraCm: number;
} {
const mp = this.masterPageEl();
const mp = this.masterPageEl(pageName);
const layout = this.pageLayoutEl();
const zone = (local: string): Element | null => {
+46
View File
@@ -6,6 +6,52 @@ export type HfZone = 'header' | 'footer';
export type HfVariant = 'default' | 'first' | 'even';
export type HfDoc = { type: 'doc'; content?: unknown[] } | null;
// One section's zones. A document has one per section (a body block carrying
// `sectionBreak` starts the next one); section 1 is the app's own editable state,
// the rest ride along from the file.
export type HfSet = {
header: HfDoc;
footer: HfDoc;
headerFirst: HfDoc;
footerFirst: HfDoc;
differentFirstPage: boolean;
headerEven: HfDoc;
footerEven: HfDoc;
differentOddEven: boolean;
};
export const EMPTY_HF_SET: HfSet = {
header: null, footer: null,
headerFirst: null, footerFirst: null, differentFirstPage: false,
headerEven: null, footerEven: null, differentOddEven: false,
};
export function hfSetIsEmpty(s: HfSet): boolean {
return hfIsEmpty(s.header) && hfIsEmpty(s.footer)
&& hfIsEmpty(s.headerFirst) && hfIsEmpty(s.footerFirst)
&& hfIsEmpty(s.headerEven) && hfIsEmpty(s.footerEven);
}
// Sections past the first, in order — imported and exported but not editable, so they
// are persisted whole rather than per zone like section 1's.
const EXTRA_KEY = 'odf-editor-hf-sections';
export function loadExtraHfSections(): HfSet[] {
const raw = localStorage.getItem(EXTRA_KEY);
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.map((s) => ({ ...EMPTY_HF_SET, ...s })) : [];
} catch {
return [];
}
}
export function saveExtraHfSections(sections: HfSet[]): void {
if (sections.length) localStorage.setItem(EXTRA_KEY, JSON.stringify(sections));
else localStorage.removeItem(EXTRA_KEY);
}
const KEYS: Record<HfZone, Record<HfVariant, string>> = {
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' },
+4 -3
View File
@@ -95,9 +95,10 @@ LibreOffice's 48, with 62 line-level differences, most of them the justification
one is rendered: a line box avoids a float's whole *margin* box, so applying the
vertical one as a top margin makes dead space where Word flows text — measured, the
thesis' figure page went from 131mm off to 187mm and the document grew a page.
- **One header/footer set for the whole document.** Word gives every *section* its own,
including its own first page; the contract's second section repeats the letterhead on
page 10 and the body starts 25mm lower there than here.
- **A later section's header/footer cannot be edited.** Its zones import, render and
export, but the Layout panel still edits section 1 only — the layer marks the others
read-only (`hf-locked`). Page geometry stays document-wide too, so a file whose
sections disagree on margins keeps the last one's.
- **Line height follows the paragraph, not the line.** The block's CSS strut applies to
every line; a word processor takes each line's own runs and the paragraph mark only on
the last. The mark's font rides the block (`blockFontSize.ts`), and a paragraph whose
+65
View File
@@ -0,0 +1,65 @@
// A section past the first carries its own header/footer: Word puts it on the section's
// sectPr, ODF on a master page the section's first block switches to.
import { describe, it, expect } from 'vitest';
import { unzipSync, strFromU8 } from 'fflate';
import { buildOdt } from '../../src/lib/export/odt';
import { importOdt } from '../../src/lib/import/odt';
import { buildDocx } from '../../src/lib/export/docx';
import { importDocx } from '../../src/lib/import/docx';
import type { HfSet } from '../../src/lib/storage/headerFooter';
type N = any;
const zone = (text: string): N => ({ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text }] }] });
const set = (h: string, f: string): HfSet => ({
header: zone(h), footer: zone(f),
headerFirst: null, footerFirst: null, differentFirstPage: false,
headerEven: null, footerEven: null, differentOddEven: false,
});
const doc: N = {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Section one body' }] },
{ type: 'paragraph', attrs: { sectionBreak: true }, content: [{ type: 'text', text: 'Section two body' }] },
],
};
const sections = [set('First header', 'First footer'), set('Second header', 'Second footer')];
const hf = {
header: sections[0].header, footer: sections[0].footer,
headerFirst: null, footerFirst: null, differentFirstPage: false,
headerEven: null, footerEven: null, differentOddEven: false,
sections, pageCount: 2,
};
const zoneText = (d: any) => d?.content?.[0]?.content?.map((n: any) => n.text ?? '').join('') ?? null;
describe('per-section header/footer', () => {
it('rides an ODF master page and comes back', async () => {
const bytes = await buildOdt(doc, { top: 2, bottom: 2, left: 2, right: 2 }, 'portrait', hf);
const files = unzipSync(bytes);
const styles = strFromU8(files['styles.xml']);
expect(styles).toContain('<style:master-page style:name="Section2"');
expect(styles).toContain('Second header');
// The section's first block points at it.
expect(strFromU8(files['content.xml'])).toMatch(/style:master-page-name="Section2"/);
const back = await importOdt(bytes);
expect(back.hfSections.length).toBe(2);
expect(zoneText(back.hfSections[0].header)).toBe('First header');
expect(zoneText(back.hfSections[1].header)).toBe('Second header');
expect(zoneText(back.hfSections[1].footer)).toBe('Second footer');
const marked = (back.content.content ?? []).filter((b: any) => b.attrs?.sectionBreak);
expect(marked.length).toBe(1);
});
it('rides a DOCX sectPr and comes back', async () => {
const bytes = await buildDocx(doc, { top: 2, bottom: 2, left: 2, right: 2 }, 'portrait', hf);
const back = importDocx(bytes);
expect(back.hfSections.length).toBe(2);
expect(zoneText(back.hfSections[0].header)).toBe('First header');
expect(zoneText(back.hfSections[1].header)).toBe('Second header');
expect((back.content.content ?? []).filter((b: any) => b.attrs?.sectionBreak).length).toBe(1);
});
});