fix: a section keeps its own side margins too

.tiptap's padding draws one pair of side margins for the whole document, so a
title page declaring 3.18cm left sat 3.85mm off. The difference now rides a
--sec-inset-left/-right node decoration on the section's top-level blocks, and
everything that writes a block margin adds it in — an indent, a named style's
and a table's alignment all compose with it instead of losing to it.
This commit is contained in:
Steffen Becker committed 2026-08-11 19:03:58 +00:00
1 parent 9bce48f6e0
commit baf0865256
7 files changed
+87 -8

No files matched your search

+18
View File
@@ -59,6 +59,24 @@ stretches them. Measured on `02-blocks`: LO fits a trailing "et" that needs ~5mm
across 15 spaces. Nothing in CSS expresses that, so a justified paragraph may break one word
earlier here; the same paragraph left-aligned matches LibreOffice to 0.1mm.
**Per-section page margins.** Word's `w:pgMar` and ODF's page layout belong to the section,
not the document, so each section's `HfSet` carries its own `margins` (and `marginsFirst`
where an ODF master hands over to another with `style:next-style-name` — that layout governs
the section's first page, the successor's the rest). `Editor.svelte` publishes them as
`--pb-section-reach` (already the per-page content area) and `--pb-section-inset`
("leftFirst|rightFirst|leftRest|rightRest" px against the document's own side margins).
Vertically a spacer can only push down, so a section whose top margin is *smaller* than the
document's gets a **lifting** spacer — height 0 and a negative `margin-top` — on its first
block, and only where nothing was pushed onto that page ahead of it; `collectLeaves` counts
that margin into `cumulativeSpacerHeight` or the next pass measures its own answer.
Horizontally `.tiptap`'s padding draws one pair for every page, so the difference becomes a
`--sec-inset-left`/`-right` node decoration on the section's top-level blocks, added in by
everything that writes a block margin (`editor.css`, `indent.ts`, `styleSheet.ts`,
`tableView.ts`) and cleared on descendants so a nested indent can't count it twice. The page
a block lands on is read *after* its own spacer, so a forced break onto the section's second
page takes the "rest" pair. Not carried: the ruler, the header/footer layer and a frame's
`COLUMN_WIDTH_CSS` stay document-wide, and both exports write one page geometry.
**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.
**Layout constants** (must stay in sync between `pageBreaks.ts`, `Editor.svelte`, and `editor.css`):
+16
View File
@@ -167,8 +167,23 @@
}),
].map((g) => g.map((n) => Math.round(n)).join('|')).join(','));
// 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").
let sectionInset = $derived([
[0, 0, 0, 0],
...extraHfSections.map((s) => {
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;
return [d(first, 'left'), d(first, 'right'), d(rest, 'left'), d(rest, 'right')];
}),
].map((g) => g.join('|')).join(','));
$effect(() => {
const s = document.documentElement.style;
s.setProperty('--pb-section-inset', sectionInset);
s.setProperty('--pb-content-top-rest', `${effTopRest}px`);
s.setProperty('--pb-content-top-first', `${effTopFirst}px`);
s.setProperty('--pb-content-bottom-rest', `${effBottomRest}px`);
@@ -192,6 +207,7 @@
// …and the header/footer-driven effective margins, so growing a zone re-paginates.
void (effTopRest + effTopFirst + effBottomRest + effBottomFirst);
void sectionReach;
void sectionInset;
const ed = editor;
if (!ed) return;
cancelAnimationFrame(marginRecalcRaf);
+3 -2
View File
@@ -146,7 +146,8 @@ export const Indent = Extension.create({
const cm = Number(attributes.indent);
return {
'data-indent': String(cm),
style: `margin-left: ${cm}cm`,
// Plus the section inset, which .tiptap's own padding can't draw (editor.css).
style: `margin-left: calc(var(--sec-inset-left, 0px) + ${cm}cm)`,
};
},
},
@@ -159,7 +160,7 @@ export const Indent = Extension.create({
const cm = Number(attributes.indentRight);
return {
'data-indent-right': String(cm),
style: `margin-right: ${cm}cm`,
style: `margin-right: calc(var(--sec-inset-right, 0px) + ${cm}cm)`,
};
},
},
+31 -3
View File
@@ -931,6 +931,12 @@ export const PageBreaks = Extension.create({
.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];
// Its side margins, as a delta on the document's: .tiptap's padding draws only
// one pair, so a section wanting others insets its own blocks by the difference.
const inset = csRoot.getPropertyValue('--pb-section-inset').split(',')
.map((g) => g.split('|').map(Number))
.filter((g) => g.length === 4 && g.every(Number.isFinite));
const insetAt = (i: number) => inset[Math.min(i, inset.length - 1)] ?? [0, 0, 0, 0];
const scale = getScaleFactor();
const leaves = collectLeaves(CONTENT_HEIGHT, scale);
@@ -960,6 +966,8 @@ export const PageBreaks = Extension.create({
// Node-decoration ranges dropping the space before a block a soft break put
// at a page top (probed in LibreOffice); a hard break and the first block keep it.
const pageTopBlocks: { from: number; to: number }[] = [];
// Top-level blocks a section's own side margins inset, keyed by doc position.
const sectionInsets = new Map<number, { to: number; left: number; right: number }>();
// Close/open bands for in-cell + between-rows table breaks. Keyed by the
// rounded openY (the grouping id) → the unrounded openY (so the band lands
// exactly on the page cycle) plus whether it's a between-rows break.
@@ -999,6 +1007,7 @@ export const PageBreaks = Extension.create({
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.
const breaks: {
@@ -1299,8 +1308,21 @@ 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));
const landedPage = getPageForY(leaf.naturalTop + cumulativeShift, CYCLE_PX);
if (leaf.sectionStart) sectionStartPages.push(landedPage);
const ins = insetAt(sectionIndex);
const insLeft = Math.round(landedPage === sectionFirstPage ? ins[0] : ins[2]);
const insRight = Math.round(landedPage === sectionFirstPage ? ins[1] : ins[3]);
if (insLeft || insRight) {
// The leaf may be a row, or a line inside one; the inset belongs on the
// top-level block, which is the ancestor .tiptap holds directly.
let block: HTMLElement = leaf.el;
while (block.parentElement && block.parentElement !== dom) block = block.parentElement;
const from = block.parentElement === dom ? docPosBeforeElement(block) : null;
const node = from !== null ? editorView.state.doc.nodeAt(from) : null;
if (from !== null && node && !sectionInsets.has(from)) {
sectionInsets.set(from, { to: from + node.nodeSize, left: insLeft, right: insRight });
}
}
cumulativeShift -= droppedShift;
// Track the lowest rendered content bottom (cumulativeShift now includes
@@ -1323,7 +1345,8 @@ export const PageBreaks = Extension.create({
const placementsKey =
placements.map((p) => `${p.docPos}:${p.height}:${p.row ? p.row.columns : 'b'}`).join('|') +
(collapsedTrailing ? `|c${collapsedTrailing.from}` : '') +
pageTopBlocks.map((b) => `|t${b.from}`).join('');
pageTopBlocks.map((b) => `|t${b.from}`).join('') +
Array.from(sectionInsets, ([f, s]) => `|i${f}:${s.left}:${s.right}`).join('');
const placementsChanged =
placementsKey !== lastPlacementsKey && placementsKey !== prevPlacementsKey;
if (placementsChanged) {
@@ -1361,6 +1384,11 @@ export const PageBreaks = Extension.create({
style: 'height:0;min-height:0;margin:0;overflow:hidden',
}));
}
for (const [from, s] of sectionInsets) {
decoArray.push(Decoration.node(from, s.to, {
style: `--sec-inset-left:${s.left}px;--sec-inset-right:${s.right}px`,
}));
}
for (const b of pageTopBlocks) {
// --space-top: a text box draws its own space above from it (textBox.ts).
decoArray.push(Decoration.node(b.from, b.to, { style: 'padding-top:0;margin-top:0;--space-top:0px' }));
+4 -2
View File
@@ -44,8 +44,10 @@ function applyMargins(node: PMNode, wrapper: HTMLElement): void {
const mr = (node.attrs.marginRight as number) || 0;
const mt = (node.attrs.marginTop as number) || 0;
const mb = (node.attrs.marginBottom as number) || 0;
wrapper.style.marginLeft = ml ? `${ml}cm` : '';
wrapper.style.marginRight = mr ? `${mr}cm` : '';
// Always written: .tableWrapper's own `margin: 0` would otherwise beat the section
// inset editor.css puts on a top-level block.
wrapper.style.marginLeft = `calc(var(--sec-inset-left, 0px) + ${ml}cm)`;
wrapper.style.marginRight = `calc(var(--sec-inset-right, 0px) + ${mr}cm)`;
wrapper.style.marginBottom = mb ? `${mb}cm` : '';
// Space above rides --space-before so editor.css can add it to the block above
// instead of collapsing against it (storage/spacingModel.ts).
+2 -1
View File
@@ -252,7 +252,8 @@ function declarations(r: ResolvedStyle): string[] {
// Padding or margin per the document's spacing model — editor.css resolves it.
if (p.spaceBefore != null) out.push(`--space-before: ${p.spaceBefore}pt`);
if (p.spaceAfter != null) out.push(`margin-bottom: ${p.spaceAfter}pt`);
if (p.indent != null) out.push(`margin-left: ${p.indent}cm`);
// Plus the section inset, which .tiptap's own padding can't draw (editor.css).
if (p.indent != null) out.push(`margin-left: calc(var(--sec-inset-left, 0px) + ${p.indent}cm)`);
if (p.backgroundColor) out.push(`background-color: ${p.backgroundColor}`);
for (const [key, side] of [['borderTop', 'top'], ['borderRight', 'right'], ['borderBottom', 'bottom'], ['borderLeft', 'left']] as const) {
const v = p[key];
+13
View File
@@ -153,6 +153,19 @@
font-size: 0;
}
/* A section whose page setup has side margins of its own insets its top-level blocks by
the difference — .tiptap's padding can only draw one pair (pageBreaks.ts sets the two
properties per block). Everything that writes a block margin adds them in, so an indent
and the inset compose; a descendant clears them, or a nested one would add them twice. */
.paper .tiptap > * {
margin-left: var(--sec-inset-left, 0px);
margin-right: var(--sec-inset-right, 0px);
}
.paper .tiptap > * * {
--sec-inset-left: 0px;
--sec-inset-right: 0px;
}
/* No paragraph spacing by default, like LibreOffice's Default Paragraph Style and
Word's implied 0 — an editor default here would show up as extra space in every
imported document (export/odt.ts zeroes odf-kit's Standard style to match). */