feat(pdf): add configurable PDF export and improve print rendering
This commit is contained in:
+89
-4
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
|
||||
+35
-4
@@ -42,7 +42,7 @@
|
||||
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
|
||||
</button>
|
||||
<div id="header-actions" class="header-actions"><button id="copy-link" class="secondary-button">Copy
|
||||
link</button>
|
||||
link</button><button id="export-pdf" class="secondary-button" type="button" title="Export PDF">PDF</button>
|
||||
<details class="page-settings">
|
||||
<summary class="secondary-button" aria-label="Page options"><span class="page-settings__label"><span
|
||||
class="page-settings__status" aria-hidden="true"></span>Page</span><span
|
||||
@@ -118,8 +118,7 @@
|
||||
data-format="definition">Definition</button><button type="button"
|
||||
data-format="highlight">Highlight</button><button type="button"
|
||||
data-format="subscript">Subscript</button><button type="button"
|
||||
data-format="superscript">Superscript</button><button type="button"
|
||||
data-format="horizontal-rule">Horizontal rule</button></div>
|
||||
data-format="superscript">Superscript</button><button type="button" data-format="page-break" title="Page break (PDF) · Ctrl/Cmd+Enter">Page break (PDF)</button></div>
|
||||
</details>
|
||||
</div>
|
||||
<button id="mobile-upload-button" class="mobile-upload-button" type="button"
|
||||
@@ -270,6 +269,38 @@
|
||||
<div id="history-list" class="history-list"></div>
|
||||
</aside>
|
||||
</main>
|
||||
<dialog id="pdf-export-dialog" class="app-dialog">
|
||||
<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>
|
||||
<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>
|
||||
<option value="a4-landscape">A4 — Landscape</option>
|
||||
<option value="a3-portrait">A3 — Portrait</option>
|
||||
<option value="a3-landscape">A3 — Landscape</option>
|
||||
<option value="a5-portrait">A5 — Portrait</option>
|
||||
<option value="letter-portrait">Letter — Portrait</option>
|
||||
<option value="letter-landscape">Letter — Landscape</option>
|
||||
<option value="screen-16-9">16:9 — Presentation</option>
|
||||
<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="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-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" type="submit">Generate PDF</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="shortcuts-dialog">
|
||||
<div class="dialog-panel shortcuts-panel">
|
||||
<div class="files-head">
|
||||
@@ -283,7 +314,7 @@
|
||||
change</span><kbd>Ctrl/Cmd+Shift+Z</kbd><span>Redo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered
|
||||
list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task
|
||||
list</span><kbd>Alt+1…4</kbd><span>Headings H1–H4</span><kbd>Tab</kbd><span>Indent by 2
|
||||
spaces</span><kbd>Shift+Tab</kbd><span>Remove indentation</span>__EXTRA_SHORTCUTS__
|
||||
spaces</span><kbd>Shift+Tab</kbd><span>Remove indentation</span><kbd>Ctrl/Cmd+Enter</kbd><span>Page break (PDF)</span>__EXTRA_SHORTCUTS__
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
@@ -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;
|
||||
|
||||
+32
-3
@@ -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 += `<div class="mermaid preview-source-line" data-source-line="${codeStart + lineOffset + 1}">${body}</div>`;
|
||||
} else if (codeLineStart !== null) {
|
||||
const numbered = body.split("\n").map((line, index) => `<span class="code-line" data-line="${codeLineStart + index}">${line || " "}</span>`).join("\n");
|
||||
const numbered = body.split("\n").map((line, index) => `<span class="code-line" data-line="${codeLineStart + index}">${line || " "}</span>`).join("");
|
||||
const languageClass = lang ? ` class="language-${escapeHtml(lang)}"` : "";
|
||||
html += `<pre${attrs(codeStart, false, "", "", lineOffset).replace(' class="', ' class="code-with-lines ')}><code${languageClass}>${numbered}</code></pre>`;
|
||||
} else {
|
||||
@@ -524,7 +553,7 @@ 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)}>`;
|
||||
} else if (/^---+$/.test(line.trim())) html += `<hr${attrs(index, false, "", "", lineOffset).replace(' class="', ' class="page-break-marker ')} data-page-break="true">`;
|
||||
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>`;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 => `<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 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
|
||||
? `<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, safeMargin, preserveColors);
|
||||
const footerText = escapeHtml(t("editor.pdfGeneratedFooter", {}, "Generated by RustPad"));
|
||||
|
||||
return (async () => {
|
||||
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 () => {
|
||||
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"),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user