feat: each page is centred, and a section can pick its own paper

A section narrower than the reserved sheet now splits the difference
between its insets instead of hanging it all on the right, and every
per-page layer draws from the box's own left — sheet, header/footer, page
border and line numbers. The ribbon's Size menu gains the same per-section
block Orientation already had.
This commit is contained in:
Steffen Becker committed 2026-08-15 09:52:43 +02:00
1 parent 5f2e7fcbcf
commit 0ec35b0168
8 files changed
+54 -33

No files matched your search

+1 -1
View File
@@ -99,6 +99,7 @@
- Page margins per section: a section's own `w:pgMar` / ODF page layout is read, rendered and written back, and an ODF page style that hands over to another (`style:next-style-name`, the title-page idiom) gives its layout to the section's first page and the successor's to the rest. The ruler and the header/footer layer still show the document's own pair
- Headers & footers: page-number / page-count fields, configurable edge distances, and variants — different first page and different odd & even pages (edit a page's zone directly; blank first/even zones supported). Press Enter to add blank lines that grow a zone into the page while body text reflows so the page break sits above the footer / below the header, never overlapping. Inline images/logos can be inserted in a zone. Round-trips to ODF (`style:header-first`/`-left`, `draw:frame`) and DOCX (`w:titlePg`/`w:evenAndOddHeaders`, `ImageRun`)
- Zoom (20–300 %)
- A section's own page setup: paper size and orientation per section (Layout ▸ Size / Orientation ▸ This section), beside the document's own. Pages of different sizes are each **centred** in the scroll, as both word processors draw them — sheet, text, header/footer, page border and line numbers alike
- Horizontal ruler above the page: click to place a tab stop of the selected type, drag to move it, drag it off to remove it; three markers set the paragraph's first-line, left and right indent. Toggle in the extended toolbar
- Show formatting marks (spaces, tabs, paragraph marks)
@@ -153,7 +154,6 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-14.
**Missing while writing**
- Hyphenation's zone and ladder count (`fo:hyphenation-ladder-count`, `w:hyphenationZone`) — CSS exposes neither
- A bibliography's citation style: the list is set the one way ("key: author, title, year"), where both word processors offer APA, MLA, Chicago and the rest, and numbered citations (`[1]`) instead of the source's short name
- A section's own **page format** is set only by the file it came from — the ribbon offers the orientation per section, the paper size document-wide. Pages of different widths are left-aligned in the scroll, where both word processors centre each
- A sort dialog's second and third key, and its choice of sort type over the detected one; a formula's own number format (LibreOffice's Number Format dialog per cell — the general format is the only one here), and a formula reaching into another table
- A list level's own hanging indent: the marker sits at the 0.635 cm both exports write (`LIST_HANGING_CM`), so a wider *left*-set marker overflows where Word moves the text to the next list tab (a **right**-set one — `w:lvlJc`, which is what the built-in Roman numberings use — grows into the margin and is fine). Reading the value back naively also moves the markers of our own ODT exports — LibreOffice draws its own flat hanging at exactly the value odf-kit writes for level 2; see `tests/render-parity/README.md` before building on it
- Linked / chained text frames
+4 -3
View File
@@ -87,9 +87,10 @@ becomes known, so a pass never measures its own last answer; it reports the runs
`Editor.svelte` publishes them as `--pb-page-runs` for every other consumer (the TOC, a
cross-reference, a page-anchored frame) plus `--pb-section-page` for the next pass.
`.paper` reserves the **widest** section's paper (`--pb-paper-width`) or a landscape page
would be cut at the sheet edge, and a narrower section takes the difference as an extra
right inset on top of its own margins. The sheets are left-aligned in that box, where
both word processors centre each page.
would be cut at the sheet edge, and a narrower section splits the difference between its
two insets, on top of its own margins — so each page is **centred** in that box, as both
word processors draw one. Every per-page layer positions from the box's own `left`
(`PageSheetLayer`, `PageDecorLayer`, `LineNumberLayer`, `HeaderFooterLayer`).
**Tables across page breaks:** when a single continuous table box crosses a page boundary, the plugin reports `TableBreakBand`s (doc-px geometry). `Editor.svelte` renders an overlay (`.band-layer` inside `.paper`) that masks the table borders bleeding through the page margins and paints the dark page gap as one seam-free stripe.
+20 -15
View File
@@ -204,13 +204,15 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
// Which section each page belongs to (sectionStartPages holds the first page of every
// section past the first), and from it the box every per-page layer paints.
let pageBoxes = $derived.by(() => {
const out: { top: number; height: number; width: number; section: number }[] = [];
const out: { top: number; left: number; height: number; width: number; section: number }[] = [];
let top = 0;
let section = 0;
for (let p = 1; p <= Math.max(1, numPages); p++) {
while (section < sectionStartPages.length && sectionStartPages[section] <= p) section++;
const paper = sectionPaper[Math.min(section, sectionPaper.length - 1)];
out.push({ top, height: paper.h, width: paper.w, section });
// A page narrower than the reserved sheet is centred in it, as both word
// processors centre each page of a document whose sections differ.
out.push({ top, left: Math.round((paperWidth - paper.w) / 2), height: paper.h, width: paper.w, section });
top += paper.h + PAGE_GAP;
}
return out;
@@ -219,19 +221,22 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
// The same per section for the side margins, as a delta against the document's own:
// .tiptap's padding draws those for every page, so a section that wants others has
// its blocks inset by the difference ("leftFirst|rightFirst|leftRest|rightRest").
// A section narrower than the sheet (.paper reserves the widest paper) takes the whole
// difference on its right, so its text still ends at its own page's right margin.
let sectionInset = $derived([
[0, paperWidth - sectionPaper[0].w, 0, paperWidth - sectionPaper[0].w],
...extraHfSections.map((s, i) => {
const rest = s.margins ?? null;
const first = s.marginsFirst ?? rest;
const d = (m: PageMargins | null, side: 'left' | 'right') =>
m ? Math.round(cmToPx(m[side]) - cmToPx(pageMargins[side])) : 0;
const narrow = paperWidth - (sectionPaper[i + 1]?.w ?? paperWidth);
return [d(first, 'left'), d(first, 'right') + narrow, d(rest, 'left'), d(rest, 'right') + narrow];
}),
].map((g) => g.join('|')).join(','));
// A section narrower than the sheet (.paper reserves the widest paper) splits the
// difference between its sides, so its text sits on the centred page it belongs to.
let sectionInset = $derived.by(() => {
const half = (i: number) => Math.round((paperWidth - (sectionPaper[i]?.w ?? paperWidth)) / 2);
return [
[half(0), half(0), half(0), half(0)],
...extraHfSections.map((s, i) => {
const rest = s.margins ?? null;
const first = s.marginsFirst ?? rest;
const d = (m: PageMargins | null, side: 'left' | 'right') =>
m ? Math.round(cmToPx(m[side]) - cmToPx(pageMargins[side])) : 0;
const narrow = half(i + 1);
return [d(first, 'left') + narrow, d(first, 'right') + narrow, d(rest, 'left') + narrow, d(rest, 'right') + narrow];
}),
].map((g) => g.join('|')).join(',');
});
$effect(() => {
const s = document.documentElement.style;
+6 -5
View File
@@ -44,7 +44,7 @@
differentOddEven?: boolean;
numPages: number;
/** One box per page (Editor.svelte): a section on its own paper differs in size. */
pageBoxes: { top: number; height: number; width: number }[];
pageBoxes: { top: number; left: number; height: number; width: number }[];
currentPage: number;
/** How the page-number field counts (format + start value). */
pageNumbering?: PageNumbering;
@@ -74,7 +74,7 @@
// A section on its own paper makes the pages differ, so every box comes from the
// grid Editor.svelte publishes; the document's own is the fallback.
const boxOf = (page: number) => pageBoxes[page - 1]
?? { top: (page - 1) * (pageHeightPx + PAGE_GAP), height: pageHeightPx, width: pageWidthPx };
?? { top: (page - 1) * (pageHeightPx + PAGE_GAP), left: 0, height: pageHeightPx, width: pageWidthPx };
let mTop = $derived(cmToPx(pageMargins.top));
let mBottom = $derived(cmToPx(pageMargins.bottom));
let mLeft = $derived(cmToPx(pageMargins.left));
@@ -89,9 +89,10 @@
function zoneBox(zone: HfZone, page: number) {
// Mirrored margins: an even page is the left-hand one, so the pair is swapped.
const left = pageMargins.mirrored && page % 2 === 0 ? mRight : mLeft;
const width = contentWidthOf(page);
const box = boxOf(page);
// From the page's own left edge — a section on narrower paper is centred.
const left = box.left + (pageMargins.mirrored && page % 2 === 0 ? mRight : mLeft);
const width = contentWidthOf(page);
if (zone === 'header') {
const top = box.top + headerDistPx;
return { top, left, width, height: Math.max(MIN_ZONE_PX, mTop - headerDistPx) };
@@ -387,7 +388,7 @@
class="hf-page-bg"
src={bg.src}
alt=""
style="top: {boxOf(p).top + bg.y}px; left: {bg.x}px; width: {bg.width}px; height: {bg.height}px;"
style="top: {boxOf(p).top + bg.y}px; left: {boxOf(p).left + bg.x}px; width: {bg.width}px; height: {bg.height}px;"
/>
{/each}
{/each}
+5 -3
View File
@@ -15,7 +15,7 @@
tick: number;
lineNumbering: LineNumbering;
/** One box per page (Editor.svelte): a section on its own paper differs in size. */
pageBoxes: { top: number; height: number; width: number }[];
pageBoxes: { top: number; left: number; height: number; width: number }[];
pageMargins: PageMargins;
} = $props();
@@ -24,7 +24,9 @@
for (let i = pageBoxes.length - 1; i >= 0; i--) if (top >= pageBoxes[i].top) return i + 1;
return 1;
};
let left = $derived(cmToPx(pageMargins.left) - cmToPx(lineNumbering.distanceCm));
// Measured from the page's own left edge: a narrower section's page is centred.
const leftOf = (top: number) => (pageBoxes[pageAt(top) - 1]?.left ?? 0)
+ cmToPx(pageMargins.left) - cmToPx(lineNumbering.distanceCm);
let marks = $state<{ top: number; label: string }[]>([]);
let host = $state<HTMLElement | null>(null);
@@ -101,7 +103,7 @@
<div class="line-number-layer" bind:this={host} aria-hidden="true">
{#each marks as m}
<span class="line-number" style="top: {m.top}px; right: calc(100% - {left}px);">{m.label}</span>
<span class="line-number" style="top: {m.top}px; right: calc(100% - {leftOf(m.top)}px);">{m.label}</span>
{/each}
</div>
+3 -3
View File
@@ -8,7 +8,7 @@
let { decor, pageBoxes, pageMargins }: {
decor: PageDecor;
/** One box per page (Editor.svelte): a section on its own paper differs in size. */
pageBoxes: { top: number; height: number; width: number }[];
pageBoxes: { top: number; left: number; height: number; width: number }[];
pageMargins: PageMargins;
} = $props();
@@ -38,14 +38,14 @@
{@const ring = inset(p)}
<div
class="page-border"
style="top: {box.top + ring.top}px; left: {ring.left}px;
style="top: {box.top + ring.top}px; left: {box.left + ring.left}px;
width: {box.width - ring.left - ring.right}px;
height: {box.height - ring.top - ring.bottom}px;
border: {decor.border.widthPt}pt solid {decor.border.color};"
></div>
{/if}
{#if decor.watermark}
<div class="watermark" style="top: {box.top}px; height: {box.height}px;">
<div class="watermark" style="top: {box.top}px; left: {box.left}px; width: {box.width}px; height: {box.height}px;">
<!-- A fontwork shape stretches its text to the box rather than setting it at a
size, which is what textLength does here — the box is the text width, as
LibreOffice sizes its own watermark. -->
+2 -3
View File
@@ -3,13 +3,13 @@
// page was the same size; a section on its own paper makes the pages differ in height
// and width, and only a box per page can say that.
let { pageBoxes }: {
pageBoxes: { top: number; height: number; width: number }[];
pageBoxes: { top: number; left: number; height: number; width: number }[];
} = $props();
</script>
<div class="page-sheet-layer" aria-hidden="true">
{#each pageBoxes as box}
<div class="page-sheet" style="top: {box.top}px; height: {box.height}px; width: {box.width}px;"></div>
<div class="page-sheet" style="top: {box.top}px; left: {box.left}px; height: {box.height}px; width: {box.width}px;"></div>
{/each}
</div>
@@ -25,7 +25,6 @@
.page-sheet {
position: absolute;
left: 0;
background: var(--color-page-bg);
border: 1px solid var(--color-page-border);
/* The border rings the sheet without growing it, so the page box stays the paper. */
@@ -57,6 +57,10 @@
});
return n;
});
// Its own paper, where it has one — else the document's.
let sectionFormat = $derived(
currentSection > 0 ? (extraHfSections[currentSection - 1]?.format ?? pageFormat) : pageFormat,
);
// Its own orientation, where it has one — else the document's.
let sectionOrientation = $derived(
currentSection > 0 ? (extraHfSections[currentSection - 1]?.orientation ?? pageOrientation) : pageOrientation,
@@ -187,6 +191,15 @@
<span class="menu-sub">{fmtCm(PAGE_FORMAT_CM[f].w)} × {fmtCm(PAGE_FORMAT_CM[f].h)} cm</span>
</button>
{/each}
{#if currentSection > 0}
<div class="rb-menu-label">{t().ribbon.thisSection}</div>
{#each FORMATS as f}
<button class:selected={sectionFormat === f} onclick={() => { closeMenu(); setSectionProp({ format: f }); }}>
{t().toolbarExpanded.pageFormats[f]}
</button>
{/each}
<button onclick={() => { closeMenu(); setSectionProp({ format: null }); }}>{t().ribbon.likeDocument}</button>
{/if}
</div>
</div>
{/if}