feat: manual pagebreaks

This commit is contained in:
Steffen Becker committed 2026-06-25 20:49:07 +02:00
1 parent a3c76fcb66
commit f6d1348a43
6 files changed
+204 -3

No files matched your search

+2
View File
@@ -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,
+65
View File
@@ -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<ReturnType> {
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<string, unknown>) => {
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);
},
};
},
});
+22 -1
View File
@@ -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,
+108 -2
View File
@@ -58,6 +58,11 @@ const TAB = '';
// applyImages rewrites it to a <draw:frame> 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<string, string> = {
'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 <style:style …>…</style:style> (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(`<style:style style:name="${name}"`);
if (open < 0) return null;
const gt = content.indexOf('>', open);
if (gt < 0) return null;
if (content[gt - 1] === '/') return content.slice(open, gt + 1);
const closeTag = '</style:style>';
const close = content.indexOf(closeTag, gt);
if (close < 0) return null;
return content.slice(open, close + closeTag.length);
}
// Clone a <style:style> 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 = '<style:paragraph-properties fo:break-before="page"/>';
if (/^<style:style\b[^>]*\/>\s*$/.test(s)) {
return s.replace(/\/>\s*$/, `>${pp}</style:style>`);
}
if (/<style:paragraph-properties\b[^>]*\/>/.test(s)) {
return s.replace(/<style:paragraph-properties\b([^>]*?)\s*\/>/, '<style:paragraph-properties$1 fo:break-before="page"/>');
}
if (/<style:paragraph-properties\b/.test(s)) {
return s.replace(/<style:paragraph-properties\b([^>]*)>/, '<style:paragraph-properties$1 fo:break-before="page">');
}
return s.replace(/(<style:style\b[^>]*[^/]>)/, `$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<string, string>();
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)
: `<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="${source || 'Standard'}"><style:paragraph-properties fo:break-before="page"/></style:style>`,
);
return name;
};
content = content.replace(
new RegExp(`<text:(p|h)\\b([^>]*)>${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 `<text:${tag}${newAttrs}>`;
},
);
// Drop any sentinels not consumed above (defensive — never legitimate text).
content = content.split(PGB).join('');
if (minted.length) {
content = content.replace('</office:automatic-styles>', `${minted.join('')}</office:automatic-styles>`);
}
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);
}
+5
View File
@@ -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;
}
+2
View File
@@ -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.')),
],
};