Files
rustpad/static/js/pdf-export.js
T

740 lines
27 KiB
JavaScript

/*
* Copyright (C) 2026 Mateusz Gruszczynski @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
import { t } from "@rustpad/i18n";
import { toast } from "@rustpad/toast";
import { loadHighlight, loadMermaid } from "@rustpad/vendor-libs";
const STORAGE_PREFIX = "rustpad:pdf-export:";
const PAGE_FORMATS = Object.freeze({
"a4-portrait": { cssSize: "A4 portrait", widthMm: 210, heightMm: 297 },
"a4-landscape": { cssSize: "A4 landscape", widthMm: 297, heightMm: 210 },
"a3-portrait": { cssSize: "A3 portrait", widthMm: 297, heightMm: 420 },
"a3-landscape": { cssSize: "A3 landscape", widthMm: 420, heightMm: 297 },
"a5-portrait": { cssSize: "A5 portrait", widthMm: 148, heightMm: 210 },
"letter-portrait": { cssSize: "letter portrait", widthMm: 215.9, heightMm: 279.4 },
"letter-landscape": { cssSize: "letter landscape", widthMm: 279.4, heightMm: 215.9 },
"screen-16-9": { cssSize: "338.667mm 190.5mm", widthMm: 338.667, heightMm: 190.5 },
"screen-4-3": { cssSize: "254mm 190.5mm", widthMm: 254, heightMm: 190.5 },
});
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function saved(key, fallback) {
try { return localStorage.getItem(`${STORAGE_PREFIX}${key}`) || fallback; } catch { return fallback; }
}
function save(key, value) {
try { localStorage.setItem(`${STORAGE_PREFIX}${key}`, String(value)); } catch { /* optional preference */ }
}
function stylesheetLinks() {
return [...document.querySelectorAll('link[rel="stylesheet"][href]')]
.map(link => `<link rel="stylesheet" href="${escapeHtml(link.href)}">`)
.join("");
}
async function ensureSyntaxHighlight(root) {
const nodes = [...root.querySelectorAll('pre code[class*="language-"]:not(.language-mermaid)')];
if (!nodes.length) return;
let hljs;
try { hljs = await loadHighlight(); } catch { return; }
const highlightText = (text, language) => {
try {
return language
? hljs.highlight(text, { language, ignoreIllegals: true }).value
: hljs.highlightAuto(text).value;
} catch {
return hljs.highlightAuto(text).value;
}
};
nodes.forEach(node => {
const language = [...node.classList].find(name => name.startsWith("language-"))?.slice(9);
const lines = [...node.querySelectorAll(".code-line")];
if (lines.length) {
lines.forEach(line => {
if (line.querySelector('[class*="hljs-"]')) return;
line.innerHTML = highlightText(line.textContent || "", language);
});
node.classList.add("hljs");
return;
}
if (!node.querySelector('[class*="hljs-"]')) {
node.innerHTML = highlightText(node.textContent || "", language);
}
node.classList.add("hljs");
});
}
function mmToPx(doc) {
const probe = doc.createElement("div");
probe.style.cssText = "position:absolute;visibility:hidden;pointer-events:none;width:100mm;height:100mm;";
doc.body.appendChild(probe);
const pxPerMm = probe.getBoundingClientRect().height / 100;
probe.remove();
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 isBlankPrintLine(node) {
if (!(node instanceof Element) || !node.classList.contains("preview-source-line")) return false;
if (node.textContent.trim()) return false;
return node.children.length === 1 && node.firstElementChild?.tagName === "BR";
}
function collapseHeadingGaps(root) {
root.querySelectorAll("h1, h2, h3, h4, h5, h6").forEach(heading => {
let next = heading.nextElementSibling;
while (isBlankPrintLine(next)) {
const remove = next;
next = next.nextElementSibling;
remove.remove();
}
});
}
function directPrintBlock(root, node) {
let block = node;
while (block?.parentElement && block.parentElement !== root) block = block.parentElement;
return block?.parentElement === root ? block : null;
}
function shortLeadBlock(node) {
if (!node) return false;
if (node.matches("h1, h2, h3, h4, h5, h6")) return true;
if (!node.matches("p")) return false;
const text = node.textContent.trim();
return text.length > 0 && text.length <= 320;
}
function previousNonBlank(node) {
let current = node?.previousElementSibling || null;
while (isBlankPrintLine(current)) current = current.previousElementSibling;
return current;
}
function groupAtomicVisuals(root) {
const blocks = new Set();
root.querySelectorAll(".mermaid, .rustpad-media").forEach(node => {
const block = directPrintBlock(root, node);
if (block) blocks.add(block);
});
root.querySelectorAll("img").forEach(node => {
const block = directPrintBlock(root, node);
if (block && !block.textContent.trim()) blocks.add(block);
});
for (const block of blocks) {
if (block.parentElement !== root) continue;
block.classList.add("pdf-atomic-visual");
let first = block;
const lead = previousNonBlank(block);
if (shortLeadBlock(lead)) {
first = lead;
if (lead.matches("p")) {
const heading = previousNonBlank(lead);
if (heading?.matches("h1, h2, h3, h4, h5, h6")) first = heading;
}
}
if (first === block) continue;
const wrapper = document.createElement("div");
wrapper.className = "pdf-keep-unit";
root.insertBefore(wrapper, first);
let current = first;
while (current) {
const next = current.nextElementSibling;
if (isBlankPrintLine(current)) current.remove();
else wrapper.appendChild(current);
if (current === block) break;
current = next;
}
}
}
function preparePrintPagination(root) {
collapseHeadingGaps(root);
groupAtomicVisuals(root);
}
async function ensurePrintableMermaid(root) {
const nodes = [...root.querySelectorAll(".mermaid[data-mermaid-source]")];
if (!nodes.length) return;
const originals = nodes.map(node => ({
node,
html: node.innerHTML,
processed: node.getAttribute("data-processed"),
}));
const staging = document.createElement("div");
staging.setAttribute("aria-hidden", "true");
staging.style.cssText = "position:fixed;left:-200vw;top:0;width:1200px;visibility:hidden;pointer-events:none;z-index:-1";
try {
const mermaid = await loadMermaid();
nodes.forEach(node => {
node.removeAttribute("data-processed");
node.textContent = node.dataset.mermaidSource || "";
});
staging.appendChild(root);
document.body.appendChild(staging);
mermaid.initialize({ startOnLoad: false, theme: "default", securityLevel: "strict" });
await mermaid.run({ nodes });
} catch {
originals.forEach(({ node, html, processed }) => {
node.innerHTML = html;
if (processed === null) node.removeAttribute("data-processed");
else node.setAttribute("data-processed", processed);
});
} finally {
if (root.parentElement === staging) staging.removeChild(root);
staging.remove();
}
}
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");
const footer = doc.querySelector(".pdf-generated-footer");
if (!documentRoot || !spacer || !footer) return;
spacer.style.height = "0px";
footer.style.removeProperty("margin-top");
const pxPerMm = mmToPx(doc);
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 = 0.75 * pxPerMm;
if (!Number.isFinite(pageContentHeight) || pageContentHeight <= footerHeight || footerHeight <= 0) return;
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) {
actualMarginTop = Math.min(
footerMarginTop,
Math.max(0, available - footerHeight - bottomGuard),
);
spacerHeight = available - footerHeight - actualMarginTop - bottomGuard;
} else {
spacerHeight = available + pageContentHeight - footerHeight - actualMarginTop - bottomGuard;
}
footer.style.marginTop = `${Math.max(0, actualMarginTop)}px`;
spacer.style.height = `${Math.max(0, spacerHeight)}px`;
}
function preparePreview(preview) {
const clone = preview.cloneNode(true);
clone.removeAttribute("id");
clone.classList.remove("preview");
clone.classList.add("pdf-content");
const computed = getComputedStyle(preview);
clone.style.fontFamily = computed.fontFamily;
clone.style.fontSize = computed.fontSize;
clone.style.lineHeight = computed.lineHeight;
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;
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 (!/^\[PAGEBREAK\]$/i.test(line.textContent.trim())) return;
const pageBreak = document.createElement("div");
pageBreak.className = "pdf-page-break";
pageBreak.setAttribute("aria-hidden", "true");
line.replaceWith(pageBreak);
});
}
clone.querySelectorAll("hr.page-break-marker, hr[data-page-break='true']").forEach(marker => {
const pageBreak = document.createElement("div");
pageBreak.className = "pdf-page-break";
pageBreak.setAttribute("aria-hidden", "true");
marker.replaceWith(pageBreak);
});
preparePrintPagination(clone);
return clone;
}
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 - 20);
const mediaMaxWidthMm = atomicMaxHeightMm * (16 / 9);
return `
@page { size: ${cssSize}; margin: ${marginMm}mm; }
html { color-scheme: light; background: #fff !important; }
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 {
display: block;
width: 100%;
height: 0;
margin: 0;
padding: 0;
border: 0;
}
.pdf-generated-footer {
box-sizing: border-box;
margin-top: 8mm;
padding-top: 1.5mm;
border-top: .2mm solid #e1e4e8;
color: #9299a3;
font: 500 7.5pt/1.2 Inter, ui-sans-serif, system-ui, sans-serif;
letter-spacing: .01em;
text-align: center;
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 .preview-source-line::before { display: none !important; content: none !important; }
.pdf-content .preview-source-line { min-height: 0; position: static !important; }
.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. */
/*
* Do not rely on break-after: avoid for the main Markdown headings.
* Chromium can first paint the heading at the end of the old page and
* then move it to the next page, leaving a clipped strip of glyphs behind.
* A small invisible reserve inside the heading keeps one following line
* with it without triggering that fragmentation bug.
*/
.pdf-content h1,
.pdf-content h2,
.pdf-content h3 {
break-after: auto;
page-break-after: auto;
padding-bottom: 1.15em;
margin-bottom: calc(.18em - 1.15em);
}
.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;
}
.pdf-content .pdf-keep-unit {
break-inside: avoid-page;
page-break-inside: avoid;
}
.pdf-content .pdf-keep-unit > :first-child { margin-top: 0 !important; }
.pdf-content .pdf-keep-unit > :last-child { margin-bottom: 0 !important; }
.pdf-content .pdf-atomic-visual {
break-before: avoid-page;
page-break-before: avoid;
}
/* 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; }
.pdf-content a { color: #5d50a5 !important; }
.pdf-content code:not(pre code) { background: #eeeaf7 !important; border-color: #bcb2a6 !important; color: #403a33 !important; }
.pdf-content pre { background: #efeae2 !important; border-color: #e2dcd3 !important; color: #413b34 !important; }
.pdf-content pre code,
.pdf-content .hljs { background: transparent !important; color: #403a33 !important; }
.pdf-content .hljs-keyword,
.pdf-content .hljs-selector-tag,
.pdf-content .hljs-literal,
.pdf-content .hljs-section,
.pdf-content .hljs-link { color: #7046a5 !important; }
.pdf-content .hljs-string,
.pdf-content .hljs-attr,
.pdf-content .hljs-regexp,
.pdf-content .hljs-template-tag,
.pdf-content .hljs-template-variable { color: #36704a !important; }
.pdf-content .hljs-number,
.pdf-content .hljs-symbol,
.pdf-content .hljs-bullet { color: #a54c25 !important; }
.pdf-content .hljs-title,
.pdf-content .hljs-title.class_,
.pdf-content .hljs-title.function_,
.pdf-content .hljs-built_in,
.pdf-content .hljs-type { color: #3568b8 !important; }
.pdf-content .hljs-variable,
.pdf-content .hljs-selector-id,
.pdf-content .hljs-selector-class,
.pdf-content .hljs-property,
.pdf-content .hljs-attribute { color: #9b3443 !important; }
.pdf-content .hljs-comment,
.pdf-content .hljs-quote { color: #756c61 !important; font-style: italic; }
.pdf-content .hljs-meta { color: #7e5d1d !important; }
.pdf-content blockquote { border-left-color: #6859b5 !important; color: #625a50 !important; }
.pdf-content mark { background: #f4e7be !important; color: #624700 !important; }
.pdf-content th { background: #f5f1ea !important; color: #29251f !important; }
.pdf-content .markdown-alert--success { border-color: #4c8064 !important; background: rgba(61, 121, 88, .10) !important; }
.pdf-content .markdown-alert--info { border-color: #56759c !important; background: rgba(68, 103, 148, .09) !important; }
.pdf-content .markdown-alert--warning { border-color: #a27831 !important; background: rgba(155, 112, 35, .11) !important; }
.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-page-break {
display: block;
width: 100%;
height: 0;
margin: 0;
padding: 0;
border: 0;
break-after: page;
page-break-after: always;
}
`;
}
function openPrintWindow({ preview, title, formatKey, marginMm, includeTitle, preserveColors }) {
const format = PAGE_FORMATS[formatKey] || PAGE_FORMATS["a4-portrait"];
const safeMargin = [8, 12, 20].includes(Number(marginMm)) ? Number(marginMm) : 12;
const printWindow = window.open("", "_blank");
if (!printWindow) throw new Error(t("editor.pdfPopupBlocked", {}, "The PDF window was blocked by the browser."));
try { printWindow.opener = null; } catch { /* same-origin hardening is best effort */ }
const content = preparePreview(preview);
const titleBlock = includeTitle && title
? `<header class="pdf-document-title"><h1>${escapeHtml(title)}</h1></header>`
: "";
const lang = escapeHtml(document.documentElement.lang || "en");
const base = escapeHtml(`${location.origin}/`);
const css = printStyles(format.cssSize, format.widthMm, format.heightMm, safeMargin, preserveColors);
const footerText = escapeHtml(t("editor.pdfGeneratedFooter", {}, "Generated by RustPad"));
return (async () => {
await ensurePrintableMermaid(content);
if (preserveColors) await ensureSyntaxHighlight(content);
printWindow.document.open();
printWindow.document.write(`<!doctype html><html lang="${lang}" data-theme="light"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><base href="${base}"><title>${escapeHtml(title || "RustPad")}</title>${stylesheetLinks()}<style>${css}</style></head><body><main class="pdf-document">${titleBlock}${content.outerHTML}<div class="pdf-footer-spacer" aria-hidden="true"></div><footer class="pdf-generated-footer" aria-hidden="true">${footerText}</footer></main></body></html>`);
printWindow.document.close();
const runPrint = async () => {
await waitForPrintableLayout(printWindow.document);
alignFooterToLastPage(printWindow.document, format.heightMm, safeMargin);
await nextFrame(printWindow);
alignFooterToLastPage(printWindow.document, format.heightMm, safeMargin);
await nextFrame(printWindow);
printWindow.focus();
printWindow.print();
};
printWindow.addEventListener("afterprint", () => printWindow.close(), { once: true });
if (printWindow.document.readyState === "complete") await runPrint();
else await new Promise(resolve => {
printWindow.addEventListener("load", async () => { await runPrint(); resolve(); }, { once: true });
});
})().catch(error => {
try { printWindow.close(); } catch { /* best effort */ }
throw error;
});
}
export function bindPdfExport({ button, dialog, form, preview, title }) {
if (!button || !dialog || !form || !preview) return;
const formatSelect = form.querySelector("#pdf-page-format");
const marginSelect = form.querySelector("#pdf-page-margin");
const includeTitle = form.querySelector("#pdf-include-title");
const preserveColors = form.querySelector("#pdf-preserve-colors");
const cancelButtons = form.querySelectorAll("[data-pdf-cancel]");
const savedFormat = saved("format", "a4-portrait");
formatSelect.value = Object.hasOwn(PAGE_FORMATS, savedFormat) ? savedFormat : "a4-portrait";
marginSelect.value = ["8", "12", "20"].includes(saved("margin", "12")) ? saved("margin", "12") : "12";
includeTitle.checked = saved("include-title", "off") === "on";
if (preserveColors) preserveColors.checked = saved("preserve-colors", "on") !== "off";
button.addEventListener("click", () => dialog.showModal());
cancelButtons.forEach(cancel => cancel.addEventListener("click", () => dialog.close("cancel")));
dialog.addEventListener("click", event => { if (event.target === dialog) dialog.close("cancel"); });
form.addEventListener("submit", event => {
event.preventDefault();
const formatKey = formatSelect.value;
const marginMm = Number(marginSelect.value);
save("format", formatKey);
save("margin", marginMm);
save("include-title", includeTitle.checked ? "on" : "off");
save("preserve-colors", preserveColors?.checked === false ? "off" : "on");
try {
const printJob = openPrintWindow({
preview,
title: typeof title === "function" ? title() : String(title || ""),
formatKey,
marginMm,
includeTitle: includeTitle.checked,
preserveColors: preserveColors?.checked !== false,
});
dialog.close("confirm");
void printJob.catch(error => {
toast.danger(error?.message || t("editor.pdfExportFailed", {}, "Could not prepare the PDF."), {
title: t("editor.exportPdf", {}, "Export PDF"),
});
});
} catch (error) {
toast.danger(error?.message || t("editor.pdfExportFailed", {}, "Could not prepare the PDF."), {
title: t("editor.exportPdf", {}, "Export PDF"),
});
}
});
}