From f6d1348a43b7f7d88e2a7206c7c3851af8c45fda Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Thu, 25 Jun 2026 20:46:37 +0200 Subject: [PATCH] feat: manual pagebreaks --- src/lib/editor/extensions.ts | 2 + src/lib/editor/extensions/pageBreak.ts | 65 ++++++++++++++ src/lib/editor/extensions/pageBreaks.ts | 23 ++++- src/lib/export/odt.ts | 110 +++++++++++++++++++++++- src/lib/import/odt.ts | 5 ++ tests/roundtrip.test.ts | 2 + 6 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 src/lib/editor/extensions/pageBreak.ts diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index be4362e..c98e1a6 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -24,6 +24,7 @@ import TextAlign from '@tiptap/extension-text-align'; import { PageBreaks } from './extensions/pageBreaks'; import { LineHeight } from './extensions/lineHeight'; import { ParagraphSpacing } from './extensions/paragraphSpacing'; +import { PageBreak } from './extensions/pageBreak'; import { Indent } from './extensions/indent'; import { FormattingMarks } from './extensions/formattingMarks'; import { SpellCheck } from './extensions/spellCheck'; @@ -60,6 +61,7 @@ export const extensions = [ Image, LineHeight, ParagraphSpacing, + PageBreak, Indent, Heading.configure({ levels: [1, 2, 3] }), BulletList, diff --git a/src/lib/editor/extensions/pageBreak.ts b/src/lib/editor/extensions/pageBreak.ts new file mode 100644 index 0000000..2603f39 --- /dev/null +++ b/src/lib/editor/extensions/pageBreak.ts @@ -0,0 +1,65 @@ +import { Extension } from '@tiptap/core'; + +// Manual page break before a paragraph/heading, stored as breakBefore: 'page' | null and +// rendered as data-page-break-before. Round-trips to ODF fo:break-before="page" (import +// blockAttrs, export replace/applyPageBreaks); pageBreaks.ts forces it to the next page. + +declare module '@tiptap/core' { + interface Commands { + pageBreak: { + setPageBreakBefore: () => ReturnType; + unsetPageBreakBefore: () => ReturnType; + togglePageBreakBefore: () => ReturnType; + }; + } +} + +export const PageBreak = Extension.create({ + name: 'pageBreak', + + addOptions() { + return { + types: ['paragraph', 'heading'] as string[], + }; + }, + + addGlobalAttributes() { + return [ + { + types: this.options.types, + attributes: { + breakBefore: { + default: null, + parseHTML: (element: HTMLElement) => + element.getAttribute('data-page-break-before') === 'page' ? 'page' : null, + renderHTML: (attributes: Record) => { + if (attributes.breakBefore !== 'page') return {}; + return { 'data-page-break-before': 'page' }; + }, + }, + }, + }, + ]; + }, + + addCommands() { + return { + setPageBreakBefore: () => ({ commands }) => + this.options.types + .map((type) => commands.updateAttributes(type, { breakBefore: 'page' })) + .some((r) => r), + unsetPageBreakBefore: () => ({ commands }) => + this.options.types + .map((type) => commands.updateAttributes(type, { breakBefore: null })) + .some((r) => r), + togglePageBreakBefore: () => ({ editor, commands }) => { + const current = + editor.getAttributes('paragraph').breakBefore ?? editor.getAttributes('heading').breakBefore; + const next = current === 'page' ? null : 'page'; + return this.options.types + .map((type) => commands.updateAttributes(type, { breakBefore: next })) + .some((r) => r); + }, + }; + }, +}); diff --git a/src/lib/editor/extensions/pageBreaks.ts b/src/lib/editor/extensions/pageBreaks.ts index d8a35e0..f0e9afa 100644 --- a/src/lib/editor/extensions/pageBreaks.ts +++ b/src/lib/editor/extensions/pageBreaks.ts @@ -179,6 +179,9 @@ type Leaf = { // pages. Their breaks get a full-width close/open mask so the single continuous // table box looks closed at each page break (it can't break structurally). inTableCell?: boolean; + // Manual page break before this block (ODF fo:break-before; pageBreak.ts): force the + // leaf to the next page's top even when it would otherwise fit on the current page. + forceBreakBefore?: 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. @@ -647,6 +650,9 @@ export const PageBreaks = Extension.create({ naturalTop: topWithin(child) - cumulativeSpacerHeight, naturalHeight: child.offsetHeight - intraSpacerHeight, inTableCell, + // A manual page break is honored for top-level blocks only (not in + // a table cell, where the table breaks atomically between rows). + forceBreakBefore: !inTableCell && child.dataset?.pageBreakBefore === 'page', }); cumulativeSpacerHeight += intraSpacerHeight; continue; @@ -752,7 +758,22 @@ export const PageBreaks = Extension.create({ // Set when a leaf overflows but no spacer can bridge it (block > one page). let noPushReason: string | null = null; - if (effectiveTop < contentStart && i > 0) { + // A manual page break forces the block to the next page's top (never the first + // leaf, so no leading blank page); once settled at a page top the guard stops. + // A forced block that then overflows is split by the normal logic next pass. + const forced = !!leaf.forceBreakBefore && i > 0 && effectiveTop > contentStart + 0.5; + + if (forced) { + const target = pageContentStart(page + 1, vm.top, CYCLE_PX); + const { docPos, row } = leafSpacer(leaf); + breaks.push({ + height: target - effectiveTop, + docPos, + row, + bandOpenY: target, + reason: 'forced-page-break', + }); + } else if (effectiveTop < contentStart && i > 0) { const { docPos, row } = leafSpacer(leaf); breaks.push({ height: contentStart - effectiveTop, diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index b35a080..616a128 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -58,6 +58,11 @@ const TAB = ''; // applyImages rewrites it to a in content.xml. U+E004. const IMG = ''; +// Sentinel prepended to a top-level paragraph/heading carrying a manual page break +// (breakBefore: 'page'); applyPageBreaks finds the marked block in content.xml, mints a +// fo:break-before="page" paragraph style for it, and strips the sentinel. U+E005. +const PGB = ''; + const EXT_BY_MIME: Record = { 'image/png': 'png', 'image/jpeg': 'jpg', @@ -151,6 +156,22 @@ function replaceTabs(node: TiptapNode): TiptapNode { return { ...node, content: node.content.map(replaceTabs) }; } +// Prepend a PGB sentinel run to each top-level paragraph/heading with breakBefore so it +// rides every odf-kit path; applyPageBreaks resolves it. Top-level only: a sentinel in a +// cell/list paragraph would corrupt the SEG-based cell/list rebuild (applyCellBlocks). +function replacePageBreaks(doc: TiptapNode): TiptapNode { + if (!doc.content?.length) return doc; + return { + ...doc, + content: doc.content.map(child => { + if ((child.type === 'paragraph' || child.type === 'heading') && child.attrs?.breakBefore === 'page') { + return { ...child, content: [{ type: 'text', text: PGB }, ...(child.content ?? [])] }; + } + return child; + }), + }; +} + // 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). @@ -415,6 +436,90 @@ function applyListItemStyles(odtBytes: Uint8Array, styles: ParaStyle[]): Uint8Ar return rezipOdt(files); } +// The whole … (or self-closing) for an automatic style by +// name from content.xml. null when the name is a common/named style (defined in +// styles.xml), so applyPageBreaks mints a child of it instead of cloning. +function findAutoStyle(content: string, name: string): string | null { + const open = content.indexOf(`', open); + if (gt < 0) return null; + if (content[gt - 1] === '/') return content.slice(open, gt + 1); + const closeTag = ''; + const close = content.indexOf(closeTag, gt); + if (close < 0) return null; + return content.slice(open, close + closeTag.length); +} + +// Clone a definition under newName, ensuring its paragraph-properties +// carry fo:break-before="page" (paragraph-properties precedes text-properties in ODF, so +// a freshly inserted one stays first child). +function cloneStyleWithBreak(def: string, newName: string): string { + const s = def.replace(/style:name="[^"]*"/, `style:name="${newName}"`); + if (/fo:break-before=/.test(s)) return s; + const pp = ''; + if (/^]*\/>\s*$/.test(s)) { + return s.replace(/\/>\s*$/, `>${pp}`); + } + if (/]*\/>/.test(s)) { + return s.replace(/]*?)\s*\/>/, ''); + } + if (/]*)>/, ''); + } + return s.replace(/(]*[^/]>)/, `$1${pp}`); +} + +// Resolve PGB sentinels: per source style, mint a fo:break-before="page" paragraph style +// (cloning its props so blocks sharing the style aren't affected, reused across blocks), +// reassign each marked block's style-name, and strip the sentinel. +function applyPageBreaks(odtBytes: Uint8Array): Uint8Array { + const files = unzipSync(odtBytes); + const contentBytes = files['content.xml']; + if (!contentBytes) return odtBytes; + + let content = strFromU8(contentBytes); + if (!content.includes(PGB)) return odtBytes; + + const minted: string[] = []; + const nameBySource = new Map(); + let counter = 0; + + const breakStyleFor = (source: string): string => { + const existing = nameBySource.get(source); + if (existing) return existing; + const name = `PB${++counter}`; + nameBySource.set(source, name); + const def = source ? findAutoStyle(content, source) : null; + minted.push(def + ? cloneStyleWithBreak(def, name) + : ``, + ); + return name; + }; + + content = content.replace( + new RegExp(`]*)>${PGB}`, 'g'), + (_m, tag: string, attrs: string) => { + const sm = /text:style-name="([^"]*)"/.exec(attrs); + const name = breakStyleFor(sm ? sm[1] : ''); + const newAttrs = sm + ? attrs.replace(/text:style-name="[^"]*"/, `text:style-name="${name}"`) + : ` text:style-name="${name}"${attrs}`; + return ``; + }, + ); + // Drop any sentinels not consumed above (defensive — never legitimate text). + content = content.split(PGB).join(''); + + if (minted.length) { + content = content.replace('', `${minted.join('')}`); + } + + files['content.xml'] = strToU8(content); + return rezipOdt(files); +} + // odf-kit always emits ordered lists as "1." (num-format="1" num-suffix="."). This // rewrites the matching L# list style to the user's chosen format; formats[] has one // entry per top-level list in odf-kit's order (null for bullets/default 'decimal'). @@ -1138,7 +1243,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU // Collect embedded images and swap them for IMG sentinels before serialization; // applyImages resolves the sentinels and writes the Pictures/ + manifest entries. const images: ImageExport[] = []; - const raw = replaceImages(replaceTabs(replaceHardBreaks(docJson)), images); + const raw = replaceImages(replaceTabs(replaceHardBreaks(replacePageBreaks(docJson))), images); 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; // Distance from the page edge to the header (top) / footer (bottom). Becomes the @@ -1250,7 +1355,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU const cleaned = collapseRunWhitespace(styledRows); const withBreaks = applyInlineSentinels(cleaned); const withImages = applyImages(withBreaks, images); - const withStyles = rewriteStylesXml(withImages, language ?? null); + const withPageBreaks = applyPageBreaks(withImages); + const withStyles = rewriteStylesXml(withPageBreaks, language ?? null); return applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist); } diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index ee4e946..08b2c37 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -362,6 +362,11 @@ function blockAttrs(paraProps: PropMap, textProps: PropMap, headingLevel: number if (ml != null && ml > EPS_PT) attrs.indent = Math.round((ml / 72) * 2.54 * 100) / 100; } + // Manual page break (fo:break-before). Honored for top-level blocks only + // (pageBreaks.ts forces them to the next page top); the editor has no column + // breaks, so only "page". Cell/list blocks can't carry it. + if (kind === 'body' && paraProps['fo:break-before'] === 'page') attrs.breakBefore = 'page'; + return attrs; } diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 60ee59a..7cd4b56 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -95,6 +95,8 @@ const fixture: N = { CELL([240], P(null, T('B2'))), ] }, ] }, + P({ breakBefore: 'page', textAlign: 'center', lineHeight: '1.5' }, T('Forced page (styled)')), + P({ breakBefore: 'page' }, T('Forced page (plain)')), P(null, T('The end.')), ], };