hotfix pagebreak

This commit is contained in:
Mateusz Gruszczyński
2026-09-12 09:10:53 +02:00
parent 090acc9559
commit ba0c2a84d5
7 changed files with 287 additions and 54 deletions
+1 -1
View File
@@ -273,7 +273,7 @@
<form id="pdf-export-form" method="dialog" class="dialog-panel pdf-export-panel">
<button class="modal-close" type="button" data-pdf-cancel aria-label="Close dialog">×</button>
<h2>Export PDF</h2>
<p class="dialog-copy" data-i18n="editor.pdfPageBreakHelp">Use --- or Ctrl/Cmd+Enter to start a new PDF page.</p>
<p class="dialog-copy" data-i18n="editor.pdfPageBreakHelp">Use Page break (PDF) or Ctrl/Cmd+Enter to start a new PDF page.</p>
<div class="pdf-export-fields">
<label><span>Page format</span><select class="app-select-input" id="pdf-page-format">
<option value="a4-portrait">A4 — Portrait</option>
+12 -3
View File
@@ -45,15 +45,23 @@ function toggleWrap(editor, before, after = before, placeholder = t("editor.form
}
export function insertPageBreak(editor) {
function insertStandaloneLine(editor, content) {
const { start, end } = selection(editor);
const value = editor.value;
const needsLeadingNewline = start > 0 && value[start - 1] !== "\n";
const needsTrailingNewline = end >= value.length || value[end] !== "\n";
const marker = `${needsLeadingNewline ? "\n" : ""}---${needsTrailingNewline ? "\n" : ""}`;
const marker = `${needsLeadingNewline ? "\n" : ""}${content}${needsTrailingNewline ? "\n" : ""}`;
editor.setRangeText(marker, start, end, "end");
}
export function insertPageBreak(editor) {
insertStandaloneLine(editor, "[PAGEBREAK]");
}
export function insertHorizontalRule(editor) {
insertStandaloneLine(editor, "---");
}
function togglePrefix(editor, prefixFactory) {
const { start, end } = selection(editor);
const lineStart = editor.value.lastIndexOf("\n", start - 1) + 1;
@@ -98,7 +106,8 @@ export function applyFormat(editor, format) {
if (format === "table") toggleWrap(editor, `| ${t("editor.format.column1", {}, "Column 1")} | ${t("editor.format.column2", {}, "Column 2")} |\n| --- | --- |\n| `, ` | ${t("editor.format.value", {}, "value")} |`, t("editor.format.value", {}, "value"));
if (format === "footnote") toggleWrap(editor, "", `[^1]\n\n[^1]: ${t("editor.format.footnote", {}, "Footnote text")}`, t("editor.format.textWithFootnote", {}, "Text with footnote"));
if (format === "definition") toggleWrap(editor, "", `\n: ${t("editor.format.definition", {}, "Definition")}`, t("editor.format.term", {}, "Term"));
if (format === "page-break" || format === "horizontal-rule") insertPageBreak(editor);
if (format === "page-break") insertPageBreak(editor);
if (format === "horizontal-rule") insertHorizontalRule(editor);
editor.focus();
editor.dispatchEvent(new Event("input", { bubbles: true }));
}
+2 -1
View File
@@ -553,7 +553,8 @@ export function renderMarkdown(source, lineOffset = 0) {
html += `<dd data-source-line="${index + lineOffset + 1}">${inline(lines[index].replace(/^:\s+/, ""))}</dd>`;
}
html += `</dl>`;
} else if (/^---+$/.test(line.trim())) html += `<hr${attrs(index, false, "", "", lineOffset).replace(' class="', ' class="page-break-marker ')} data-page-break="true">`;
} else if (/^\[PAGEBREAK\]$/i.test(line.trim())) html += `<hr${attrs(index, false, "", "", lineOffset).replace(' class="', ' class="page-break-marker ')} data-page-break="true">`;
else if (/^---+$/.test(line.trim())) html += `<hr${attrs(index, false, "", "", lineOffset)}>`;
else if (line.startsWith("> ")) html += `<blockquote${attrs(index, true, "> ", "", lineOffset)}>${inline(line.slice(2))}</blockquote>`;
else if (line.trim()) html += `<p${attrs(index, true, "", "", lineOffset)}>${inline(line)}</p>`;
else html += `<div${attrs(index, true, "", "", lineOffset)}><br></div>`;
+268 -45
View File
@@ -94,6 +94,89 @@ function mmToPx(doc) {
return pxPerMm;
}
function nextFrame(view) {
return new Promise(resolve => view.requestAnimationFrame(() => resolve()));
}
async function waitForPrintableLayout(doc) {
try { await doc.fonts?.ready; } catch { /* print with available fonts */ }
const images = [...doc.images];
if (images.length) {
const imageReady = Promise.all(images.map(image => {
if (image.complete) {
try { return image.decode?.() || Promise.resolve(); } catch { return Promise.resolve(); }
}
return new Promise(resolve => {
image.addEventListener("load", resolve, { once: true });
image.addEventListener("error", resolve, { once: true });
});
}));
await Promise.race([
imageReady,
new Promise(resolve => setTimeout(resolve, 1500)),
]);
}
await nextFrame(doc.defaultView);
await nextFrame(doc.defaultView);
}
function measureLastPageFill(doc, documentRoot, pageContentHeight) {
const content = documentRoot.querySelector(":scope > .pdf-content");
if (!content) return null;
const width = documentRoot.getBoundingClientRect().width;
if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(pageContentHeight) || pageContentHeight <= 0) return null;
const probe = doc.createElement("div");
probe.setAttribute("aria-hidden", "true");
probe.style.cssText = [
"position:absolute",
"visibility:hidden",
"pointer-events:none",
"top:0",
`left:${-(Math.ceil(width) + 200)}px`,
`width:${width}px`,
`height:${pageContentHeight}px`,
`column-width:${width}px`,
"column-gap:0",
"column-fill:auto",
"overflow:visible",
"box-sizing:border-box",
"z-index:-1",
].join(";");
const title = documentRoot.querySelector(":scope > .pdf-document-title");
if (title) probe.appendChild(title.cloneNode(true));
const flow = content.cloneNode(true);
flow.querySelectorAll(".pdf-page-break").forEach(pageBreak => {
pageBreak.style.breakAfter = "column";
pageBreak.style.pageBreakAfter = "auto";
});
probe.appendChild(flow);
const sentinel = doc.createElement("span");
sentinel.style.cssText = "display:inline-block;width:1px;height:1px;margin:0;padding:0;border:0;vertical-align:top;";
probe.appendChild(sentinel);
doc.body.appendChild(probe);
const probeRect = probe.getBoundingClientRect();
const sentinelRect = sentinel.getBoundingClientRect();
const fill = sentinelRect.bottom - probeRect.top;
probe.remove();
if (!Number.isFinite(fill) || fill < 0 || fill > pageContentHeight + 4) return null;
return Math.max(0, Math.min(pageContentHeight, fill));
}
function fallbackLastPageFill(documentRoot, spacer, pageContentHeight) {
const rootTop = documentRoot.getBoundingClientRect().top;
const contentEnd = Math.max(0, spacer.getBoundingClientRect().top - rootTop);
return ((contentEnd % pageContentHeight) + pageContentHeight) % pageContentHeight;
}
function alignFooterToLastPage(doc, pageHeightMm, marginMm) {
const documentRoot = doc.querySelector(".pdf-document");
const spacer = doc.querySelector(".pdf-footer-spacer");
@@ -107,38 +190,23 @@ function alignFooterToLastPage(doc, pageHeightMm, marginMm) {
const pageContentHeight = (Number(pageHeightMm) - (2 * Number(marginMm))) * pxPerMm;
const footerHeight = footer.getBoundingClientRect().height;
const footerMarginTop = Number.parseFloat(doc.defaultView?.getComputedStyle(footer).marginTop || "0") || 0;
const bottomGuard = 1.5 * pxPerMm;
const bottomGuard = 0.75 * pxPerMm;
if (!Number.isFinite(pageContentHeight) || pageContentHeight <= footerHeight || footerHeight <= 0) return;
const rootTop = documentRoot.getBoundingClientRect().top;
const footerTop = footer.getBoundingClientRect().top - rootTop;
const pageBreaks = documentRoot.querySelectorAll(".pdf-page-break");
const lastPageBreak = pageBreaks.length ? pageBreaks[pageBreaks.length - 1] : null;
const finalSegmentStart = lastPageBreak
? lastPageBreak.getBoundingClientRect().bottom - rootTop
: 0;
// Measure the end of document content, not the footer box. The footer's
// top margin may be reduced on a tight last page so it does not create an
// otherwise unnecessary extra page.
const contentEnd = Math.max(0, footerTop - footerMarginTop - finalSegmentStart);
const positionOnPage = ((contentEnd % pageContentHeight) + pageContentHeight) % pageContentHeight;
const available = pageContentHeight - positionOnPage;
const measuredFill = measureLastPageFill(doc, documentRoot, pageContentHeight);
const positionOnPage = measuredFill ?? fallbackLastPageFill(documentRoot, spacer, pageContentHeight);
const available = Math.max(0, pageContentHeight - positionOnPage);
let actualMarginTop = footerMarginTop;
let spacerHeight;
if (available >= footerHeight + bottomGuard) {
// Keep the normal footer gap when possible, but shrink it before moving
// the footer to another page. Leave a small guard against print rounding.
actualMarginTop = Math.min(
footerMarginTop,
Math.max(0, available - footerHeight - bottomGuard),
);
spacerHeight = available - footerHeight - actualMarginTop - bottomGuard;
} else {
// The footer itself cannot fit on the current page. Align it near the
// bottom of the next page while preserving its normal top gap.
spacerHeight = available + pageContentHeight - footerHeight - actualMarginTop - bottomGuard;
}
@@ -159,12 +227,19 @@ function preparePreview(preview) {
clone.querySelectorAll('[contenteditable="true"]').forEach(node => node.removeAttribute("contenteditable"));
clone.querySelectorAll(".image-alias-tools, .image-alias-resize-handle").forEach(node => node.remove());
clone.querySelectorAll("details").forEach(node => { node.open = true; });
clone.querySelectorAll("details").forEach(node => {
node.open = true;
const replacement = document.createElement("section");
[...node.attributes].forEach(attribute => replacement.setAttribute(attribute.name, attribute.value));
replacement.setAttribute("open", "");
while (node.firstChild) replacement.appendChild(node.firstChild);
node.replaceWith(replacement);
});
clone.querySelectorAll("input, button, select, textarea").forEach(node => { node.tabIndex = -1; });
if (clone.classList.contains("preview--raw")) {
clone.querySelectorAll(".preview-source-line").forEach(line => {
if (!/^---+$/.test(line.textContent.trim())) return;
if (!/^\[PAGEBREAK\]$/i.test(line.textContent.trim())) return;
const pageBreak = document.createElement("div");
pageBreak.className = "pdf-page-break";
pageBreak.setAttribute("aria-hidden", "true");
@@ -182,12 +257,23 @@ function preparePreview(preview) {
return clone;
}
function printStyles(cssSize, pageWidthMm, marginMm, preserveColors) {
function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, preserveColors) {
const pageContentWidthMm = Math.max(20, Number(pageWidthMm) - (2 * Number(marginMm)));
const pageContentHeightMm = Math.max(20, Number(pageHeightMm) - (2 * Number(marginMm)));
const atomicMaxHeightMm = Math.max(20, pageContentHeightMm - 8);
const mediaMaxWidthMm = atomicMaxHeightMm * (16 / 9);
return `
@page { size: ${cssSize}; margin: ${marginMm}mm; }
html { color-scheme: light; background: #fff !important; }
body { width: ${pageWidthMm - (2 * marginMm)}mm; margin: 0 !important; background: #fff !important; color: #16181d !important; }
.pdf-document { width: ${pageWidthMm - (2 * marginMm)}mm; box-sizing: border-box; }
body {
width: ${pageContentWidthMm}mm;
max-width: ${pageContentWidthMm}mm;
margin: 0 !important;
background: #fff !important;
color: #16181d !important;
}
.pdf-document { width: ${pageContentWidthMm}mm; box-sizing: border-box; }
.pdf-document-title { margin: 0 0 1.2em; padding: 0 0 .45em; border-bottom: 1px solid #d7dce2; }
.pdf-document-title h1 { margin: 0; font: 700 1.7rem/1.18 Inter, ui-sans-serif, system-ui, sans-serif; color: #111318; }
.pdf-footer-spacer {
@@ -197,8 +283,6 @@ function printStyles(cssSize, pageWidthMm, marginMm, preserveColors) {
margin: 0;
padding: 0;
border: 0;
break-after: avoid-page;
page-break-after: avoid;
}
.pdf-generated-footer {
box-sizing: border-box;
@@ -209,17 +293,156 @@ function printStyles(cssSize, pageWidthMm, marginMm, preserveColors) {
font: 500 7.5pt/1.2 Inter, ui-sans-serif, system-ui, sans-serif;
letter-spacing: .01em;
text-align: center;
break-before: avoid-page;
page-break-before: avoid;
break-inside: avoid;
break-inside: avoid-page;
page-break-inside: avoid;
}
.pdf-content { width: 100%; min-height: 0; margin: 0; padding: 0 !important; overflow: visible !important; color: #16181d !important; box-sizing: border-box; }
.pdf-content {
width: 100%;
min-height: 0;
margin: 0;
padding: 0 !important;
overflow: visible !important;
color: #16181d !important;
box-sizing: border-box;
}
.pdf-content .preview-source-line::before { display: none !important; content: none !important; }
.pdf-content .preview-source-line { min-height: 0; }
.pdf-content .preview-editable:hover,
.pdf-content .preview-editable:focus { background: transparent !important; box-shadow: none !important; }
.pdf-content a { color: inherit !important; text-decoration: underline; }
/* Book-like pagination: keep small semantic units together, not whole sections. */
.pdf-content h1,
.pdf-content h2,
.pdf-content h3,
.pdf-content h4,
.pdf-content h5,
.pdf-content h6,
.pdf-content .markdown-details > summary,
.pdf-content dt {
break-after: avoid-page;
page-break-after: avoid;
}
.pdf-content h1,
.pdf-content h2,
.pdf-content h3,
.pdf-content h4,
.pdf-content h5,
.pdf-content h6,
.pdf-content blockquote,
.pdf-content dd,
.pdf-content .footnotes li {
break-inside: avoid-page;
page-break-inside: avoid;
}
.pdf-content p,
.pdf-content li,
.pdf-content dd { orphans: 3; widows: 3; }
.pdf-content ul,
.pdf-content ol,
.pdf-content dl,
.pdf-content .table-wrap,
.pdf-content table,
.pdf-content .markdown-alert,
.pdf-content .markdown-details,
.pdf-content .markdown-details__content,
.pdf-content .footnotes,
.pdf-content pre {
break-inside: auto;
page-break-inside: auto;
}
.pdf-content li {
break-inside: avoid-page;
page-break-inside: avoid;
}
.pdf-content li:has(> ul),
.pdf-content li:has(> ol) {
break-inside: auto;
page-break-inside: auto;
}
.pdf-content .table-wrap { overflow: visible !important; }
.pdf-content table { width: 100% !important; }
.pdf-content thead { display: table-header-group; }
.pdf-content tfoot { display: table-footer-group; }
.pdf-content tr {
break-inside: avoid-page;
page-break-inside: avoid;
}
.pdf-content pre {
overflow: visible !important;
white-space: pre-wrap !important;
orphans: 3;
widows: 3;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.pdf-content pre code,
.pdf-content pre.code-with-lines .code-line {
white-space: pre-wrap !important;
overflow-wrap: anywhere;
word-break: break-word;
}
.pdf-content pre.code-with-lines .code-line {
break-inside: avoid-page;
page-break-inside: avoid;
}
.pdf-content .markdown-alert,
.pdf-content .markdown-details {
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
/* Atomic visual items are scaled to the printable page instead of being fragmented. */
.pdf-content img {
max-width: 100% !important;
max-height: ${atomicMaxHeightMm}mm !important;
width: auto;
height: auto !important;
object-fit: contain;
break-inside: avoid-page;
page-break-inside: avoid;
}
.pdf-content .markdown-alias-image--sized {
height: auto !important;
aspect-ratio: auto !important;
}
.pdf-content .markdown-alias-image--sized > img {
width: auto !important;
max-width: 100% !important;
height: auto !important;
}
.pdf-content .mermaid {
overflow: hidden !important;
break-inside: avoid-page;
page-break-inside: avoid;
}
.pdf-content .mermaid svg {
display: block;
width: auto !important;
max-width: 100% !important;
max-height: ${atomicMaxHeightMm}mm !important;
height: auto !important;
margin-inline: auto;
}
.pdf-content .rustpad-media {
width: min(100%, ${mediaMaxWidthMm}mm) !important;
max-width: 100% !important;
margin-inline: auto !important;
box-shadow: none !important;
break-inside: avoid-page;
page-break-inside: avoid;
}
.pdf-content .rustpad-media__player,
.pdf-content .rustpad-media__player iframe,
.pdf-content .rustpad-media__player video,
.pdf-content video.rustpad-media__player {
max-height: ${atomicMaxHeightMm}mm !important;
}
${preserveColors ? `
.pdf-document,
.pdf-document * { print-color-adjust: exact !important; -webkit-print-color-adjust: exact !important; }
@@ -263,17 +486,17 @@ function printStyles(cssSize, pageWidthMm, marginMm, preserveColors) {
.pdf-content .markdown-alert--danger { border-color: #a54b55 !important; background: rgba(155, 52, 67, .09) !important; }
.pdf-content .task-checkbox { accent-color: #6859b5 !important; }
` : ""}
.pdf-content pre { overflow: visible !important; white-space: pre-wrap !important; break-inside: avoid; page-break-inside: avoid; }
.pdf-content pre code,
.pdf-content pre.code-with-lines .code-line { white-space: pre-wrap !important; overflow-wrap: anywhere; word-break: break-word; }
.pdf-content table,
.pdf-content img,
.pdf-content svg,
.pdf-content blockquote { break-inside: avoid; page-break-inside: avoid; }
.pdf-content img,
.pdf-content svg { max-width: 100% !important; height: auto; }
.pdf-content .table-wrap { overflow: visible !important; }
.pdf-page-break { display: block; height: 0; margin: 0; padding: 0; border: 0; break-after: page; page-break-after: always; }
.pdf-page-break {
display: block;
width: 100%;
height: 0;
margin: 0;
padding: 0;
border: 0;
break-after: page;
page-break-after: always;
}
`;
}
@@ -290,7 +513,7 @@ function openPrintWindow({ preview, title, formatKey, marginMm, includeTitle, pr
: "";
const lang = escapeHtml(document.documentElement.lang || "en");
const base = escapeHtml(`${location.origin}/`);
const css = printStyles(format.cssSize, format.widthMm, safeMargin, preserveColors);
const css = printStyles(format.cssSize, format.widthMm, format.heightMm, safeMargin, preserveColors);
const footerText = escapeHtml(t("editor.pdfGeneratedFooter", {}, "Generated by RustPad"));
return (async () => {
@@ -301,11 +524,11 @@ function openPrintWindow({ preview, title, formatKey, marginMm, includeTitle, pr
printWindow.document.close();
const runPrint = async () => {
try { await printWindow.document.fonts?.ready; } catch { /* print with available fonts */ }
await new Promise(resolve => setTimeout(resolve, 120));
await waitForPrintableLayout(printWindow.document);
alignFooterToLastPage(printWindow.document, format.heightMm, safeMargin);
await new Promise(resolve => printWindow.requestAnimationFrame(() => resolve()));
await nextFrame(printWindow);
alignFooterToLastPage(printWindow.document, format.heightMm, safeMargin);
await nextFrame(printWindow);
printWindow.focus();
printWindow.print();
};