This commit is contained in:
Mateusz Gruszczyński committed 2026-10-29 08:53:39 +01:00
1 parent 25d04daaee
commit b15a6c54ce
8 files changed
+214 -52

No files matched your search

+2 -7
View File
@@ -9186,7 +9186,7 @@ dialog::backdrop {
.pdf-export-fields {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(150px, .62fr);
grid-template-columns: 1fr;
gap: 12px;
}
@@ -9209,22 +9209,17 @@ dialog::backdrop {
gap: 9px;
}
.pdf-export-color-toggle,
.pdf-export-dark-toggle,
.pdf-export-title-toggle {
width: fit-content;
}
.pdf-export-color-help,
.pdf-export-help {
color: var(--muted);
font-size: .78rem;
line-height: 1.45;
}
.pdf-export-color-help {
margin: -6px 0 0 24px;
}
#pdf-export-dialog .dialog-actions .primary-button {
width: auto;
min-width: 132px;
+3 -5
View File
@@ -316,20 +316,18 @@
<option value="screen-4-3">4:3 — Presentation</option>
</select></label>
<label><span>Margins</span><select class="app-select-input" id="pdf-page-margin">
<option value="0">None (0 mm)</option>
<option value="8">Narrow (8 mm)</option>
<option value="12" selected>Normal (12 mm)</option>
<option value="20">Wide (20 mm)</option>
</select></label>
</div>
<div class="pdf-export-options">
<label class="public-task-toggle pdf-export-dark-toggle"><input id="pdf-dark-mode" type="checkbox"
checked> <span data-i18n="editor.darkPdf">Dark PDF</span></label>
<label class="public-task-toggle pdf-export-title-toggle"><input id="pdf-include-title" type="checkbox">
Include document title</label>
<label class="public-task-toggle pdf-export-color-toggle"><input id="pdf-preserve-colors"
type="checkbox"> <span data-i18n="editor.preservePdfColors">Preserve colors and
backgrounds</span></label>
</div>
<p class="pdf-export-color-help" data-i18n="editor.pdfColorHelp">Keeps syntax highlighting, alerts, quotes,
highlights, table headers, and other colors in the PDF.</p>
<p class="pdf-export-help">The browser print dialog will open. Choose Save as PDF.</p>
<div class="dialog-actions"><button class="secondary-button" value="cancel" type="button"
data-pdf-cancel>Cancel</button><button id="generate-pdf" class="primary-button"
+196 -32
View File
@@ -201,12 +201,79 @@ function groupAtomicVisuals(root) {
}
}
function preparePrintPagination(root) {
collapseHeadingGaps(root);
groupAtomicVisuals(root);
function isShortList(node) {
if (!node?.matches?.("ul, ol")) return false;
const items = [...node.children].filter(child => child.matches?.("li"));
if (!items.length || items.length > 8) return false;
if (node.querySelector(":scope > li > ul, :scope > li > ol")) return false;
return node.textContent.trim().length <= 900;
}
async function ensurePrintableMermaid(root) {
function isShortCodeBlock(node) {
if (!node?.matches?.("pre")) return false;
const text = node.textContent || "";
const lines = text ? text.split(/\r?\n/).length : 0;
return lines > 0 && lines <= 12 && text.length <= 1400;
}
function markShortKeepBlocks(root) {
root.querySelectorAll("ul, ol").forEach(list => {
if (isShortList(list)) list.classList.add("pdf-keep-short");
});
root.querySelectorAll("pre").forEach(pre => {
if (isShortCodeBlock(pre)) pre.classList.add("pdf-keep-short");
});
}
function isHeadingLead(node) {
if (!node || node.classList?.contains("pdf-page-break")) return false;
if (node.matches?.("p, blockquote")) {
const text = node.textContent.trim();
return text.length > 0 && text.length <= 520;
}
return isShortList(node) || isShortCodeBlock(node);
}
function groupHeadingsWithLead(root) {
const headings = [...root.querySelectorAll(":scope > h1, :scope > h2, :scope > h3, :scope > h4, :scope > h5, :scope > h6")];
headings.forEach(heading => {
if (heading.parentElement !== root) return;
let lead = heading.nextElementSibling;
while (isBlankPrintLine(lead)) {
const remove = lead;
lead = lead.nextElementSibling;
remove.remove();
}
if (!isHeadingLead(lead)) return;
const wrapper = document.createElement("div");
wrapper.className = "pdf-keep-unit pdf-heading-lead";
root.insertBefore(wrapper, heading);
wrapper.appendChild(heading);
wrapper.appendChild(lead);
if (lead.matches?.("p, blockquote")) {
let next = wrapper.nextElementSibling;
while (isBlankPrintLine(next)) {
const remove = next;
next = next.nextElementSibling;
remove.remove();
}
if (isShortList(next) || isShortCodeBlock(next)) wrapper.appendChild(next);
}
});
}
function preparePrintPagination(root) {
collapseHeadingGaps(root);
markShortKeepBlocks(root);
groupAtomicVisuals(root);
groupHeadingsWithLead(root);
}
async function ensurePrintableMermaid(root, darkMode = false) {
const nodes = [...root.querySelectorAll(".mermaid[data-mermaid-source]")];
if (!nodes.length) return;
@@ -228,7 +295,7 @@ async function ensurePrintableMermaid(root) {
staging.appendChild(root);
document.body.appendChild(staging);
mermaid.initialize({ startOnLoad: false, theme: "default", securityLevel: "strict" });
mermaid.initialize({ startOnLoad: false, theme: darkMode ? "dark" : "default", securityLevel: "strict" });
await mermaid.run({ nodes });
} catch {
originals.forEach(({ node, html, processed }) => {
@@ -378,25 +445,46 @@ function preparePreview(preview) {
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)));
function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, darkMode) {
const safeMargin = Math.max(0, Number(marginMm) || 0);
const pageContentWidthMm = Math.max(20, Number(pageWidthMm) - (2 * safeMargin));
const pageContentHeightMm = Math.max(20, Number(pageHeightMm) - (2 * safeMargin));
const atomicMaxHeightMm = Math.max(20, pageContentHeightMm - 20);
const mediaMaxWidthMm = atomicMaxHeightMm * (16 / 9);
const pageBackground = darkMode ? "#15181d" : "#fff";
const textColor = darkMode ? "#e6e9ed" : "#16181d";
const headingColor = darkMode ? "#f1f3f5" : "#111318";
const borderColor = darkMode ? "#3a414b" : "#d7dce2";
const footerColor = darkMode ? "#aeb5bf" : "#9299a3";
const colorScheme = darkMode ? "dark" : "light";
return `
@page { size: ${cssSize}; margin: ${marginMm}mm; }
html { color-scheme: light; background: #fff !important; }
@page { size: ${cssSize}; margin: ${safeMargin}mm; background: ${pageBackground}; }
html {
color-scheme: ${colorScheme};
background: ${pageBackground} !important;
print-color-adjust: exact !important;
-webkit-print-color-adjust: exact !important;
}
body {
width: ${pageContentWidthMm}mm;
max-width: ${pageContentWidthMm}mm;
min-height: 100vh;
margin: 0 !important;
background: #fff !important;
color: #16181d !important;
background: ${pageBackground} !important;
color: ${textColor} !important;
print-color-adjust: exact !important;
-webkit-print-color-adjust: exact !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-document {
width: ${pageContentWidthMm}mm;
min-height: ${pageContentHeightMm}mm;
box-sizing: border-box;
background: ${pageBackground} !important;
color: ${textColor} !important;
}
.pdf-document-title { margin: 0 0 1.2em; padding: 0 0 .45em; border-bottom: 1px solid ${borderColor}; }
.pdf-document-title h1 { margin: 0; font: 700 1.7rem/1.18 Inter, ui-sans-serif, system-ui, sans-serif; color: ${headingColor}; }
.pdf-footer-spacer {
display: block;
width: 100%;
@@ -409,8 +497,8 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, preserveColor
box-sizing: border-box;
margin-top: 8mm;
padding-top: 1.5mm;
border-top: .2mm solid #e1e4e8;
color: #9299a3;
border-top: .2mm solid ${borderColor};
color: ${footerColor};
font: 500 7.5pt/1.2 Inter, ui-sans-serif, system-ui, sans-serif;
letter-spacing: .01em;
text-align: center;
@@ -423,7 +511,7 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, preserveColor
margin: 0;
padding: 0 !important;
overflow: visible !important;
color: #16181d !important;
color: ${textColor} !important;
box-sizing: border-box;
}
.pdf-content .preview-source-line::before { display: none !important; content: none !important; }
@@ -471,6 +559,10 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, preserveColor
.pdf-content p,
.pdf-content li,
.pdf-content dd { orphans: 3; widows: 3; }
.pdf-content p {
break-inside: avoid-page;
page-break-inside: avoid;
}
.pdf-content ul,
.pdf-content ol,
@@ -545,6 +637,16 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, preserveColor
}
.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-keep-short {
break-inside: avoid-page !important;
page-break-inside: avoid !important;
}
.pdf-content .pdf-heading-lead > h1,
.pdf-content .pdf-heading-lead > h2,
.pdf-content .pdf-heading-lead > h3 {
padding-bottom: 0;
margin-bottom: .18em;
}
.pdf-content .pdf-atomic-visual {
display: inline-block;
width: 100%;
@@ -563,6 +665,24 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, preserveColor
object-fit: contain;
break-inside: avoid-page;
page-break-inside: avoid;
/* Dark PDF changes the document theme only. Never recolor raster images. */
filter: none !important;
-webkit-filter: none !important;
opacity: 1 !important;
mix-blend-mode: normal !important;
forced-color-adjust: none !important;
color-scheme: normal !important;
}
.pdf-content picture,
.pdf-content picture > img,
.pdf-content video,
.pdf-content canvas {
filter: none !important;
-webkit-filter: none !important;
opacity: 1 !important;
mix-blend-mode: normal !important;
forced-color-adjust: none !important;
color-scheme: normal !important;
}
.pdf-content .markdown-alias-image--sized {
height: auto !important;
@@ -601,9 +721,50 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, preserveColor
max-height: ${atomicMaxHeightMm}mm !important;
}
${preserveColors ? `
.pdf-document,
.pdf-document * { print-color-adjust: exact !important; -webkit-print-color-adjust: exact !important; }
${darkMode ? `
.pdf-content a { color: #b9b4d8 !important; }
.pdf-content code:not(pre code) { background: #242931 !important; border-color: #414852 !important; color: #e2e5e9 !important; }
.pdf-content pre { background: #1b1f25 !important; border-color: #3a414b !important; color: #d2d6dc !important; }
.pdf-content pre code,
.pdf-content .hljs { background: transparent !important; color: #d2d6dc !important; }
.pdf-content .hljs-keyword,
.pdf-content .hljs-selector-tag,
.pdf-content .hljs-literal,
.pdf-content .hljs-section,
.pdf-content .hljs-link { color: #c2acd0 !important; }
.pdf-content .hljs-string,
.pdf-content .hljs-attr,
.pdf-content .hljs-regexp,
.pdf-content .hljs-template-tag,
.pdf-content .hljs-template-variable { color: #b8c9a1 !important; }
.pdf-content .hljs-number,
.pdf-content .hljs-symbol,
.pdf-content .hljs-bullet { color: #d8a08e !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: #9fb6d8 !important; }
.pdf-content .hljs-variable,
.pdf-content .hljs-selector-id,
.pdf-content .hljs-selector-class,
.pdf-content .hljs-property,
.pdf-content .hljs-attribute { color: #d39ca2 !important; }
.pdf-content .hljs-comment,
.pdf-content .hljs-quote { color: #9299a3 !important; font-style: italic; }
.pdf-content .hljs-meta { color: #cdbb8f !important; }
.pdf-content blockquote { border-left-color: #85818f !important; color: #cfd3d8 !important; }
.pdf-content mark { background: #5b5338 !important; color: #eee5b8 !important; }
.pdf-content th { background: #242930 !important; color: #e1e4e8 !important; }
.pdf-content .markdown-alert--success { border-color: #63806f !important; background: rgba(99, 128, 111, .12) !important; }
.pdf-content .markdown-alert--info { border-color: #6e8198 !important; background: rgba(110, 129, 152, .12) !important; }
.pdf-content .markdown-alert--warning { border-color: #9a8968 !important; background: rgba(154, 137, 104, .12) !important; }
.pdf-content .markdown-alert--danger { border-color: #956f73 !important; background: rgba(149, 111, 115, .12) !important; }
.pdf-content .task-checkbox { accent-color: #8c8798 !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; }
@@ -643,7 +804,7 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, preserveColor
.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;
@@ -658,9 +819,9 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, preserveColor
`;
}
function openPrintWindow({ preview, title, formatKey, marginMm, includeTitle, preserveColors }) {
function openPrintWindow({ preview, title, formatKey, marginMm, includeTitle, darkMode }) {
const format = PAGE_FORMATS[formatKey] || PAGE_FORMATS["a4-portrait"];
const safeMargin = [8, 12, 20].includes(Number(marginMm)) ? Number(marginMm) : 12;
const safeMargin = [0, 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 */ }
@@ -671,15 +832,15 @@ 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, format.heightMm, safeMargin, preserveColors);
const css = printStyles(format.cssSize, format.widthMm, format.heightMm, safeMargin, darkMode);
const footerText = escapeHtml(t("editor.pdfGeneratedFooter", {}, "Generated by RustPad"));
return (async () => {
await ensurePrintableMermaid(content);
if (preserveColors) await ensureSyntaxHighlight(content);
await ensurePrintableMermaid(content, darkMode);
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.write(`<!doctype html><html lang="${lang}" data-theme="${darkMode ? "dark" : "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 () => {
@@ -707,15 +868,18 @@ 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 darkMode = form.querySelector("#pdf-dark-mode");
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";
if (marginSelect) {
const savedMargin = saved("margin", "12");
marginSelect.value = ["0", "8", "12", "20"].includes(savedMargin) ? savedMargin : "12";
}
if (darkMode) darkMode.checked = saved("dark-mode", "on") !== "off";
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")));
@@ -724,11 +888,11 @@ export function bindPdfExport({ button, dialog, form, preview, title }) {
form.addEventListener("submit", event => {
event.preventDefault();
const formatKey = formatSelect.value;
const marginMm = Number(marginSelect.value);
const marginMm = Number(marginSelect?.value ?? 12);
save("format", formatKey);
save("margin", marginMm);
save("dark-mode", darkMode?.checked === false ? "off" : "on");
save("include-title", includeTitle.checked ? "on" : "off");
save("preserve-colors", preserveColors?.checked === false ? "off" : "on");
try {
const printJob = openPrintWindow({
preview,
@@ -736,7 +900,7 @@ export function bindPdfExport({ button, dialog, form, preview, title }) {
formatKey,
marginMm,
includeTitle: includeTitle.checked,
preserveColors: preserveColors?.checked !== false,
darkMode: darkMode?.checked !== false,
});
dialog.close("confirm");
void printJob.catch(error => {