diff --git a/Cargo.lock b/Cargo.lock index a375ae0..873ff3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.69" +version = "0.2.72" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index cb42325..afe2a3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.2.69" +version = "0.2.72" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/README.md b/README.md index c7a06a8..55375e6 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,13 @@ The selected interface language is stored in the browser and can be changed from - Alert blocks: `success`, `info`, `warning`, and `danger`. - Table of contents generated with `[TOC]`, using headings after the marker and nesting them by level. - Optional line numbers in fenced code blocks. +- PDF export with selectable A5/A4/A3, Letter, 16:9, and 4:3 page formats; optional color/background preservation keeps syntax highlighting, alerts, quotes, highlights, and table styling; `---` creates a hard PDF page break. + +## PDF export and page breaks + +Use the **PDF** button in the editor header to choose the page format and margins, then generate a browser-native PDF print view. The export supports A5/A4/A3, Letter, 16:9, and 4:3 layouts. Enable **Preserve colors and backgrounds** to keep syntax highlighting, alerts, quotes, highlighted text, table headers, inline code, links, and other styled Markdown elements. The exporter re-applies Highlight.js to the print copy and uses a print-safe light color palette so code colors do not depend on the editor theme. The end of the exported document includes one subtle localized RustPad footer. + +A Markdown line containing only `---` is treated as a page break during PDF export. Insert it from **More → Page break (PDF)** or with `Ctrl/Cmd+Enter`. In the editor preview it remains visible as a dashed horizontal divider. ## Fenced code blocks and language aliases diff --git a/lang/en.json b/lang/en.json index 1cadf90..ffc219b 100644 --- a/lang/en.json +++ b/lang/en.json @@ -366,6 +366,32 @@ "editor.enablePage": "Enable Page", "editor.enablePageTitle": "Enable or disable the published page", "editor.extendedMarkdown": "Extended Markdown", + "editor.pdfPopupBlocked": "The PDF window was blocked by the browser.", + "editor.pdfExportFailed": "Could not prepare the PDF.", + "editor.a4Portrait": "A4 — Portrait", + "editor.a4Landscape": "A4 — Landscape", + "editor.a3Portrait": "A3 — Portrait", + "editor.a3Landscape": "A3 — Landscape", + "editor.a5Portrait": "A5 — Portrait", + "editor.letterPortrait": "Letter — Portrait", + "editor.letterLandscape": "Letter — Landscape", + "editor.exportPdf": "Export PDF", + "editor.generatePdf": "Generate PDF", + "editor.includeDocumentTitle": "Include document title", + "editor.margins": "Margins", + "editor.narrowMargin": "Narrow (8 mm)", + "editor.normalMargin": "Normal (12 mm)", + "editor.pageBreak": "Page break (PDF)", + "editor.pageBreakShortcut": "Page break (PDF) · Ctrl/Cmd+Enter", + "editor.pageFormat": "Page format", + "editor.pdfPageBreakHelp": "Use --- or Ctrl/Cmd+Enter to start a new PDF page.", + "editor.preservePdfColors": "Preserve colors and backgrounds", + "editor.pdfColorHelp": "Keeps syntax highlighting, alerts, quotes, highlights, table headers, and other colors in the PDF.", + "editor.pdfPrintHelp": "The browser print dialog will open. Choose Save as PDF.", + "editor.pdfGeneratedFooter": "Generated by RustPad", + "editor.presentation16x9": "16:9 — Presentation", + "editor.presentation4x3": "4:3 — Presentation", + "editor.wideMargin": "Wide (20 mm)", "editor.failedMermaid": "Failed to load Mermaid.", "editor.fileCount": "{count} files", "editor.font": "Font", diff --git a/lang/pl.json b/lang/pl.json index 8ba3252..f3a2731 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -366,6 +366,32 @@ "editor.enablePage": "Włącz stronę", "editor.enablePageTitle": "Włącz lub wyłącz opublikowaną stronę", "editor.extendedMarkdown": "Rozszerzony Markdown", + "editor.pdfPopupBlocked": "Przeglądarka zablokowała okno eksportu PDF.", + "editor.pdfExportFailed": "Nie udało się przygotować PDF.", + "editor.a4Portrait": "A4 — pionowo", + "editor.a4Landscape": "A4 — poziomo", + "editor.a3Portrait": "A3 — pionowo", + "editor.a3Landscape": "A3 — poziomo", + "editor.a5Portrait": "A5 — pionowo", + "editor.letterPortrait": "Letter — pionowo", + "editor.letterLandscape": "Letter — poziomo", + "editor.exportPdf": "Eksport PDF", + "editor.generatePdf": "Generuj PDF", + "editor.includeDocumentTitle": "Dołącz tytuł dokumentu", + "editor.margins": "Marginesy", + "editor.narrowMargin": "Wąskie (8 mm)", + "editor.normalMargin": "Normalne (12 mm)", + "editor.pageBreak": "Podział strony (PDF)", + "editor.pageBreakShortcut": "Podział strony (PDF) · Ctrl/Cmd+Enter", + "editor.pageFormat": "Format strony", + "editor.pdfPageBreakHelp": "Użyj --- lub Ctrl/Cmd+Enter, aby rozpocząć nową stronę PDF.", + "editor.preservePdfColors": "Zachowaj kolory i tła", + "editor.pdfColorHelp": "Zachowuje w PDF kolorowanie składni, alerty, cytaty, wyróżnienia, nagłówki tabel i inne kolory.", + "editor.pdfPrintHelp": "Otworzy się okno drukowania przeglądarki. Wybierz Zapisz jako PDF.", + "editor.pdfGeneratedFooter": "Wygenerowano przez RustPad", + "editor.presentation16x9": "16:9 — prezentacja", + "editor.presentation4x3": "4:3 — prezentacja", + "editor.wideMargin": "Szerokie (20 mm)", "editor.failedMermaid": "Nie udało się załadować Mermaid.", "editor.fileCount": "{count} plików", "editor.font": "Czcionka", diff --git a/src/assets.rs b/src/assets.rs index a351676..cbf72c2 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -34,6 +34,7 @@ const MODULES: &[&str] = &[ "note-files", "preview-edit", "preferences", + "pdf-export", "render-queue", "session", "socket", diff --git a/static/css/styles.css b/static/css/styles.css index 3870e5c..152b1d7 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -1092,6 +1092,7 @@ textarea::selection { padding: 0; border: 0; background: transparent; + line-height: inherit; } .markdown-body blockquote { @@ -1110,6 +1111,10 @@ textarea::selection { border-top: 1px solid var(--border); } +.markdown-body hr.page-break-marker { + border-top-style: dashed; +} + .editor-footer { display: flex; align-items: center; @@ -3326,7 +3331,7 @@ dialog::backdrop { .preview-source-line::before { content: attr(data-source-line); position: absolute; - top: 0; + top: var(--preview-line-top, 0px); left: var(--preview-line-left, -50px); width: 32px; color: var(--gutter-text); @@ -5358,14 +5363,16 @@ dialog::backdrop { } .markdown-body pre.code-with-lines code { + display: block; counter-reset: none; } .markdown-body pre.code-with-lines .code-line { display: block; - min-height: 1.35em; + min-height: 1em; padding-left: 4.6em; position: relative; + line-height: inherit; white-space: pre; } @@ -7993,7 +8000,7 @@ dialog::backdrop { .public-content .public-permalink { position: absolute; - top: 0; + top: var(--preview-line-top, 0px); left: var(--preview-line-left, -50px); z-index: 3; display: none; @@ -8471,4 +8478,82 @@ dialog::backdrop { .public-header__actions .header-preferences { margin-left: auto; } -} \ No newline at end of file +} + +/* PDF export */ +#pdf-export-dialog { + width: min(520px, calc(100% - 28px)); + max-width: 520px; +} + +.pdf-export-panel { + position: relative; + gap: 16px; +} + +.pdf-export-panel h2, +.pdf-export-panel .dialog-copy, +.pdf-export-panel .pdf-export-help { + margin: 0; +} + +.pdf-export-fields { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(150px, .62fr); + gap: 12px; +} + +.pdf-export-fields label { + display: grid; + gap: 7px; + min-width: 0; + color: var(--text-secondary); + font-size: .82rem; + font-weight: 650; +} + +.pdf-export-fields .app-select-input, +.pdf-export-fields .app-select { + width: 100%; +} + +.pdf-export-options { + display: grid; + gap: 9px; +} + +.pdf-export-title-toggle, +.pdf-export-color-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; + margin: 0; +} + +@media (max-width: 560px) { + .pdf-export-fields { + grid-template-columns: 1fr; + } + + #pdf-export-dialog .dialog-actions { + align-items: stretch; + } + + #pdf-export-dialog .dialog-actions > button { + flex: 1 1 0; + } +} diff --git a/static/editor.html b/static/editor.html index a66d9c1..a30b859 100644 --- a/static/editor.html +++ b/static/editor.html @@ -42,7 +42,7 @@
+ link
PageDefinition
+ data-format="superscript">Superscript +

