diff --git a/docs/architecture/pagination.md b/docs/architecture/pagination.md index 821022d..c0673af 100644 --- a/docs/architecture/pagination.md +++ b/docs/architecture/pagination.md @@ -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`): diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index eb72f44..9d0db66 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -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); diff --git a/src/lib/editor/extensions/indent.ts b/src/lib/editor/extensions/indent.ts index 90e76c2..10eb436 100644 --- a/src/lib/editor/extensions/indent.ts +++ b/src/lib/editor/extensions/indent.ts @@ -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)`, }; }, }, diff --git a/src/lib/editor/extensions/pageBreaks.ts b/src/lib/editor/extensions/pageBreaks.ts index c57db92..2714a5d 100644 --- a/src/lib/editor/extensions/pageBreaks.ts +++ b/src/lib/editor/extensions/pageBreaks.ts @@ -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(); // 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' })); diff --git a/src/lib/editor/extensions/tableView.ts b/src/lib/editor/extensions/tableView.ts index 16ea817..6a77d23 100644 --- a/src/lib/editor/extensions/tableView.ts +++ b/src/lib/editor/extensions/tableView.ts @@ -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). diff --git a/src/lib/styles/styleSheet.ts b/src/lib/styles/styleSheet.ts index 3b04cf2..be34234 100644 --- a/src/lib/styles/styleSheet.ts +++ b/src/lib/styles/styleSheet.ts @@ -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]; diff --git a/src/styles/editor.css b/src/styles/editor.css index cee8425..8597464 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -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). */