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:
1 parent
60980fa4d6
commit
6accc7268f
14 files changed
+567
-109
No files matched your search
+19
-1
@@ -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}
|
||||
|
||||
@@ -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).
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' };
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user