Export PDF

+

Use --- or Ctrl/Cmd+Enter to start a new PDF page.

+
+ + +
+
+ + +
+

Keeps syntax highlighting, alerts, quotes, highlights, table headers, and other colors in the PDF.

+

The browser print dialog will open. Choose Save as PDF.

+
+ +
@@ -283,7 +314,7 @@ changeCtrl/Cmd+Shift+ZRedoCtrl/Cmd+BBoldCtrl/Cmd+IItalicCtrl/Cmd+Shift+XStrikethroughCtrl/Cmd+KLinkCtrl/Cmd+Shift+7Numbered listCtrl/Cmd+Shift+8Bullet listCtrl/Cmd+Shift+9Task listAlt+1…4Headings H1–H4TabIndent by 2 - spacesShift+TabRemove indentation__EXTRA_SHORTCUTS__ + spacesShift+TabRemove indentationCtrl/Cmd+EnterPage break (PDF)__EXTRA_SHORTCUTS__
diff --git a/static/js/editor-format.js b/static/js/editor-format.js index 4cb25ff..871282c 100644 --- a/static/js/editor-format.js +++ b/static/js/editor-format.js @@ -44,6 +44,16 @@ function toggleWrap(editor, before, after = before, placeholder = t("editor.form if (!selected) editor.setSelectionRange(start + before.length, start + before.length + text.length); } + +export function insertPageBreak(editor) { + 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" : ""}`; + editor.setRangeText(marker, start, end, "end"); +} + function togglePrefix(editor, prefixFactory) { const { start, end } = selection(editor); const lineStart = editor.value.lastIndexOf("\n", start - 1) + 1; @@ -88,7 +98,7 @@ 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 === "horizontal-rule") toggleWrap(editor, "\n---\n", "", ""); + if (format === "page-break" || format === "horizontal-rule") insertPageBreak(editor); editor.focus(); editor.dispatchEvent(new Event("input", { bubbles: true })); } @@ -104,6 +114,7 @@ export function bindFormatShortcuts(editor) { else if (primary && !event.shiftKey && event.key.toLowerCase() === "k") format = "link"; else if (primary && event.shiftKey && event.key === "7") format = "number"; else if (primary && event.shiftKey && event.key === "8") format = "bullet"; + else if (primary && !event.shiftKey && event.key === "Enter") format = "page-break"; else if (primary && event.shiftKey && event.key === "9") format = "task"; else if (event.altKey && /^[1-4]$/.test(event.key)) format = `heading${event.key}`; if (!format) return; diff --git a/static/js/markdown.js b/static/js/markdown.js index ce9ef06..4c15995 100644 --- a/static/js/markdown.js +++ b/static/js/markdown.js @@ -360,14 +360,43 @@ function renderTableOfContentsList(nodes) { } +function firstRenderedTextRect(element) { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let node; + while ((node = walker.nextNode())) { + const match = node.nodeValue?.match(/\S/); + if (!match) continue; + const range = document.createRange(); + const codePoint = node.nodeValue.codePointAt(match.index); + const characterLength = codePoint > 0xffff ? 2 : 1; + range.setStart(node, match.index); + range.setEnd(node, match.index + characterLength); + const rect = range.getClientRects()[0]; + range.detach?.(); + if (rect?.width || rect?.height) return rect; + } + return null; +} + export function alignPreviewLineNumbers(root) { if (!root) return; const styles = getComputedStyle(root); const targetLeft = parseFloat(styles.paddingLeft || "0") - 50; const rootLeft = root.getBoundingClientRect().left; root.querySelectorAll(".preview-source-line").forEach(line => { - const lineLeft = line.getBoundingClientRect().left - rootLeft + root.scrollLeft; + const lineRect = line.getBoundingClientRect(); + const lineLeft = lineRect.left - rootLeft + root.scrollLeft; line.style.setProperty("--preview-line-left", `${targetLeft - lineLeft}px`); + + const textRect = firstRenderedTextRect(line); + const lineStyles = getComputedStyle(line); + const gutterStyles = getComputedStyle(line, "::before"); + const gutterLineHeight = parseFloat(gutterStyles.lineHeight) || parseFloat(gutterStyles.fontSize) || 0; + const textLineHeight = textRect?.height || 0; + const borderTop = parseFloat(lineStyles.borderTopWidth) || 0; + const textTop = textRect ? textRect.top - lineRect.top - borderTop : 0; + const gutterTop = Math.max(0, textTop + (textLineHeight - gutterLineHeight) / 2); + line.style.setProperty("--preview-line-top", `${gutterTop}px`); }); } @@ -399,7 +428,7 @@ export function renderMarkdown(source, lineOffset = 0) { if (lang === "mermaid") { html += `
${body}
`; } else if (codeLineStart !== null) { - const numbered = body.split("\n").map((line, index) => `${line || " "}`).join("\n"); + const numbered = body.split("\n").map((line, index) => `${line || " "}`).join(""); const languageClass = lang ? ` class="language-${escapeHtml(lang)}"` : ""; html += `${numbered}`; } else { @@ -524,7 +553,7 @@ export function renderMarkdown(source, lineOffset = 0) { html += `
${inline(lines[index].replace(/^:\s+/, ""))}
`; } html += ``; - } else if (/^---+$/.test(line.trim())) html += ``; + } else if (/^---+$/.test(line.trim())) html += ``; else if (line.startsWith("> ")) html += ` ", "", lineOffset)}>${inline(line.slice(2))}`; else if (line.trim()) html += `${inline(line)}

`; else html += `
`; diff --git a/static/js/note-editor.js b/static/js/note-editor.js index 8c35808..3223f33 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -20,6 +20,7 @@ import { applyFormat, bindFormatShortcuts, bindIndentationShortcuts } from "@rus import { bindEmojiPicker } from "@rustpad/emoji-picker"; import { updateImageAliasInLineBySource } from "@rustpad/image-alias"; import { previewEditingHost } from "@rustpad/preview-edit"; +import { bindPdfExport } from "@rustpad/pdf-export"; import { createRenderQueue } from "@rustpad/render-queue"; import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles, unresolvedMarkdownFileAliases } from "@rustpad/markdown"; import { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session"; @@ -957,6 +958,7 @@ export function startNoteEditor(adapter) { if (primary && !event.shiftKey && event.key.toLowerCase() === "k") return "link"; if (primary && event.shiftKey && event.key === "7") return "number"; if (primary && event.shiftKey && event.key === "8") return "bullet"; + if (primary && !event.shiftKey && event.key === "Enter") return "page-break"; if (primary && event.shiftKey && event.key === "9") return "task"; if (event.altKey && /^[1-4]$/.test(event.key)) return `heading${event.key}`; return null; @@ -2051,6 +2053,13 @@ export function startNoteEditor(adapter) { }); bindFormatShortcuts(editor); bindIndentationShortcuts(editor, { size: 2 }); + bindPdfExport({ + button: document.querySelector("#export-pdf"), + dialog: document.querySelector("#pdf-export-dialog"), + form: document.querySelector("#pdf-export-form"), + preview, + title: () => document.querySelector("#document-title")?.textContent?.trim() || document.title, + }); bindEmojiPicker({ editor, details: document.querySelector("#emoji-picker"), search: document.querySelector("#emoji-search"), categories: document.querySelector("#emoji-categories"), grid: document.querySelector("#emoji-grid"), empty: document.querySelector("#emoji-empty") }); document.querySelector("#shortcuts-button").addEventListener("click", () => document.querySelector("#shortcuts-dialog").showModal()); document.querySelector("#close-shortcuts").addEventListener("click", () => document.querySelector("#shortcuts-dialog").close()); diff --git a/static/js/pdf-export.js b/static/js/pdf-export.js new file mode 100644 index 0000000..164a7b9 --- /dev/null +++ b/static/js/pdf-export.js @@ -0,0 +1,345 @@ +/* + * 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 } 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("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +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 => ``) + .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 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"; + + const pxPerMm = mmToPx(doc); + const pageContentHeight = (Number(pageHeightMm) - (2 * Number(marginMm))) * pxPerMm; + const footerHeight = footer.getBoundingClientRect().height; + 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; + + // A forced break starts a fresh page, so only the final segment determines + // where the one-time footer lands on the last printed page. + const finalSegmentHeight = Math.max(0, footerTop - finalSegmentStart); + const positionOnPage = ((finalSegmentHeight % pageContentHeight) + pageContentHeight) % pageContentHeight; + let spacerHeight = pageContentHeight - positionOnPage - footerHeight; + if (spacerHeight < -0.25) spacerHeight += pageContentHeight; + 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; }); + 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; + 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); + }); + + return clone; +} + +function printStyles(cssSize, pageWidthMm, marginMm, preserveColors) { + 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; } + .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-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; } + .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; } + ${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-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; } + `; +} + +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 + ? `

${escapeHtml(title)}

` + : ""; + const lang = escapeHtml(document.documentElement.lang || "en"); + const base = escapeHtml(`${location.origin}/`); + const css = printStyles(format.cssSize, format.widthMm, safeMargin, preserveColors); + const footerText = escapeHtml(t("editor.pdfGeneratedFooter", {}, "Generated by RustPad")); + + return (async () => { + if (preserveColors) await ensureSyntaxHighlight(content); + + printWindow.document.open(); + printWindow.document.write(`${escapeHtml(title || "RustPad")}${stylesheetLinks()}
${titleBlock}${content.outerHTML}
`); + printWindow.document.close(); + + const runPrint = async () => { + try { await printWindow.document.fonts?.ready; } catch { /* print with available fonts */ } + await new Promise(resolve => setTimeout(resolve, 120)); + alignFooterToLastPage(printWindow.document, format.heightMm, safeMargin); + await new Promise(resolve => printWindow.requestAnimationFrame(() => resolve())); + alignFooterToLastPage(printWindow.document, format.heightMm, safeMargin); + 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"), + }); + } + }); +}