mirror of
https://github.com/shrekbytes/advanced-pdf-export.git
synced 2026-07-22 07:25:03 +00:00
2141 lines
86 KiB
TypeScript
2141 lines
86 KiB
TypeScript
import {
|
||
App, Component, MarkdownRenderer, MarkdownView, Menu, Modal, Notice,
|
||
Plugin, PluginSettingTab, Setting, TFile, setIcon,
|
||
} from "obsidian";
|
||
|
||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||
|
||
const PAGE_SIZES: Record<string, { w: number; h: number }> = {
|
||
A4: { w: 794, h: 1123 },
|
||
A3: { w: 1123, h: 1587 },
|
||
Letter: { w: 816, h: 1056 },
|
||
Legal: { w: 816, h: 1344 },
|
||
A5: { w: 559, h: 794 },
|
||
};
|
||
|
||
// ─── Interfaces ───────────────────────────────────────────────────────────────
|
||
|
||
interface DocStyle {
|
||
name: string;
|
||
fontFamily: string;
|
||
fontSize: number;
|
||
lineHeight: number;
|
||
paragraphSpacing: number;
|
||
headingScale: number;
|
||
accentColor: string;
|
||
bodyColor: string;
|
||
headingColor: string;
|
||
h1BorderBottom: boolean;
|
||
h2BorderBottom: boolean;
|
||
centerH1: boolean;
|
||
blockquoteBg: string;
|
||
blockquoteBorderColor: string;
|
||
codeBackground: string;
|
||
codeFontSize: number;
|
||
tableHeaderBg: string;
|
||
tableStriped: boolean;
|
||
pageBackground: string; // page/paper background colour (white for light, dark for dark-mode)
|
||
marginTop: number; // mm
|
||
marginBottom: number; // mm
|
||
marginLeft: number; // mm
|
||
marginRight: number; // mm
|
||
}
|
||
|
||
interface PDFExportSettings extends DocStyle {
|
||
pageSize: string;
|
||
orientation: "portrait" | "landscape";
|
||
preset: string;
|
||
headerText: string;
|
||
footerText: string;
|
||
showHeader: boolean;
|
||
showFooter: boolean;
|
||
showPageNumbers: boolean;
|
||
pageNumberPosition: "center" | "left" | "right";
|
||
pageNumberStart: number;
|
||
showHeaderFooterOnFirstPage: boolean;
|
||
headerAlignment: "left" | "center" | "right";
|
||
hideFrontmatter: boolean;
|
||
customFontName: string;
|
||
autoBreakH1: boolean;
|
||
autoBreakH2: boolean;
|
||
includeFilenameAsTitle: boolean;
|
||
previewScale: number;
|
||
}
|
||
|
||
// Typed layout cache — avoids re-paginating on zoom-only changes.
|
||
// fontFamily and accentColor are cached so zoom redraws stay consistent
|
||
// with the paginated body even if settings are changed in the interim.
|
||
interface LayoutCache {
|
||
layouts: PageLayout[];
|
||
pw: number;
|
||
ph: number;
|
||
mTop: number;
|
||
mLeft: number;
|
||
footerH: number;
|
||
headerH: number;
|
||
contentW: number;
|
||
contentH: number;
|
||
docCSS: string;
|
||
fontFamily: string;
|
||
accentColor: string;
|
||
pageBackground: string;
|
||
}
|
||
|
||
interface PageLayout {
|
||
pageNodes: HTMLElement[];
|
||
pageNum: number;
|
||
totalPages: number;
|
||
headerLeft: string;
|
||
headerCenter: string;
|
||
headerRight: string;
|
||
footerLeft: string;
|
||
footerRight: string;
|
||
footerCenter: string;
|
||
}
|
||
|
||
// ─── Style Presets ────────────────────────────────────────────────────────────
|
||
|
||
const PRESETS: Record<string, DocStyle> = {
|
||
default: {
|
||
name: "Default",
|
||
fontFamily: "Georgia, serif",
|
||
fontSize: 13,
|
||
lineHeight: 1.85,
|
||
paragraphSpacing: 0.65,
|
||
headingScale: 1.0,
|
||
accentColor: "#7c6af7",
|
||
bodyColor: "#1a1a2e",
|
||
headingColor: "#0d0d1a",
|
||
h1BorderBottom: false,
|
||
h2BorderBottom: true,
|
||
centerH1: false,
|
||
blockquoteBg: "transparent",
|
||
blockquoteBorderColor: "#7c6af7",
|
||
codeBackground: "#f0f0f8",
|
||
codeFontSize: 0.85,
|
||
tableHeaderBg: "#f0f0f8",
|
||
tableStriped: true,
|
||
pageBackground: "#ffffff",
|
||
marginTop: 20, marginBottom: 20, marginLeft: 25, marginRight: 25,
|
||
},
|
||
minimal: {
|
||
name: "Minimal",
|
||
fontFamily: "'Helvetica Neue', Helvetica, sans-serif",
|
||
fontSize: 12,
|
||
lineHeight: 1.6,
|
||
paragraphSpacing: 0.45,
|
||
headingScale: 0.88,
|
||
accentColor: "#333",
|
||
bodyColor: "#222",
|
||
headingColor: "#111",
|
||
h1BorderBottom: false,
|
||
h2BorderBottom: false,
|
||
centerH1: false,
|
||
blockquoteBg: "#f8f8f8",
|
||
blockquoteBorderColor: "#ccc",
|
||
codeBackground: "#f4f4f4",
|
||
codeFontSize: 0.82,
|
||
tableHeaderBg: "#efefef",
|
||
tableStriped: false,
|
||
pageBackground: "#ffffff",
|
||
marginTop: 16, marginBottom: 16, marginLeft: 20, marginRight: 20,
|
||
},
|
||
academic: {
|
||
name: "Academic",
|
||
fontFamily: "'Times New Roman', Times, serif",
|
||
fontSize: 12,
|
||
lineHeight: 2.0,
|
||
paragraphSpacing: 0.0,
|
||
headingScale: 0.95,
|
||
accentColor: "#1a3a6b",
|
||
bodyColor: "#000",
|
||
headingColor: "#000",
|
||
h1BorderBottom: true,
|
||
h2BorderBottom: true,
|
||
centerH1: true,
|
||
blockquoteBg: "transparent",
|
||
blockquoteBorderColor: "#999",
|
||
codeBackground: "#f9f9f9",
|
||
codeFontSize: 0.88,
|
||
tableHeaderBg: "#e8e8e8",
|
||
tableStriped: false,
|
||
pageBackground: "#ffffff",
|
||
marginTop: 25, marginBottom: 25, marginLeft: 30, marginRight: 30,
|
||
},
|
||
colorful: {
|
||
name: "Colorful",
|
||
fontFamily: "Georgia, serif",
|
||
fontSize: 13,
|
||
lineHeight: 1.85,
|
||
paragraphSpacing: 0.65,
|
||
headingScale: 1.05,
|
||
accentColor: "#e84393",
|
||
bodyColor: "#1a1a2e",
|
||
headingColor: "#2d0a4e",
|
||
h1BorderBottom: false,
|
||
h2BorderBottom: false,
|
||
centerH1: false,
|
||
blockquoteBg: "#fdf0f8",
|
||
blockquoteBorderColor: "#e84393",
|
||
codeBackground: "#f0eaff",
|
||
codeFontSize: 0.85,
|
||
tableHeaderBg: "#2d0a4e",
|
||
tableStriped: true,
|
||
pageBackground: "#ffffff",
|
||
marginTop: 20, marginBottom: 20, marginLeft: 25, marginRight: 25,
|
||
},
|
||
modern: {
|
||
name: "Modern",
|
||
fontFamily: "Arial, sans-serif",
|
||
fontSize: 13,
|
||
lineHeight: 1.75,
|
||
paragraphSpacing: 0.6,
|
||
headingScale: 1.0,
|
||
accentColor: "#0070f3",
|
||
bodyColor: "#111",
|
||
headingColor: "#000",
|
||
h1BorderBottom: false,
|
||
h2BorderBottom: false,
|
||
centerH1: false,
|
||
blockquoteBg: "#f0f7ff",
|
||
blockquoteBorderColor: "#0070f3",
|
||
codeBackground: "#f1f5f9",
|
||
codeFontSize: 0.85,
|
||
tableHeaderBg: "#0070f3",
|
||
tableStriped: true,
|
||
pageBackground: "#ffffff",
|
||
marginTop: 20, marginBottom: 20, marginLeft: 25, marginRight: 25,
|
||
},
|
||
newspaper: {
|
||
name: "Newspaper",
|
||
fontFamily: "Georgia, serif",
|
||
fontSize: 12,
|
||
lineHeight: 1.7,
|
||
paragraphSpacing: 0.4,
|
||
headingScale: 1.1,
|
||
accentColor: "#111",
|
||
bodyColor: "#111",
|
||
headingColor: "#000",
|
||
h1BorderBottom: true,
|
||
h2BorderBottom: true,
|
||
centerH1: true,
|
||
blockquoteBg: "transparent",
|
||
blockquoteBorderColor: "#111",
|
||
codeBackground: "#f4f4f4",
|
||
codeFontSize: 0.82,
|
||
tableHeaderBg: "#111",
|
||
tableStriped: false,
|
||
pageBackground: "#ffffff",
|
||
marginTop: 18, marginBottom: 18, marginLeft: 20, marginRight: 20,
|
||
},
|
||
dark: {
|
||
name: "Dark",
|
||
fontFamily: "Georgia, serif",
|
||
fontSize: 13,
|
||
lineHeight: 1.85,
|
||
paragraphSpacing: 0.65,
|
||
headingScale: 1.0,
|
||
accentColor: "#818cf8",
|
||
bodyColor: "#d1d5db",
|
||
headingColor: "#f1f5f9",
|
||
h1BorderBottom: false,
|
||
h2BorderBottom: true,
|
||
centerH1: false,
|
||
blockquoteBg: "#1e293b",
|
||
blockquoteBorderColor: "#818cf8",
|
||
codeBackground: "#0f172a",
|
||
codeFontSize: 0.85,
|
||
tableHeaderBg: "#1e293b",
|
||
tableStriped: true,
|
||
pageBackground: "#111827",
|
||
marginTop: 20, marginBottom: 20, marginLeft: 25, marginRight: 25,
|
||
},
|
||
};
|
||
|
||
const DEFAULT_SETTINGS: PDFExportSettings = {
|
||
pageSize: "A4",
|
||
orientation: "portrait",
|
||
preset: "default",
|
||
...PRESETS["default"],
|
||
headerText: "",
|
||
footerText: "",
|
||
showHeader: true,
|
||
showFooter: true,
|
||
showPageNumbers: true,
|
||
pageNumberPosition: "right",
|
||
pageNumberStart: 1,
|
||
showHeaderFooterOnFirstPage: true,
|
||
headerAlignment: "right",
|
||
hideFrontmatter: false,
|
||
customFontName: "",
|
||
autoBreakH1: false,
|
||
autoBreakH2: false,
|
||
includeFilenameAsTitle: false,
|
||
previewScale: 0.62,
|
||
};
|
||
|
||
// ─── Utilities ────────────────────────────────────────────────────────────────
|
||
|
||
/** mm → px at 96 dpi, matching Electron's print pipeline. */
|
||
const mmToPx = (mm: number) => (mm / 25.4) * 96;
|
||
|
||
function escapeHTML(s: string): string {
|
||
return s
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
// ─── Markdown helpers ─────────────────────────────────────────────────────────
|
||
|
||
function normalizeMarkdown(raw: string): string {
|
||
return raw
|
||
.replace(/\r\n/g, "\n");
|
||
// Callout blocks (> [!TYPE] …) are intentionally left intact so that
|
||
// Obsidian's MarkdownRenderer can convert them to .callout HTML elements,
|
||
// which the plugin then styles distinctly via buildDocCSS.
|
||
}
|
||
|
||
/**
|
||
* Strips a YAML frontmatter block from the start of a markdown document.
|
||
* Matches the opening `---`, any content, and the closing `---` (with
|
||
* optional trailing whitespace on the fence lines), so it handles both
|
||
* Unix and Windows line endings. If no frontmatter is present the string
|
||
* is returned unchanged.
|
||
*/
|
||
function stripFrontmatter(md: string): string {
|
||
return md.replace(/^---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*(\r?\n|$)/, "");
|
||
}
|
||
|
||
function splitMarkdownSections(md: string): string[] {
|
||
return md
|
||
.split(/^\/\/\/\s*$/m)
|
||
.map((s) => s.trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
async function renderMarkdownToHtml(
|
||
app: App,
|
||
markdown: string,
|
||
sourcePath: string,
|
||
component: Component,
|
||
): Promise<string> {
|
||
const temp = document.createElement("div");
|
||
await MarkdownRenderer.render(app, markdown, temp, sourcePath, component);
|
||
postProcessRenderedHTML(temp);
|
||
return temp.innerHTML;
|
||
}
|
||
|
||
/**
|
||
* Converts a heading's text content into a URL-fragment-safe slug,
|
||
* matching the algorithm used by most Markdown renderers:
|
||
* - lowercase
|
||
* - strip everything except letters, digits, spaces, and hyphens
|
||
* - replace spaces with hyphens
|
||
* - collapse consecutive hyphens
|
||
*/
|
||
function slugifyHeading(text: string): string {
|
||
return text
|
||
.toLowerCase()
|
||
.replace(/[^\p{L}\p{N}\s-]/gu, "")
|
||
.trim()
|
||
.replace(/\s+/g, "-")
|
||
.replace(/-+/g, "-");
|
||
}
|
||
|
||
/**
|
||
* Post-processing passes over Obsidian's rendered HTML:
|
||
*
|
||
* 1. Assigns `id` attributes to all h1–h6 so `#fragment` links have targets.
|
||
* 2. Strips the `external-link` class from <a> elements so Obsidian's theme
|
||
* CSS never gets a chance to inject the ↗ icon (works even when the theme
|
||
* rule has higher specificity than our own `.mpdf-doc` scoped rule).
|
||
* 3. Removes copy-code buttons that Obsidian injects into every fenced code
|
||
* block — they are interactive UI widgets that have no place in a PDF or
|
||
* a read-only print preview.
|
||
*/
|
||
function postProcessRenderedHTML(root: HTMLElement): void {
|
||
// ── Pass 1: assign heading IDs ────────────────────────────────────────────
|
||
const seen = new Map<string, number>();
|
||
|
||
root.querySelectorAll("h1,h2,h3,h4,h5,h6").forEach((el) => {
|
||
const text = el.textContent || "";
|
||
const base = slugifyHeading(text);
|
||
if (!base) return;
|
||
const count = seen.get(base) ?? 0;
|
||
seen.set(base, count + 1);
|
||
const id = count === 0 ? base : `${base}-${count}`;
|
||
el.id = id;
|
||
});
|
||
|
||
// ── Pass 2: fix all anchor elements ──────────────────────────────────────
|
||
root.querySelectorAll<HTMLAnchorElement>("a").forEach((a) => {
|
||
// Remove the Obsidian-added class that triggers the external link icon.
|
||
// This is the only reliable way to beat the theme's unscoped CSS rule.
|
||
a.classList.remove("external-link");
|
||
});
|
||
|
||
// ── Pass 3: strip copy-code buttons ──────────────────────────────────────
|
||
// Obsidian injects a .copy-code-button into every rendered <pre> block.
|
||
// Removing the nodes here ensures they never reach the paginator, the
|
||
// preview DOM, or the exported PDF HTML — regardless of CSS.
|
||
root.querySelectorAll(".copy-code-button").forEach((el) => el.remove());
|
||
|
||
// ── Pass 4: normalise callout blocks ─────────────────────────────────────
|
||
// Obsidian renders > [!TYPE] as .callout divs with fold controls and
|
||
// interactive collapse behaviour. For preview/PDF we:
|
||
// • Force-expand every callout so no content is hidden in the output.
|
||
// • Remove the fold-arrow button (meaningless in a static document).
|
||
// • Strip the .is-collapsed class so CSS transitions don't hide content.
|
||
root.querySelectorAll<HTMLElement>(".callout").forEach((callout) => {
|
||
callout.removeAttribute("data-callout-fold");
|
||
callout.classList.remove("is-collapsed");
|
||
callout.querySelectorAll(".callout-fold").forEach((el) => el.remove());
|
||
});
|
||
}
|
||
|
||
// ─── CSS generator ────────────────────────────────────────────────────────────
|
||
//
|
||
// Single source of truth used by both the live preview and the exported PDF.
|
||
// The selector is always `.mpdf-doc` so both pipelines get identical styles.
|
||
|
||
/**
|
||
* Returns the relative luminance (0–1) of a CSS hex color (#rrggbb or #rgb).
|
||
* Used to pick readable table-header text regardless of which preset or custom
|
||
* color is active. Non-hex values (e.g. named colors) return 1 (treat as
|
||
* light) so we fall back to the heading color rather than forcing white.
|
||
*/
|
||
function hexLuminance(hex: string): number {
|
||
const full = hex.replace(
|
||
/^#([\da-f])([\da-f])([\da-f])$/i,
|
||
(_, r, g, b) => `#${r}${r}${g}${g}${b}${b}`,
|
||
);
|
||
if (!/^#[\da-f]{6}$/i.test(full)) return 1;
|
||
const linearize = (c: number) =>
|
||
c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||
const r = linearize(parseInt(full.slice(1, 3), 16) / 255);
|
||
const g = linearize(parseInt(full.slice(3, 5), 16) / 255);
|
||
const b = linearize(parseInt(full.slice(5, 7), 16) / 255);
|
||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||
}
|
||
|
||
function buildDocCSS(s: PDFExportSettings): string {
|
||
const hs = s.headingScale;
|
||
// Resolve custom font: when "__custom__" is selected use customFontName,
|
||
// otherwise use the preset's font stack directly.
|
||
const fontFamily = s.fontFamily === "__custom__"
|
||
? (s.customFontName.trim() || "inherit")
|
||
: s.fontFamily;
|
||
// Choose white or heading-colour text based on the actual background
|
||
// luminance, so customised colours work correctly regardless of preset name.
|
||
const tableHeaderTextColor =
|
||
hexLuminance(s.tableHeaderBg) < 0.35 ? "#fff" : s.headingColor;
|
||
|
||
return `
|
||
.mpdf-doc {
|
||
font-family: ${fontFamily};
|
||
font-size: ${s.fontSize}px;
|
||
line-height: ${s.lineHeight};
|
||
color: ${s.bodyColor};
|
||
box-sizing: border-box;
|
||
}
|
||
.mpdf-doc *, .mpdf-doc *::before, .mpdf-doc *::after { box-sizing: border-box; }
|
||
.mpdf-doc strong, .mpdf-doc b { font-weight: 700; font-style: normal; }
|
||
.mpdf-doc em, .mpdf-doc i { font-style: italic; font-weight: inherit; }
|
||
.mpdf-doc mark { background: #ffe066; color: inherit; padding: 0 2px; border-radius: 2px; }
|
||
.mpdf-doc del, .mpdf-doc s { text-decoration: line-through; }
|
||
.mpdf-doc h1 {
|
||
font-size: ${Math.round(22 * hs)}px;
|
||
font-weight: 700;
|
||
color: ${s.headingColor};
|
||
margin: 0 0 ${Math.round(12 * hs)}px;
|
||
line-height: 1.2;
|
||
${s.h1BorderBottom ? `border-bottom: 2px solid ${s.accentColor}; padding-bottom: 6px;` : ""}
|
||
${s.centerH1 ? "text-align: center;" : ""}
|
||
}
|
||
.mpdf-doc h2 {
|
||
font-size: ${Math.round(17 * hs)}px;
|
||
font-weight: 600;
|
||
color: ${s.headingColor};
|
||
margin: ${Math.round(20 * hs)}px 0 ${Math.round(10 * hs)}px;
|
||
${s.h2BorderBottom ? `border-bottom: 0.5px solid ${s.accentColor}55; padding-bottom: 5px;` : ""}
|
||
}
|
||
.mpdf-doc h3 {
|
||
font-size: ${Math.round(15 * hs)}px;
|
||
font-weight: 700;
|
||
color: ${s.headingColor};
|
||
margin: ${Math.round(16 * hs)}px 0 ${Math.round(8 * hs)}px;
|
||
letter-spacing: 0.01em;
|
||
}
|
||
.mpdf-doc h4 { font-size: ${Math.round(13 * hs)}px; font-weight: 700; color: ${s.headingColor}; margin: 12px 0 6px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||
.mpdf-doc h5 { font-size: ${Math.round(12 * hs)}px; font-weight: 600; color: ${s.headingColor}; margin: 10px 0 4px; font-style: italic; }
|
||
.mpdf-doc h6 { font-size: ${Math.round(11 * hs)}px; font-weight: 600; color: ${s.bodyColor}; margin: 8px 0 4px; font-style: italic; opacity: 0.75; }
|
||
.mpdf-doc p { margin: 0 0 ${s.paragraphSpacing}em; }
|
||
.mpdf-doc ul, .mpdf-doc ol { padding-left: 1.4em; margin: 0 0 ${s.paragraphSpacing}em; }
|
||
.mpdf-doc li { margin-bottom: 0.2em; line-height: ${s.lineHeight}; }
|
||
.mpdf-doc blockquote {
|
||
border-left: 3px solid ${s.blockquoteBorderColor};
|
||
background: ${s.blockquoteBg};
|
||
padding: 4px 0 4px 1em;
|
||
margin: ${s.paragraphSpacing}em 0;
|
||
font-style: italic;
|
||
color: ${s.bodyColor}cc;
|
||
}
|
||
.mpdf-doc code {
|
||
font-family: 'Courier New', monospace;
|
||
font-size: ${s.codeFontSize}em;
|
||
background: ${s.codeBackground};
|
||
padding: 1px 4px;
|
||
border-radius: 3px;
|
||
color: ${s.accentColor};
|
||
}
|
||
.mpdf-doc pre {
|
||
background: ${s.codeBackground};
|
||
border-radius: 4px;
|
||
padding: 10px 12px;
|
||
margin: 0 0 ${s.paragraphSpacing}em;
|
||
overflow: hidden;
|
||
}
|
||
.mpdf-doc pre code {
|
||
font-family: 'Courier New', monospace;
|
||
font-size: ${s.codeFontSize}em;
|
||
color: ${s.bodyColor};
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
background: none;
|
||
padding: 0;
|
||
}
|
||
.mpdf-doc hr {
|
||
border: none;
|
||
border-top: 0.5px solid ${s.accentColor}44;
|
||
margin: ${s.paragraphSpacing * 1.5}em 0;
|
||
}
|
||
.mpdf-doc img { max-width: 100%; height: auto; display: block; margin: ${s.paragraphSpacing}em auto; }
|
||
.mpdf-doc a { color: ${s.accentColor}; }
|
||
.mpdf-doc a.external-link::after { display: none !important; content: none !important; }
|
||
/* Belt-and-suspenders: hide any copy-code button Obsidian re-injects after
|
||
the DOM-removal pass in postProcessRenderedHTML (e.g. via a MutationObserver
|
||
or a theme that adds its own variant with different timing). */
|
||
.mpdf-doc .copy-code-button { display: none !important; }
|
||
.mpdf-doc table { width: 100%; border-collapse: collapse; margin: 0 0 ${s.paragraphSpacing}em; font-size: 0.92em; }
|
||
.mpdf-doc th {
|
||
background: ${s.tableHeaderBg};
|
||
color: ${tableHeaderTextColor};
|
||
padding: 6px 10px;
|
||
text-align: left;
|
||
font-weight: 600;
|
||
border: 0.5px solid ${s.accentColor}33;
|
||
font-size: 0.9em;
|
||
}
|
||
.mpdf-doc td { padding: 5px 10px; border: 0.5px solid ${s.bodyColor}22; vertical-align: top; }
|
||
${s.tableStriped ? `.mpdf-doc tbody tr:nth-child(even) { background: ${s.tableHeaderBg}55; }` : ""}
|
||
|
||
/* ── Callouts ────────────────────────────────────────────────────────────
|
||
* Obsidian renders > [!TYPE] blocks as .callout divs. We override all
|
||
* theme-provided callout styles with !important so the PDF and the preview
|
||
* both look identical regardless of which Obsidian theme is active.
|
||
* The design is deliberately bolder than a plain blockquote: coloured
|
||
* header bar, tinted body background, and ALL-CAPS type label. */
|
||
.mpdf-doc .callout {
|
||
border-left: 4px solid ${s.accentColor} !important;
|
||
border-radius: 0 5px 5px 0 !important;
|
||
background: ${s.accentColor}12 !important;
|
||
margin: ${s.paragraphSpacing * 1.2}em 0 !important;
|
||
padding: 0 !important;
|
||
overflow: hidden !important;
|
||
box-shadow: inset 0 0 0 1px ${s.accentColor}22 !important;
|
||
font-style: normal !important;
|
||
}
|
||
.mpdf-doc .callout-title {
|
||
display: flex !important;
|
||
align-items: center !important;
|
||
gap: 7px !important;
|
||
padding: 7px 12px !important;
|
||
background: ${s.accentColor}28 !important;
|
||
border-bottom: 1px solid ${s.accentColor}33 !important;
|
||
font-family: ${fontFamily} !important;
|
||
font-size: 0.8em !important;
|
||
font-weight: 800 !important;
|
||
font-style: normal !important;
|
||
letter-spacing: 0.08em !important;
|
||
text-transform: uppercase !important;
|
||
color: ${s.accentColor} !important;
|
||
line-height: 1.3 !important;
|
||
}
|
||
.mpdf-doc .callout-icon {
|
||
display: inline-flex !important;
|
||
align-items: center !important;
|
||
flex-shrink: 0 !important;
|
||
width: 15px !important;
|
||
height: 15px !important;
|
||
color: ${s.accentColor} !important;
|
||
}
|
||
.mpdf-doc .callout-icon svg {
|
||
width: 15px !important;
|
||
height: 15px !important;
|
||
stroke: ${s.accentColor} !important;
|
||
fill: none !important;
|
||
stroke-width: 2 !important;
|
||
}
|
||
.mpdf-doc .callout-title-inner {
|
||
flex: 1 !important;
|
||
min-width: 0 !important;
|
||
}
|
||
.mpdf-doc .callout-fold { display: none !important; }
|
||
.mpdf-doc .callout-content {
|
||
padding: 9px 14px !important;
|
||
color: ${s.bodyColor} !important;
|
||
font-style: normal !important;
|
||
background: transparent !important;
|
||
}
|
||
.mpdf-doc .callout-content > p:first-child { margin-top: 0 !important; }
|
||
.mpdf-doc .callout-content > p:last-child { margin-bottom: 0 !important; }
|
||
/* Nested blockquotes inside callout content keep a subtler indent */
|
||
.mpdf-doc .callout-content blockquote {
|
||
border-left-color: ${s.accentColor}66 !important;
|
||
background: transparent !important;
|
||
}
|
||
`.trim();
|
||
}
|
||
|
||
// ─── Paginator ────────────────────────────────────────────────────────────────
|
||
//
|
||
// Attaches a hidden sandbox to document.body (never to a scrollable container)
|
||
// so height measurements are unaffected by scroll context or overflow clipping.
|
||
// Walks the block-level children of the rendered HTML and fills page buckets
|
||
// by measured height. Elements that overflow are split by their natural unit
|
||
// (line for PRE, row for TABLE, item for UL/OL, word for inline text).
|
||
|
||
const INLINE_SPLIT_TAGS = new Set(["P", "LI", "BLOCKQUOTE", "TD", "TH"]);
|
||
|
||
const BLOCK_TAGS = new Set([
|
||
"P", "DIV", "SECTION", "ARTICLE", "ASIDE", "NAV", "HEADER", "FOOTER",
|
||
"UL", "OL", "LI", "TABLE", "THEAD", "TBODY", "TFOOT", "TR", "TD", "TH",
|
||
"PRE", "BLOCKQUOTE", "HR", "IMG",
|
||
"H1", "H2", "H3", "H4", "H5", "H6",
|
||
]);
|
||
|
||
// Elements that cannot be split at all — they land on one page whole.
|
||
// PRE is deliberately absent: it gets its own line-based splitter below.
|
||
const UNSPLITTABLE_TAGS = new Set([
|
||
"CODE", "IMG", "HR", "H1", "H2", "H3", "H4", "H5", "H6",
|
||
]);
|
||
|
||
const HEIGHT_EPS = 0.5; // px — prevents infinite loops on rounding boundaries
|
||
|
||
function measureNodesHeight(nodes: HTMLElement[], measureEl: HTMLElement): number {
|
||
measureEl.innerHTML = "";
|
||
for (const node of nodes) measureEl.appendChild(node.cloneNode(true));
|
||
return measureEl.getBoundingClientRect().height;
|
||
}
|
||
|
||
function makeFitFn(
|
||
currentPage: HTMLElement[],
|
||
measureEl: HTMLElement,
|
||
contentHeightPx: number,
|
||
): (node: HTMLElement) => boolean {
|
||
return (node: HTMLElement) =>
|
||
measureNodesHeight([...currentPage, node], measureEl) <= contentHeightPx - HEIGHT_EPS;
|
||
}
|
||
|
||
// ── Inline (text) splitter ───────────────────────────────────────────────────
|
||
|
||
function trimLeadingWhitespace(el: HTMLElement): void {
|
||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||
while (walker.nextNode()) {
|
||
const node = walker.currentNode as Text;
|
||
const trimmed = (node.textContent ?? "").replace(/^\s+/, "");
|
||
if (trimmed !== node.textContent) node.textContent = trimmed;
|
||
if (trimmed.length > 0) break;
|
||
}
|
||
}
|
||
|
||
function buildInlineSplitAt(
|
||
el: HTMLElement,
|
||
splitOffset: number,
|
||
): [HTMLElement, HTMLElement] | null {
|
||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||
let count = 0;
|
||
let target: Text | null = null;
|
||
let localOffset = 0;
|
||
|
||
while (walker.nextNode()) {
|
||
const node = walker.currentNode as Text;
|
||
const len = node.textContent?.length ?? 0;
|
||
if (count + len >= splitOffset) {
|
||
target = node;
|
||
localOffset = splitOffset - count;
|
||
break;
|
||
}
|
||
count += len;
|
||
}
|
||
|
||
if (!target) return null;
|
||
|
||
const range1 = document.createRange();
|
||
range1.selectNodeContents(el);
|
||
range1.setEnd(target, localOffset);
|
||
|
||
const range2 = document.createRange();
|
||
range2.selectNodeContents(el);
|
||
range2.setStart(target, localOffset);
|
||
|
||
const first = el.cloneNode(false) as HTMLElement;
|
||
first.appendChild(range1.cloneContents());
|
||
|
||
const second = el.cloneNode(false) as HTMLElement;
|
||
second.appendChild(range2.cloneContents());
|
||
trimLeadingWhitespace(second);
|
||
|
||
return [first, second];
|
||
}
|
||
|
||
function getWordBoundaryOffsets(text: string): number[] {
|
||
const offsets: number[] = [];
|
||
const re = /\s+/g;
|
||
let match: RegExpExecArray | null;
|
||
while ((match = re.exec(text))) {
|
||
if (match.index > 0 && match.index < text.length) offsets.push(match.index);
|
||
}
|
||
return offsets;
|
||
}
|
||
|
||
function splitInlineElement(
|
||
el: HTMLElement,
|
||
fits: (node: HTMLElement) => boolean,
|
||
forceSplit: boolean,
|
||
): [HTMLElement, HTMLElement] | null {
|
||
const text = el.textContent ?? "";
|
||
if (text.length < 2) return null;
|
||
|
||
// First attempt: binary-search for the latest word boundary that fits.
|
||
const offsets = getWordBoundaryOffsets(text);
|
||
if (offsets.length > 0) {
|
||
let lo = 0, hi = offsets.length - 1, bestIdx = -1;
|
||
while (lo <= hi) {
|
||
const mid = Math.floor((lo + hi) / 2);
|
||
const split = buildInlineSplitAt(el, offsets[mid]);
|
||
if (!split) { hi = mid - 1; continue; }
|
||
if (fits(split[0])) { bestIdx = mid; lo = mid + 1; }
|
||
else { hi = mid - 1; }
|
||
}
|
||
if (bestIdx >= 0) return buildInlineSplitAt(el, offsets[bestIdx]);
|
||
if (!forceSplit) return null;
|
||
}
|
||
|
||
// Fallback: character-level binary search (oversized single word, or forced).
|
||
let lo = 1, hi = text.length - 1, best = 0;
|
||
while (lo <= hi) {
|
||
const mid = Math.floor((lo + hi) / 2);
|
||
const split = buildInlineSplitAt(el, mid);
|
||
if (!split) { hi = mid - 1; continue; }
|
||
if (fits(split[0])) { best = mid; lo = mid + 1; }
|
||
else { hi = mid - 1; }
|
||
}
|
||
return best > 0 ? buildInlineSplitAt(el, best) : null;
|
||
}
|
||
|
||
// ── List splitter ────────────────────────────────────────────────────────────
|
||
|
||
function buildListWithItems(listEl: HTMLElement, items: HTMLElement[], startAt?: number): HTMLElement {
|
||
const clone = listEl.cloneNode(false) as HTMLElement;
|
||
// For ordered lists, set the correct start value so numbering continues
|
||
// across page breaks instead of restarting at 1.
|
||
if (listEl.tagName === "OL" && startAt !== undefined && startAt > 1) {
|
||
(clone as HTMLOListElement).start = startAt;
|
||
}
|
||
for (const item of items) clone.appendChild(item.cloneNode(true));
|
||
return clone;
|
||
}
|
||
|
||
function splitListElement(
|
||
listEl: HTMLElement,
|
||
fits: (node: HTMLElement) => boolean,
|
||
forceSplit: boolean,
|
||
): [HTMLElement, HTMLElement] | null {
|
||
const items = Array.from(listEl.children).filter(
|
||
(c) => (c as HTMLElement).tagName === "LI",
|
||
) as HTMLElement[];
|
||
if (items.length === 0) return null;
|
||
|
||
// For OL, respect an existing start attribute (e.g. already a continuation).
|
||
const existingStart = listEl.tagName === "OL"
|
||
? ((listEl as HTMLOListElement).start ?? 1)
|
||
: 1;
|
||
|
||
let fitCount = 0;
|
||
for (let i = 0; i < items.length; i++) {
|
||
if (fits(buildListWithItems(listEl, items.slice(0, i + 1), existingStart))) fitCount = i + 1;
|
||
else break;
|
||
}
|
||
|
||
// When forced (alone on empty page), guarantee at least 1 item moves forward.
|
||
if (fitCount <= 0) {
|
||
if (!forceSplit || items.length < 2) return null;
|
||
fitCount = 1;
|
||
}
|
||
if (fitCount >= items.length) return null;
|
||
|
||
return [
|
||
buildListWithItems(listEl, items.slice(0, fitCount), existingStart),
|
||
// Second fragment starts at existingStart + fitCount so numbering is continuous.
|
||
buildListWithItems(listEl, items.slice(fitCount), existingStart + fitCount),
|
||
];
|
||
}
|
||
|
||
// ── Table splitter ───────────────────────────────────────────────────────────
|
||
|
||
function buildTableWithRows(tableEl: HTMLTableElement, rows: HTMLTableRowElement[]): HTMLTableElement {
|
||
const clone = tableEl.cloneNode(false) as HTMLTableElement;
|
||
const caption = tableEl.querySelector("caption");
|
||
if (caption) clone.appendChild(caption.cloneNode(true));
|
||
const colgroup = tableEl.querySelector("colgroup");
|
||
if (colgroup) clone.appendChild(colgroup.cloneNode(true));
|
||
if (tableEl.tHead) clone.appendChild(tableEl.tHead.cloneNode(true));
|
||
const tbody = document.createElement("tbody");
|
||
for (const row of rows) tbody.appendChild(row.cloneNode(true));
|
||
clone.appendChild(tbody);
|
||
return clone;
|
||
}
|
||
|
||
function splitTableElement(
|
||
tableEl: HTMLTableElement,
|
||
fits: (node: HTMLElement) => boolean,
|
||
forceSplit: boolean,
|
||
): [HTMLElement, HTMLElement] | null {
|
||
const body = tableEl.tBodies[0];
|
||
const rows = body
|
||
? Array.from(body.rows)
|
||
: Array.from(tableEl.rows).filter((r) => r.parentElement?.tagName !== "THEAD");
|
||
if (rows.length === 0) return null;
|
||
|
||
let fitCount = 0;
|
||
for (let i = 0; i < rows.length; i++) {
|
||
if (fits(buildTableWithRows(tableEl, rows.slice(0, i + 1)))) fitCount = i + 1;
|
||
else break;
|
||
}
|
||
|
||
// When forced (alone on empty page), guarantee at least 1 row moves forward.
|
||
if (fitCount <= 0) {
|
||
if (!forceSplit || rows.length < 2) return null;
|
||
fitCount = 1;
|
||
}
|
||
if (fitCount >= rows.length) return null;
|
||
|
||
return [
|
||
buildTableWithRows(tableEl, rows.slice(0, fitCount)),
|
||
buildTableWithRows(tableEl, rows.slice(fitCount)),
|
||
];
|
||
}
|
||
|
||
// ── PRE splitter ─────────────────────────────────────────────────────────────
|
||
//
|
||
// Splits a <pre><code>…</code></pre> block by lines, preserving the inner
|
||
// <code> wrapper so CSS styling (background, font, colour) stays correct.
|
||
// When forced (alone on an empty page), guarantees at least 1 line moves
|
||
// forward so the main loop can never stall on an oversized code block.
|
||
|
||
function buildPreWithLines(preEl: HTMLElement, lines: string[]): HTMLElement {
|
||
const clone = preEl.cloneNode(false) as HTMLElement;
|
||
const codeEl = preEl.querySelector("code");
|
||
if (codeEl) {
|
||
const codeClone = codeEl.cloneNode(false) as HTMLElement;
|
||
codeClone.textContent = lines.join("\n");
|
||
clone.appendChild(codeClone);
|
||
} else {
|
||
clone.textContent = lines.join("\n");
|
||
}
|
||
return clone;
|
||
}
|
||
|
||
function splitPreElement(
|
||
preEl: HTMLElement,
|
||
fits: (node: HTMLElement) => boolean,
|
||
forceSplit: boolean,
|
||
): [HTMLElement, HTMLElement] | null {
|
||
const lines = (preEl.textContent ?? "").split("\n");
|
||
// Drop the trailing empty string that String.split() produces.
|
||
if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
|
||
if (lines.length < 2) return null;
|
||
|
||
let fitCount = 0;
|
||
for (let i = 0; i < lines.length; i++) {
|
||
if (fits(buildPreWithLines(preEl, lines.slice(0, i + 1)))) fitCount = i + 1;
|
||
else break;
|
||
}
|
||
|
||
if (fitCount <= 0) {
|
||
if (!forceSplit || lines.length < 2) return null;
|
||
fitCount = 1;
|
||
}
|
||
if (fitCount >= lines.length) return null;
|
||
|
||
return [
|
||
buildPreWithLines(preEl, lines.slice(0, fitCount)),
|
||
buildPreWithLines(preEl, lines.slice(fitCount)),
|
||
];
|
||
}
|
||
|
||
// ── Element splitter dispatcher ──────────────────────────────────────────────
|
||
|
||
function isInlineSplitCandidate(el: HTMLElement): boolean {
|
||
if (!INLINE_SPLIT_TAGS.has(el.tagName)) return false;
|
||
for (const child of Array.from(el.childNodes)) {
|
||
if (child.nodeType === Node.ELEMENT_NODE && BLOCK_TAGS.has((child as HTMLElement).tagName))
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function splitElement(
|
||
el: HTMLElement,
|
||
fits: (node: HTMLElement) => boolean,
|
||
forceSplit: boolean,
|
||
): [HTMLElement, HTMLElement] | null {
|
||
if (UNSPLITTABLE_TAGS.has(el.tagName)) return null;
|
||
if (el.tagName === "PRE") return splitPreElement(el, fits, forceSplit);
|
||
if (el.tagName === "TABLE") return splitTableElement(el as HTMLTableElement, fits, forceSplit);
|
||
if (el.tagName === "UL" || el.tagName === "OL") return splitListElement(el, fits, forceSplit);
|
||
if (isInlineSplitCandidate(el)) return splitInlineElement(el, fits, forceSplit);
|
||
return null;
|
||
}
|
||
|
||
// ── Main pagination loop ─────────────────────────────────────────────────────
|
||
|
||
function paginateHTML(
|
||
html: string,
|
||
contentWidthPx: number,
|
||
contentHeightPx: number,
|
||
docCSS: string,
|
||
): HTMLElement[][] {
|
||
// Fixed-position sandbox: out of any scroll container, never affects layout.
|
||
const sandbox = document.createElement("div");
|
||
sandbox.style.cssText = [
|
||
"position:fixed", "top:0", "left:-99999px",
|
||
`width:${contentWidthPx}px`,
|
||
"visibility:hidden", "pointer-events:none", "z-index:-1",
|
||
].join(";");
|
||
|
||
const styleEl = document.createElement("style");
|
||
styleEl.textContent = docCSS;
|
||
sandbox.appendChild(styleEl);
|
||
|
||
const inner = document.createElement("div");
|
||
inner.className = "mpdf-doc";
|
||
inner.innerHTML = html;
|
||
sandbox.appendChild(inner);
|
||
|
||
// Measurement div: same width, always empty before each measurement.
|
||
const measure = document.createElement("div");
|
||
measure.className = "mpdf-doc";
|
||
measure.style.cssText = `position:absolute;top:0;left:0;width:${contentWidthPx}px;visibility:hidden;`;
|
||
sandbox.appendChild(measure);
|
||
|
||
document.body.appendChild(sandbox);
|
||
|
||
const pages: HTMLElement[][] = [];
|
||
try {
|
||
let currentPage: HTMLElement[] = [];
|
||
const children = Array.from(inner.children) as HTMLElement[];
|
||
let idx = 0;
|
||
|
||
while (idx < children.length) {
|
||
const child = children[idx];
|
||
const fits = makeFitFn(currentPage, measure, contentHeightPx);
|
||
|
||
if (fits(child.cloneNode(true) as HTMLElement)) {
|
||
currentPage.push(child.cloneNode(true) as HTMLElement);
|
||
idx++;
|
||
continue;
|
||
}
|
||
|
||
// Element doesn't fit on the current page. Try splitting it.
|
||
const forceSplit = currentPage.length === 0;
|
||
const split = splitElement(child, fits, forceSplit);
|
||
if (split) {
|
||
currentPage.push(split[0]);
|
||
pages.push(currentPage);
|
||
currentPage = [];
|
||
// Replace current child with the remainder for re-processing.
|
||
const remainder = split[1];
|
||
if (remainder.textContent?.trim() || remainder.children.length > 0) {
|
||
children[idx] = remainder;
|
||
} else {
|
||
idx++;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Can't split. If there's content on this page, flush it and retry the
|
||
// same element on a fresh full-height page.
|
||
if (currentPage.length > 0) {
|
||
pages.push(currentPage);
|
||
currentPage = [];
|
||
continue;
|
||
}
|
||
|
||
// Element is alone on an empty page and truly unsplittable (e.g. a giant
|
||
// image). Force it onto its own page and advance so we never stall.
|
||
currentPage.push(child.cloneNode(true) as HTMLElement);
|
||
pages.push(currentPage);
|
||
currentPage = [];
|
||
idx++;
|
||
}
|
||
|
||
if (currentPage.length > 0) pages.push(currentPage);
|
||
} finally {
|
||
document.body.removeChild(sandbox);
|
||
}
|
||
return pages.length > 0 ? pages : [[]];
|
||
}
|
||
|
||
// ─── Page layout builder ──────────────────────────────────────────────────────
|
||
//
|
||
// Computes header/footer text for every page. Both the live preview and the
|
||
// PDF export consume the same PageLayout objects so they are always in sync.
|
||
|
||
function buildPageLayouts(allPages: HTMLElement[][], s: PDFExportSettings): PageLayout[] {
|
||
const totalPages = allPages.length;
|
||
return allPages.map((pageNodes, i) => {
|
||
const pageNum = i + 1;
|
||
// When first-page header/footer is disabled, page 0 is always blank.
|
||
const showHF = s.showHeaderFooterOnFirstPage || i > 0;
|
||
|
||
// Compute the display number.
|
||
// enabled: page index 0 → pageNumberStart, 1 → pageNumberStart+1, …
|
||
// disabled: page index 1 → pageNumberStart, 2 → pageNumberStart+1, …
|
||
const displayNum = s.showHeaderFooterOnFirstPage
|
||
? s.pageNumberStart + i
|
||
: s.pageNumberStart + (i - 1);
|
||
const numStr = `${displayNum} / ${totalPages}`;
|
||
|
||
let footerLeft = "", footerRight = "", footerCenter = "";
|
||
let headerLeft = "", headerCenter = "", headerRight = "";
|
||
|
||
if (showHF) {
|
||
// ── Footer / page-number ───────────────────────────────────────────────
|
||
if (s.showPageNumbers) {
|
||
if (s.pageNumberPosition === "center") {
|
||
footerCenter = (s.footerText ? s.footerText + " — " : "") + numStr;
|
||
} else if (s.pageNumberPosition === "left") {
|
||
footerLeft = numStr;
|
||
footerRight = s.footerText ?? "";
|
||
} else {
|
||
footerLeft = s.footerText ?? "";
|
||
footerRight = numStr;
|
||
}
|
||
} else {
|
||
footerLeft = s.footerText ?? "";
|
||
}
|
||
|
||
// ── Header ────────────────────────────────────────────────────────────
|
||
if (s.headerText) {
|
||
if (s.headerAlignment === "center") {
|
||
headerCenter = s.headerText;
|
||
} else if (s.headerAlignment === "left") {
|
||
headerLeft = s.headerText;
|
||
} else {
|
||
headerRight = s.headerText;
|
||
}
|
||
}
|
||
}
|
||
|
||
return { pageNodes, pageNum, totalPages, headerLeft, headerCenter, headerRight, footerLeft, footerRight, footerCenter };
|
||
});
|
||
}
|
||
|
||
// ─── Plugin ───────────────────────────────────────────────────────────────────
|
||
|
||
export default class MarkdownPDFPlugin extends Plugin {
|
||
settings: PDFExportSettings;
|
||
activeModal: PDFExportModal | null = null;
|
||
|
||
async onload() {
|
||
await this.loadSettings();
|
||
this.addCommand({
|
||
id: "open-advanced-pdf-export",
|
||
name: "Open Panel",
|
||
callback: () => this.openModal(),
|
||
});
|
||
this.registerEvent(
|
||
this.app.workspace.on("file-menu", (menu: Menu, file) => {
|
||
if (!(file instanceof TFile) || file.extension !== "md") return;
|
||
menu.addItem((item) =>
|
||
item.setTitle("Advanced PDF Export: Open Panel").setIcon("file-output")
|
||
.onClick(() => this.openModal(file)),
|
||
);
|
||
}),
|
||
);
|
||
this.addSettingTab(new PDFExportSettingTab(this.app, this));
|
||
}
|
||
|
||
onunload() {
|
||
this.activeModal?.close();
|
||
}
|
||
|
||
/** Open the modal, optionally pre-loading a specific file. */
|
||
openModal(file?: TFile) {
|
||
if (this.activeModal) this.activeModal.close();
|
||
new PDFExportModal(this.app, this, file).open();
|
||
}
|
||
|
||
async loadSettings() {
|
||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||
}
|
||
|
||
async saveSettings() {
|
||
await this.saveData(this.settings);
|
||
}
|
||
|
||
async saveSettingsAndRender() {
|
||
await this.saveSettings();
|
||
this.activeModal?.render();
|
||
}
|
||
|
||
applyPreset(key: string) {
|
||
const p = PRESETS[key];
|
||
if (!p) return;
|
||
this.settings.preset = key;
|
||
Object.assign(this.settings, p);
|
||
}
|
||
}
|
||
|
||
// ─── File resolver ────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Find the best markdown file to load when the modal opens.
|
||
*
|
||
* Four-level cascade so every real-world scenario is covered:
|
||
*
|
||
* 1. `initialFile` — passed explicitly (right-click menu, command
|
||
* with a specific file). Always wins.
|
||
* 2. `getActiveViewOfType` — a MarkdownView is the currently focused leaf.
|
||
* The common case when a note tab is open and active.
|
||
* 3. `getActiveFile` — "returns the most recently active file" per the
|
||
* Obsidian API docs. Covers the case where the file
|
||
* explorer sidebar retains focus after the user
|
||
* clicks a file to open it.
|
||
* 4. `getMostRecentLeaf` — searches rootSplit (the main editor area),
|
||
* explicitly ignoring sidebar leaves. The Obsidian
|
||
* docs describe it as "useful for interacting with
|
||
* the leaf in the root split while a sidebar leaf
|
||
* might be active." Covers fresh Obsidian startup
|
||
* before any click, and is file-manager-agnostic so
|
||
* it works with any third-party file manager plugin.
|
||
*/
|
||
function resolveActiveMarkdownFile(app: App, initialFile?: TFile | null): TFile | null {
|
||
if (initialFile) return initialFile;
|
||
|
||
const fromView = app.workspace.getActiveViewOfType(MarkdownView)?.file;
|
||
if (fromView) return fromView;
|
||
|
||
const activeFile = app.workspace.getActiveFile();
|
||
if (activeFile?.extension === "md") return activeFile;
|
||
|
||
const leaf = app.workspace.getMostRecentLeaf();
|
||
if (leaf?.view instanceof MarkdownView) return (leaf.view as MarkdownView).file ?? null;
|
||
|
||
return null;
|
||
}
|
||
|
||
// ─── Modal ────────────────────────────────────────────────────────────────────
|
||
|
||
class PDFExportModal extends Modal {
|
||
plugin: MarkdownPDFPlugin;
|
||
private editorEl: HTMLTextAreaElement;
|
||
private previewEl: HTMLElement;
|
||
private pageCountEl: HTMLElement;
|
||
private noteTitleEl: HTMLElement;
|
||
private renderBtn!: HTMLButtonElement;
|
||
private exportBtn!: HTMLButtonElement;
|
||
private loadingOverlayEl!: HTMLElement;
|
||
|
||
// Owned Component for MarkdownRenderer — loaded on open, unloaded on close.
|
||
private renderComponent = new Component();
|
||
|
||
private readonly initialFile: TFile | null;
|
||
private currentFile: TFile | null = null;
|
||
private renderToken = 0;
|
||
private layoutCache: LayoutCache | null = null;
|
||
// Debounce handle for settings-driven re-renders; cleared on close.
|
||
private renderDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
constructor(app: App, plugin: MarkdownPDFPlugin, file?: TFile) {
|
||
super(app);
|
||
this.plugin = plugin;
|
||
this.initialFile = file ?? null;
|
||
}
|
||
|
||
async onOpen() {
|
||
this.plugin.activeModal = this;
|
||
this.renderComponent.load();
|
||
this.modalEl.addClass("mpdf-modal");
|
||
this.buildUI(this.contentEl);
|
||
|
||
const file = resolveActiveMarkdownFile(this.app, this.initialFile);
|
||
|
||
if (file) {
|
||
this.currentFile = file;
|
||
const content = await this.app.vault.read(file);
|
||
this.editorEl.value = content;
|
||
this.noteTitleEl.textContent = file.basename;
|
||
this.noteTitleEl.title = file.path;
|
||
// Show the loading overlay right away so the panel feels instantly open,
|
||
// then yield two animation frames so the browser can paint the modal and
|
||
// textarea content before the heavy pagination work starts.
|
||
this.showLoading();
|
||
await new Promise<void>((r) => requestAnimationFrame(() => requestAnimationFrame(() => r())));
|
||
this.render(true);
|
||
}
|
||
}
|
||
|
||
onClose() {
|
||
if (this.renderDebounceTimer !== null) {
|
||
clearTimeout(this.renderDebounceTimer);
|
||
this.renderDebounceTimer = null;
|
||
}
|
||
this.renderComponent.unload();
|
||
this.plugin.activeModal = null;
|
||
this.currentFile = null;
|
||
this.layoutCache = null;
|
||
}
|
||
|
||
// ── UI ──────────────────────────────────────────────────────────────────────
|
||
|
||
private buildUI(container: HTMLElement) {
|
||
const s = this.plugin.settings;
|
||
this.buildTopbar(container.createEl("div", { cls: "mpdf-topbar" }), s);
|
||
|
||
const main = container.createEl("div", { cls: "mpdf-main" });
|
||
|
||
const editorPanel = main.createEl("div", { cls: "mpdf-editor-panel" });
|
||
|
||
this.editorEl = editorPanel.createEl("textarea", { cls: "mpdf-editor" });
|
||
this.editorEl.placeholder =
|
||
"Type or paste markdown here to preview and export as PDF.\n\n" +
|
||
"Tip: open this panel from a note's right-click menu, command palette,\n" +
|
||
"or keyboard shortcut to auto-load the active note.\n\n" +
|
||
"Use /// on its own line for a manual page break.\n" +
|
||
"Use --- for a horizontal rule.\n\n" +
|
||
"Markdown tables:\n| Col A | Col B |\n|-------|-------|\n| Cell | Cell |";
|
||
|
||
// Wrap the preview in a container so the loading overlay (absolutely
|
||
// positioned) stays fixed over the viewport portion instead of scrolling
|
||
// along with the page thumbnails.
|
||
const previewContainer = main.createEl("div", { cls: "mpdf-preview-container" });
|
||
this.previewEl = previewContainer.createEl("div", { cls: "mpdf-preview" });
|
||
|
||
// Loading overlay — hidden by default, shown while rendering.
|
||
this.loadingOverlayEl = previewContainer.createEl("div", { cls: "mpdf-loading-overlay" });
|
||
this.loadingOverlayEl.style.display = "none";
|
||
this.loadingOverlayEl.createEl("div", { cls: "mpdf-spinner" });
|
||
this.loadingOverlayEl.createEl("span", { cls: "mpdf-loading-text", text: "Rendering…" });
|
||
|
||
// Keyboard shortcut: Ctrl+Enter / Cmd+Enter to trigger a manual refresh.
|
||
this.editorEl.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||
e.preventDefault();
|
||
this.render(true);
|
||
}
|
||
});
|
||
}
|
||
|
||
private buildTopbar(topbar: HTMLElement, s: PDFExportSettings) {
|
||
const left = topbar.createEl("div", { cls: "mpdf-topbar-left" });
|
||
this.noteTitleEl = topbar.createEl("div", { cls: "mpdf-topbar-title", text: "—" });
|
||
const right = topbar.createEl("div", { cls: "mpdf-topbar-right" });
|
||
|
||
const makeSelect = (
|
||
label: string,
|
||
opts: Record<string, string>,
|
||
val: string,
|
||
cb: (v: string) => Promise<void>,
|
||
) => {
|
||
const wrap = left.createEl("div", { cls: "mpdf-ctrl" });
|
||
wrap.createEl("span", { cls: "mpdf-ctrl-label", text: label });
|
||
const el = wrap.createEl("select", { cls: "mpdf-select" });
|
||
for (const [v, t] of Object.entries(opts)) {
|
||
const o = el.createEl("option", { text: t, value: v });
|
||
if (v === val) o.selected = true;
|
||
}
|
||
el.addEventListener("change", () => void cb(el.value));
|
||
};
|
||
|
||
const presetOpts: Record<string, string> = {};
|
||
Object.entries(PRESETS).forEach(([k, v]) => (presetOpts[k] = v.name));
|
||
makeSelect("Style", presetOpts, s.preset, async (v) => {
|
||
this.plugin.applyPreset(v);
|
||
await this.plugin.saveSettingsAndRender();
|
||
});
|
||
|
||
const sizeOpts: Record<string, string> = {};
|
||
Object.keys(PAGE_SIZES).forEach((k) => (sizeOpts[k] = k));
|
||
makeSelect("Size", sizeOpts, s.pageSize, async (v) => {
|
||
this.plugin.settings.pageSize = v;
|
||
await this.plugin.saveSettingsAndRender();
|
||
});
|
||
|
||
makeSelect("Orient", { portrait: "Portrait", landscape: "Landscape" }, s.orientation,
|
||
async (v) => {
|
||
this.plugin.settings.orientation = v as "portrait" | "landscape";
|
||
await this.plugin.saveSettingsAndRender();
|
||
},
|
||
);
|
||
|
||
const zoomWrap = left.createEl("div", { cls: "mpdf-ctrl" });
|
||
zoomWrap.createEl("span", { cls: "mpdf-ctrl-label", text: "Zoom" });
|
||
const zoomLabel = zoomWrap.createEl("span", {
|
||
cls: "mpdf-ctrl-label",
|
||
text: Math.round(s.previewScale * 100) + "%",
|
||
});
|
||
const zoomSlider = zoomWrap.createEl("input");
|
||
zoomSlider.type = "range";
|
||
zoomSlider.min = "0.35";
|
||
zoomSlider.max = "1.0";
|
||
zoomSlider.step = "0.05";
|
||
zoomSlider.value = String(s.previewScale);
|
||
zoomSlider.addClass("mpdf-zoom-slider");
|
||
zoomSlider.addEventListener("input", async () => {
|
||
const v = parseFloat(zoomSlider.value);
|
||
this.plugin.settings.previewScale = v;
|
||
zoomLabel.textContent = Math.round(v * 100) + "%";
|
||
await this.plugin.saveSettings();
|
||
this.renderPreviewOnly();
|
||
});
|
||
|
||
const breakBtn = left.createEl("button", { cls: "mpdf-btn", text: "Insert Page Break" });
|
||
breakBtn.title = "Insert page break (///)";
|
||
breakBtn.addEventListener("click", () => this.insertAtCursor("\n///\n"));
|
||
|
||
this.pageCountEl = right.createEl("span", { cls: "mpdf-page-count", text: "— pages" });
|
||
|
||
const settingsBtn = right.createEl("button", { cls: "mpdf-btn mpdf-btn-icon" });
|
||
settingsBtn.setAttr("aria-label", "Open Advanced PDF Export settings");
|
||
setIcon(settingsBtn, "settings");
|
||
settingsBtn.addEventListener("click", () => {
|
||
const settings = (this.app as any).setting;
|
||
settings?.open?.();
|
||
settings?.openTabById?.("advanced-pdf-export");
|
||
});
|
||
|
||
this.renderBtn = right.createEl("button", { cls: "mpdf-btn", text: "⟳ Render PDF" });
|
||
this.renderBtn.title = "Render preview (Ctrl+Enter)";
|
||
this.renderBtn.addEventListener("click", () => this.render(true));
|
||
|
||
this.exportBtn = right.createEl("button", { cls: "mpdf-btn mpdf-btn-primary", text: "⬇ Export PDF" });
|
||
this.exportBtn.addEventListener("click", () => void this.exportPDF());
|
||
}
|
||
|
||
private insertAtCursor(text: string) {
|
||
const ta = this.editorEl;
|
||
const start = ta.selectionStart;
|
||
const end = ta.selectionEnd;
|
||
ta.value = ta.value.slice(0, start) + text + ta.value.slice(end);
|
||
ta.selectionStart = ta.selectionEnd = start + text.length;
|
||
ta.focus();
|
||
}
|
||
|
||
// ── Render ──────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Schedule a render.
|
||
*
|
||
* @param immediate When true (button click, Ctrl+Enter, initial open) the
|
||
* render starts on the very next animation frame.
|
||
* When false (settings changes) a 150 ms debounce is
|
||
* applied so rapid successive changes collapse into one
|
||
* render instead of hammering the paginator.
|
||
*
|
||
* Either way the loading overlay is shown right away so the UI never looks
|
||
* frozen — the user always gets visual feedback that work is in progress.
|
||
*/
|
||
render(immediate = false) {
|
||
const token = ++this.renderToken;
|
||
// Cancel any pending debounced render — this one supersedes it.
|
||
if (this.renderDebounceTimer !== null) {
|
||
clearTimeout(this.renderDebounceTimer);
|
||
this.renderDebounceTimer = null;
|
||
}
|
||
// Show the loading state before any async gap so the UI responds instantly.
|
||
this.showLoading();
|
||
|
||
// Shared error handler: if doRender throws (e.g. MarkdownRenderer fails),
|
||
// restore the UI rather than leaving the loading overlay up forever.
|
||
const safeDo = () =>
|
||
this.doRender(token).catch((err: unknown) => {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
console.error("[advanced-pdf-export] render error:", err);
|
||
this.hideLoading();
|
||
new Notice("Render error: " + msg);
|
||
});
|
||
|
||
if (immediate) {
|
||
// Two rAFs are required: the first fires before the browser paints,
|
||
// the second fires after. Without the second one the heavy synchronous
|
||
// pagination work in doRender blocks the thread before the browser ever
|
||
// gets a chance to render the spinner/disabled-button state.
|
||
requestAnimationFrame(() => requestAnimationFrame(() => void safeDo()));
|
||
} else {
|
||
this.renderDebounceTimer = setTimeout(() => {
|
||
this.renderDebounceTimer = null;
|
||
requestAnimationFrame(() => requestAnimationFrame(() => void safeDo()));
|
||
}, 150);
|
||
}
|
||
}
|
||
|
||
// ── Auto page-break insertion ────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Inserts `///` (manual page-break markers) immediately before H1 and/or H2
|
||
* headings, but ONLY when those headings are genuine Markdown headings — i.e.
|
||
* `# ` / `## ` at the start of a line that is NOT inside a fenced code block.
|
||
*
|
||
* Without this guard the plain regex approach fires on `#` comment lines
|
||
* inside Python, Shell, Ruby, etc. code blocks that happen to start with `# `.
|
||
*
|
||
* Heading detection uses `^# ` / `^## ` anchored at the start of the line,
|
||
* so indented headings (` # comment`), tab-indented lines, and tags with
|
||
* no trailing space (`#tag`) are all correctly ignored without extra logic.
|
||
*
|
||
* Fencing rules (CommonMark §4.5):
|
||
* • A fence opens with a line beginning with 3+ back-ticks or 3+ tildes.
|
||
* • It closes with a line of the same character and at least the same length,
|
||
* with optional trailing whitespace only.
|
||
*
|
||
* A break is never inserted before the very first heading in the document
|
||
* (checked via `out.length > 0`) so the opening heading doesn't acquire a
|
||
* spurious blank page above it.
|
||
*/
|
||
private static insertAutoBreaks(
|
||
md: string,
|
||
breakH1: boolean,
|
||
breakH2: boolean,
|
||
): string {
|
||
if (!breakH1 && !breakH2) return md;
|
||
|
||
const lines = md.split("\n");
|
||
const out: string[] = [];
|
||
let inFence = false;
|
||
let fenceMarker = "";
|
||
|
||
for (const line of lines) {
|
||
if (!inFence) {
|
||
const open = line.match(/^(`{3,}|~{3,})/);
|
||
if (open) {
|
||
inFence = true;
|
||
fenceMarker = open[1];
|
||
} else if (out.length > 0) {
|
||
// Only true headings: `^# ` cannot match `## …` because position 1
|
||
// would be `#` not ` `; likewise `^## ` cannot match `### …`.
|
||
if (breakH1 && /^# /.test(line)) out.push("///");
|
||
else if (breakH2 && /^## /.test(line)) out.push("///");
|
||
}
|
||
} else {
|
||
// A closing fence uses the same character and is at least as long.
|
||
const close = line.match(/^(`{3,}|~{3,})\s*$/);
|
||
if (
|
||
close &&
|
||
close[1][0] === fenceMarker[0] &&
|
||
close[1].length >= fenceMarker.length
|
||
) {
|
||
inFence = false;
|
||
fenceMarker = "";
|
||
}
|
||
}
|
||
out.push(line);
|
||
}
|
||
|
||
return out.join("\n");
|
||
}
|
||
|
||
private async doRender(token: number) {
|
||
const s = this.plugin.settings;
|
||
let md = normalizeMarkdown(this.editorEl.value);
|
||
|
||
// Strip YAML frontmatter when the option is enabled, so property blocks
|
||
// are hidden in both the preview and the exported PDF.
|
||
if (s.hideFrontmatter) {
|
||
md = stripFrontmatter(md);
|
||
}
|
||
|
||
// Prepend the file name as an H1 title when the option is enabled and a
|
||
// file is loaded. This mirrors Obsidian's built-in "Include file name as
|
||
// title" PDF export option, so the rendered document leads with the note
|
||
// title even when the markdown itself contains no top-level heading.
|
||
if (s.includeFilenameAsTitle && this.currentFile) {
|
||
md = `# ${this.currentFile.basename}\n\n${md}`;
|
||
}
|
||
|
||
md = PDFExportModal.insertAutoBreaks(md, s.autoBreakH1, s.autoBreakH2);
|
||
|
||
const sections = splitMarkdownSections(md);
|
||
const dims = PAGE_SIZES[s.pageSize] ?? PAGE_SIZES["A4"];
|
||
const pw = s.orientation === "landscape" ? dims.h : dims.w;
|
||
const ph = s.orientation === "landscape" ? dims.w : dims.h;
|
||
|
||
const mTop = mmToPx(s.marginTop);
|
||
const mBottom = mmToPx(s.marginBottom);
|
||
const mLeft = mmToPx(s.marginLeft);
|
||
const mRight = mmToPx(s.marginRight);
|
||
const footerH = s.showFooter ? 28 : 0;
|
||
const headerH = s.showHeader && s.headerText ? 20 : 0;
|
||
const contentW = pw - mLeft - mRight;
|
||
const contentH = ph - mTop - mBottom - footerH - headerH;
|
||
const docCSS = buildDocCSS(s);
|
||
const sourcePath = this.currentFile?.path ?? "pdf-export";
|
||
|
||
const sectionHtml = await Promise.all(
|
||
sections.map((sec) => renderMarkdownToHtml(this.app, sec.trim(), sourcePath, this.renderComponent)),
|
||
);
|
||
|
||
// Bail if a newer render has been requested while we were awaiting.
|
||
if (token !== this.renderToken) return;
|
||
|
||
const allPages: HTMLElement[][] = [];
|
||
for (const html of sectionHtml) {
|
||
allPages.push(...paginateHTML(html, contentW, contentH, docCSS));
|
||
}
|
||
|
||
const layouts = buildPageLayouts(allPages, s);
|
||
const resolvedFont = s.fontFamily === "__custom__" ? (s.customFontName.trim() || "inherit") : s.fontFamily;
|
||
this.layoutCache = { layouts, pw, ph, mTop, mLeft, footerH, headerH, contentW, contentH, docCSS, fontFamily: resolvedFont, accentColor: s.accentColor, pageBackground: s.pageBackground };
|
||
|
||
this.drawPreview(this.layoutCache, s.previewScale);
|
||
this.pageCountEl.textContent = `${layouts.length} page${layouts.length !== 1 ? "s" : ""}`;
|
||
// Render finished — restore the UI to its interactive state.
|
||
this.hideLoading();
|
||
}
|
||
|
||
// Re-draw from the cached layout without re-paginating (zoom-only change).
|
||
private renderPreviewOnly() {
|
||
if (!this.layoutCache) return;
|
||
this.drawPreview(this.layoutCache, this.plugin.settings.previewScale);
|
||
}
|
||
|
||
// ── Loading state helpers ────────────────────────────────────────────────────
|
||
|
||
/** Show the spinning overlay and disable the Render button. */
|
||
private showLoading() {
|
||
this.loadingOverlayEl.style.display = "flex";
|
||
this.renderBtn.disabled = true;
|
||
this.renderBtn.textContent = "Rendering…";
|
||
}
|
||
|
||
/** Hide the spinning overlay and restore the Render button. */
|
||
private hideLoading() {
|
||
this.loadingOverlayEl.style.display = "none";
|
||
this.renderBtn.disabled = false;
|
||
this.renderBtn.textContent = "⟳ Render PDF";
|
||
}
|
||
|
||
private drawPreview(c: LayoutCache, scale: number) {
|
||
const { layouts, pw, ph, mTop, mLeft, footerH, headerH, contentW, contentH, docCSS, fontFamily, accentColor, pageBackground } = c;
|
||
const s = this.plugin.settings;
|
||
this.previewEl.empty();
|
||
|
||
// Build heading id → page-wrap map so anchor clicks can scroll to the right page.
|
||
// We index by page-wrap index so we can call scrollIntoView on the *wrapper*
|
||
// (which lives in the light DOM and is findable), not on the element inside the
|
||
// shadow root (which querySelector on the light tree cannot reach).
|
||
const idToWrapIndex = new Map<string, number>();
|
||
layouts.forEach((layout, i) => {
|
||
for (const node of layout.pageNodes) {
|
||
node.querySelectorAll("[id]").forEach((el) => {
|
||
if (!idToWrapIndex.has(el.id)) idToWrapIndex.set(el.id, i);
|
||
});
|
||
if (node.id && !idToWrapIndex.has(node.id)) idToWrapIndex.set(node.id, i);
|
||
}
|
||
});
|
||
|
||
// Keep references to the page-wrap elements so anchor clicks can find them.
|
||
const pageWraps: HTMLElement[] = [];
|
||
|
||
for (const layout of layouts) {
|
||
const scaledW = Math.round(pw * scale);
|
||
const scaledH = Math.round(ph * scale);
|
||
|
||
const wrap = this.previewEl.createEl("div", { cls: "mpdf-page-wrap" });
|
||
wrap.style.cssText = `width:${scaledW}px;height:${scaledH}px;position:relative;flex-shrink:0;`;
|
||
wrap.createEl("div", { cls: "mpdf-page-label", text: `Page ${layout.pageNum} of ${layout.totalPages}` });
|
||
pageWraps.push(wrap);
|
||
|
||
const scaleWrap = wrap.createEl("div", { cls: "mpdf-page-scale" });
|
||
scaleWrap.style.cssText = `width:${scaledW}px;height:${scaledH}px;overflow:hidden;position:relative;`;
|
||
|
||
// ── Shadow host ──────────────────────────────────────────────────────────
|
||
// Each page is rendered inside a shadow root so that Obsidian's theme CSS
|
||
// is physically prevented from reaching the page content. External CSS
|
||
// cannot cross the shadow boundary — only explicitly inherited CSS custom
|
||
// properties can, and our generated CSS uses no variables at all, only
|
||
// fully-resolved values from the active preset. The shadow host takes the
|
||
// place of the old .mpdf-page div; its box-shadow and background are now
|
||
// declared inside the :host rule within the shadow stylesheet.
|
||
const shadowHost = document.createElement("div");
|
||
shadowHost.style.cssText = [
|
||
`width:${pw}px`, `height:${ph}px`,
|
||
`transform:scale(${scale})`, "transform-origin:top left",
|
||
"position:absolute", "top:0", "left:0",
|
||
].join(";");
|
||
scaleWrap.appendChild(shadowHost);
|
||
|
||
const shadow = shadowHost.attachShadow({ mode: "open" });
|
||
|
||
// Scoped stylesheet: :host supplies the page background, shadow and
|
||
// box-model reset; the rest is the same docCSS used by the PDF export.
|
||
const styleEl = document.createElement("style");
|
||
styleEl.textContent = `
|
||
:host {
|
||
display: block;
|
||
width: ${pw}px;
|
||
height: ${ph}px;
|
||
background: ${pageBackground};
|
||
box-shadow: 0 2px 8px rgba(0,0,0,.30), 0 8px 32px rgba(0,0,0,.25);
|
||
overflow: hidden;
|
||
position: relative;
|
||
box-sizing: border-box;
|
||
}
|
||
*, *::before, *::after { box-sizing: border-box; }
|
||
${docCSS}
|
||
`;
|
||
shadow.appendChild(styleEl);
|
||
|
||
// ── Header ───────────────────────────────────────────────────────────────
|
||
const hasHeader = s.showHeader && (layout.headerLeft || layout.headerCenter || layout.headerRight);
|
||
if (hasHeader) {
|
||
const hdr = document.createElement("div");
|
||
hdr.style.cssText = [
|
||
"position:absolute", `top:${mTop * 0.4}px`, `left:${mLeft}px`, `right:${mLeft}px`,
|
||
"display:flex", "align-items:center",
|
||
"font-size:9px", "color:#999", `font-family:${fontFamily}`, "white-space:nowrap",
|
||
].join(";");
|
||
if (layout.headerCenter) {
|
||
const span = document.createElement("span");
|
||
span.style.cssText = "flex:1;text-align:center;";
|
||
span.textContent = layout.headerCenter;
|
||
hdr.appendChild(span);
|
||
} else {
|
||
const leftSpan = document.createElement("span");
|
||
leftSpan.textContent = layout.headerLeft;
|
||
hdr.appendChild(leftSpan);
|
||
const rightSpan = document.createElement("span");
|
||
rightSpan.style.marginLeft = "auto";
|
||
rightSpan.textContent = layout.headerRight;
|
||
hdr.appendChild(rightSpan);
|
||
}
|
||
shadow.appendChild(hdr);
|
||
}
|
||
|
||
// ── Content ──────────────────────────────────────────────────────────────
|
||
const contentDiv = document.createElement("div");
|
||
contentDiv.className = "mpdf-doc";
|
||
contentDiv.style.cssText = [
|
||
"position:absolute", `top:${mTop + headerH}px`, `left:${mLeft}px`,
|
||
`width:${contentW}px`, `height:${contentH}px`, "overflow:hidden",
|
||
].join(";");
|
||
for (const node of layout.pageNodes) contentDiv.appendChild(node.cloneNode(true));
|
||
shadow.appendChild(contentDiv);
|
||
|
||
// Wire internal anchor links. Because content is inside a shadow root,
|
||
// we scroll the *page-wrap* (light DOM) into view instead of the element.
|
||
contentDiv.querySelectorAll<HTMLAnchorElement>("a[href^='#']").forEach((a) => {
|
||
const targetId = decodeURIComponent(a.getAttribute("href")!.slice(1));
|
||
const wrapIdx = idToWrapIndex.get(targetId);
|
||
if (wrapIdx !== undefined) {
|
||
a.title = `Go to page ${wrapIdx + 1}`;
|
||
a.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
pageWraps[wrapIdx]?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
});
|
||
}
|
||
});
|
||
|
||
// ── Footer ───────────────────────────────────────────────────────────────
|
||
if (s.showFooter) {
|
||
const footer = document.createElement("div");
|
||
footer.style.cssText = [
|
||
"position:absolute", "bottom:0", "left:0", "right:0",
|
||
`height:${footerH}px`, "display:flex", "align-items:center",
|
||
`border-top:0.5px solid ${accentColor}33`,
|
||
`padding:0 ${mLeft}px`, "font-size:9px", "color:#aaa", `font-family:${fontFamily}`,
|
||
].join(";");
|
||
|
||
if (layout.footerCenter) {
|
||
const span = document.createElement("span");
|
||
span.style.cssText = "flex:1;text-align:center;";
|
||
span.textContent = layout.footerCenter;
|
||
footer.appendChild(span);
|
||
} else {
|
||
const left = document.createElement("span");
|
||
left.textContent = layout.footerLeft;
|
||
footer.appendChild(left);
|
||
const right = document.createElement("span");
|
||
right.style.marginLeft = "auto";
|
||
right.textContent = layout.footerRight;
|
||
footer.appendChild(right);
|
||
}
|
||
shadow.appendChild(footer);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Export ──────────────────────────────────────────────────────────────────
|
||
//
|
||
// Reuses the same paginated page nodes from the last preview render, so the
|
||
// exported PDF is guaranteed to be pixel-identical to what the user sees.
|
||
// Each page becomes a fixed-size <div> in a standalone HTML document which
|
||
// Electron's printToPDF renders with @page margin: none.
|
||
|
||
private async exportPDF() {
|
||
const s = this.plugin.settings;
|
||
|
||
// Disable the export button immediately so the user knows work is happening.
|
||
this.exportBtn.disabled = true;
|
||
this.exportBtn.textContent = "⬇ Exporting…";
|
||
|
||
// Helper to restore the button in every exit path.
|
||
const resetExportBtn = () => {
|
||
this.exportBtn.disabled = false;
|
||
this.exportBtn.textContent = "⬇ Export PDF";
|
||
};
|
||
|
||
// Ensure we have a layout — run a full render if the modal was just opened.
|
||
// We skip requestAnimationFrame here because the export button is already
|
||
// disabled; we don't need to yield for a paint before starting the work.
|
||
if (!this.layoutCache) {
|
||
// Cancel any pending debounced render — this export render supersedes it.
|
||
if (this.renderDebounceTimer !== null) {
|
||
clearTimeout(this.renderDebounceTimer);
|
||
this.renderDebounceTimer = null;
|
||
}
|
||
const token = ++this.renderToken;
|
||
this.showLoading();
|
||
try {
|
||
await this.doRender(token);
|
||
} catch (err: unknown) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
new Notice("Render error: " + msg);
|
||
this.hideLoading();
|
||
resetExportBtn();
|
||
return;
|
||
}
|
||
}
|
||
|
||
const cache = this.layoutCache;
|
||
if (!cache || cache.layouts.length === 0) {
|
||
new Notice("Nothing to export.");
|
||
resetExportBtn();
|
||
return;
|
||
}
|
||
|
||
const { layouts, pw, ph, mTop, mLeft, footerH, headerH, contentW, docCSS, fontFamily, accentColor: exportAccent, pageBackground } = cache;
|
||
|
||
const pageHTMLParts = layouts.map((layout) => {
|
||
const contentHTML = layout.pageNodes.map((n) => n.outerHTML).join("\n");
|
||
|
||
const hasExportHeader = s.showHeader && (layout.headerLeft || layout.headerCenter || layout.headerRight);
|
||
const headerInnerHTML = layout.headerCenter
|
||
? `<span style="flex:1;text-align:center;">${escapeHTML(layout.headerCenter)}</span>`
|
||
: `<span>${escapeHTML(layout.headerLeft)}</span><span style="margin-left:auto;">${escapeHTML(layout.headerRight)}</span>`;
|
||
const headerHTML = hasExportHeader
|
||
? `<div style="position:absolute;top:${mTop * 0.4}px;left:${mLeft}px;right:${mLeft}px;display:flex;align-items:center;font-size:9px;color:#999;font-family:${fontFamily};white-space:nowrap;">${headerInnerHTML}</div>`
|
||
: "";
|
||
|
||
const footerInnerHTML = layout.footerCenter
|
||
? `<span style="flex:1;text-align:center;">${escapeHTML(layout.footerCenter)}</span>`
|
||
: `<span>${escapeHTML(layout.footerLeft)}</span><span style="margin-left:auto;">${escapeHTML(layout.footerRight)}</span>`;
|
||
|
||
const footerHTML = s.showFooter
|
||
? `<div style="position:absolute;bottom:0;left:0;right:0;height:${footerH}px;display:flex;align-items:center;border-top:0.5px solid ${exportAccent}33;padding:0 ${mLeft}px;font-size:9px;color:#aaa;font-family:${fontFamily};">${footerInnerHTML}</div>`
|
||
: "";
|
||
|
||
const contentDivHTML = `<div class="mpdf-doc" style="position:absolute;top:${mTop + headerH}px;left:${mLeft}px;width:${contentW}px;">${contentHTML}</div>`;
|
||
|
||
return `<div class="mpdf-export-page">${headerHTML}${contentDivHTML}${footerHTML}</div>`;
|
||
});
|
||
|
||
const printCSS = `
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
@page { size: ${pw}px ${ph}px; margin: 0; }
|
||
html, body { margin: 0; padding: 0; background: ${pageBackground}; }
|
||
.mpdf-export-page {
|
||
position: relative;
|
||
width: ${pw}px; height: ${ph}px;
|
||
overflow: hidden;
|
||
background: ${pageBackground};
|
||
page-break-after: always; break-after: page;
|
||
}
|
||
.mpdf-export-page:last-child { page-break-after: avoid; break-after: avoid; }
|
||
${docCSS}
|
||
`;
|
||
|
||
const fullHTML = `<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>${escapeHTML(this.currentFile?.basename ?? "Export")}</title>
|
||
<style>${printCSS}</style>
|
||
</head>
|
||
<body>
|
||
${pageHTMLParts.join("\n")}
|
||
</body>
|
||
</html>`;
|
||
|
||
try {
|
||
// In Obsidian (Electron 14+) the `remote` module was moved out of the
|
||
// core `electron` package into the separate `@electron/remote` package.
|
||
// `electron.remote` is `undefined` on Electron 14+ (including Electron 39
|
||
// which ships with current Obsidian), so the old fallback
|
||
// `electron.remote || electron` silently used the plain `electron` object
|
||
// which has no `dialog` or `BrowserWindow` in the renderer process,
|
||
// causing every export to fail with the wrong error message.
|
||
const remote = (window as any).require("@electron/remote");
|
||
if (!remote?.dialog) throw new Error("no remote");
|
||
|
||
const res: { canceled: boolean; filePath?: string } = await remote.dialog.showSaveDialog({
|
||
title: "Save PDF",
|
||
defaultPath: (this.currentFile?.basename ?? "export") + ".pdf",
|
||
filters: [{ name: "PDF", extensions: ["pdf"] }],
|
||
});
|
||
if (res.canceled || !res.filePath) {
|
||
resetExportBtn();
|
||
return;
|
||
}
|
||
|
||
// Let the user know the hidden window is generating the PDF.
|
||
new Notice("Generating PDF…");
|
||
|
||
const blob = new Blob([fullHTML], { type: "text/html" });
|
||
const url = URL.createObjectURL(blob);
|
||
const win = new (remote.BrowserWindow)({ show: false, webPreferences: { nodeIntegration: false } });
|
||
|
||
win.loadURL(url);
|
||
|
||
// Guard: both did-fail-load (subresource errors) and did-finish-load can fire
|
||
// for the same page load. This flag ensures we only act on the first one.
|
||
let exportHandled = false;
|
||
const cleanupWin = () => { URL.revokeObjectURL(url); win.close(); resetExportBtn(); };
|
||
|
||
// Guard against the page failing to load (OOM, sandbox restrictions, etc.).
|
||
// Without this, did-finish-load never fires and the UI permanently locks.
|
||
win.webContents.once("did-fail-load", (_event: unknown, _code: number, desc: string) => {
|
||
if (exportHandled) return;
|
||
exportHandled = true;
|
||
new Notice("PDF load error: " + desc);
|
||
cleanupWin();
|
||
});
|
||
|
||
win.webContents.once("did-finish-load", () => {
|
||
if (exportHandled) return;
|
||
exportHandled = true;
|
||
win.webContents
|
||
.printToPDF({ pageSize: s.pageSize, landscape: s.orientation === "landscape", printBackground: true, margins: { marginType: "none" } })
|
||
.then((data: Buffer) => {
|
||
(window as any).require("fs").writeFile(res.filePath!, data, (err: Error | null) => {
|
||
if (err) new Notice("Error saving PDF: " + err.message);
|
||
else new Notice("✓ PDF saved: " + res.filePath);
|
||
cleanupWin();
|
||
});
|
||
})
|
||
.catch((err: Error) => {
|
||
new Notice("PDF render error: " + err.message);
|
||
cleanupWin();
|
||
});
|
||
});
|
||
} catch {
|
||
new Notice("Advanced PDF export requires the Obsidian desktop app.");
|
||
resetExportBtn();
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Settings tab ─────────────────────────────────────────────────────────────
|
||
|
||
class PDFExportSettingTab extends PluginSettingTab {
|
||
plugin: MarkdownPDFPlugin;
|
||
|
||
/**
|
||
* Set to `true` whenever the user changes any setting while this tab is open.
|
||
* The live preview modal is re-rendered exactly once when the tab closes,
|
||
* rather than on every individual keystroke or toggle.
|
||
*/
|
||
private dirty = false;
|
||
|
||
constructor(app: App, plugin: MarkdownPDFPlugin) {
|
||
super(app, plugin);
|
||
this.plugin = plugin;
|
||
}
|
||
|
||
/** Obsidian calls this when the tab becomes visible. */
|
||
display(): void {
|
||
// Start fresh: if nothing changes before hide(), no render will happen.
|
||
this.dirty = false;
|
||
this.buildSettings();
|
||
}
|
||
|
||
/**
|
||
* Obsidian calls this when the user navigates away from the tab or closes
|
||
* the settings modal. If any setting was changed we fire a single render
|
||
* now, which is far cheaper than rendering on every keystroke.
|
||
*/
|
||
hide(): void {
|
||
if (this.dirty) {
|
||
this.dirty = false;
|
||
this.plugin.activeModal?.render(true);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Saves the current settings to disk and records that a re-render is
|
||
* pending. The actual render is deferred until `hide()` fires.
|
||
*/
|
||
private async markDirty(): Promise<void> {
|
||
this.dirty = true;
|
||
await this.plugin.saveSettings();
|
||
}
|
||
|
||
/**
|
||
* Builds (or re-builds) the settings UI. Extracted from `display()` so
|
||
* that preset / reset handlers can refresh the form without resetting the
|
||
* dirty flag back to `false`.
|
||
*/
|
||
private buildSettings(): void {
|
||
const { containerEl } = this;
|
||
containerEl.empty();
|
||
const s = this.plugin.settings;
|
||
|
||
containerEl.createEl("h2", { text: "Advanced PDF Export" });
|
||
|
||
// ── Style Preset ──────────────────────────────────────────────────────────
|
||
containerEl.createEl("h3", { text: "Style Preset" });
|
||
new Setting(containerEl)
|
||
.setName("Preset")
|
||
.setDesc("Pick a preset to configure the overall document style. Fine-tune any setting below.")
|
||
.addDropdown((d) => {
|
||
Object.entries(PRESETS).forEach(([k, v]) => d.addOption(k, v.name));
|
||
d.setValue(s.preset).onChange(async (v) => {
|
||
this.plugin.applyPreset(v);
|
||
await this.markDirty();
|
||
// Refresh the form so all controls show the new preset's values,
|
||
// but do NOT call display() — that would reset the dirty flag.
|
||
this.buildSettings();
|
||
});
|
||
})
|
||
.addButton((b) =>
|
||
b.setButtonText("Reset Preset")
|
||
.setTooltip("Reset current preset to its default values")
|
||
.onClick(async () => {
|
||
this.plugin.applyPreset(s.preset);
|
||
await this.markDirty();
|
||
this.buildSettings();
|
||
}),
|
||
);
|
||
|
||
// ── Page ──────────────────────────────────────────────────────────────────
|
||
containerEl.createEl("h3", { text: "Page" });
|
||
new Setting(containerEl).setName("Page size").addDropdown((d) => {
|
||
Object.keys(PAGE_SIZES).forEach((k) => d.addOption(k, k));
|
||
d.setValue(s.pageSize).onChange(async (v) => {
|
||
s.pageSize = v;
|
||
await this.markDirty();
|
||
});
|
||
});
|
||
new Setting(containerEl).setName("Orientation").addDropdown((d) =>
|
||
d.addOptions({ portrait: "Portrait", landscape: "Landscape" })
|
||
.setValue(s.orientation)
|
||
.onChange(async (v) => {
|
||
s.orientation = v as "portrait" | "landscape";
|
||
await this.markDirty();
|
||
}),
|
||
);
|
||
|
||
// ── Margins ───────────────────────────────────────────────────────────────
|
||
containerEl.createEl("h3", { text: "Margins (mm)" });
|
||
const marginSetting = (name: string, key: keyof PDFExportSettings) =>
|
||
new Setting(containerEl).setName(name).addText((t) =>
|
||
t.setValue(String(s[key])).onChange(async (v) => {
|
||
(s as any)[key] = parseInt(v) || 0;
|
||
await this.markDirty();
|
||
}),
|
||
);
|
||
marginSetting("Top", "marginTop");
|
||
marginSetting("Bottom", "marginBottom");
|
||
marginSetting("Left", "marginLeft");
|
||
marginSetting("Right", "marginRight");
|
||
|
||
// ── Typography ────────────────────────────────────────────────────────────
|
||
containerEl.createEl("h3", { text: "Typography" });
|
||
let customFontSetting: Setting;
|
||
new Setting(containerEl).setName("Font family").addDropdown((d) =>
|
||
d.addOptions({
|
||
"Georgia, serif": "Georgia (Serif)",
|
||
"'Times New Roman', Times, serif": "Times New Roman",
|
||
"'Palatino Linotype', Palatino, serif": "Palatino",
|
||
"Arial, sans-serif": "Arial",
|
||
"'Helvetica Neue', Helvetica, sans-serif": "Helvetica",
|
||
"'Trebuchet MS', sans-serif": "Trebuchet",
|
||
"'Courier New', monospace": "Courier New",
|
||
"__custom__": "Custom…",
|
||
}).setValue(s.fontFamily)
|
||
.onChange(async (v) => {
|
||
s.fontFamily = v;
|
||
customFontSetting.settingEl.style.display = v === "__custom__" ? "" : "none";
|
||
await this.markDirty();
|
||
}),
|
||
);
|
||
customFontSetting = new Setting(containerEl)
|
||
.setName("Custom font name")
|
||
.setDesc("CSS font-family value — e.g. \"Inter, sans-serif\". The font must be installed on your system.")
|
||
.addText((t) =>
|
||
t.setPlaceholder("e.g. Inter, sans-serif")
|
||
.setValue(s.customFontName)
|
||
.onChange(async (v) => { s.customFontName = v; await this.markDirty(); }),
|
||
);
|
||
customFontSetting.settingEl.style.display = s.fontFamily === "__custom__" ? "" : "none";
|
||
new Setting(containerEl).setName("Font size (px)").addDropdown((d) => {
|
||
["10","11","12","13","14","15","16"].forEach((v) => d.addOption(v, v + "px"));
|
||
d.setValue(String(s.fontSize)).onChange(async (v) => {
|
||
s.fontSize = parseInt(v);
|
||
await this.markDirty();
|
||
});
|
||
});
|
||
new Setting(containerEl).setName("Code font size").addDropdown((d) =>
|
||
d.addOptions({ "0.75": "0.75em", "0.80": "0.80em", "0.82": "0.82em", "0.85": "0.85em", "0.88": "0.88em", "0.90": "0.90em", "1.0": "1.00em" })
|
||
.setValue(String(s.codeFontSize))
|
||
.onChange(async (v) => { s.codeFontSize = parseFloat(v); await this.markDirty(); }),
|
||
);
|
||
new Setting(containerEl).setName("Line height").addDropdown((d) =>
|
||
d.addOptions({ "1.4": "Tight (1.4)", "1.6": "Compact (1.6)", "1.75": "Normal (1.75)", "1.85": "Relaxed (1.85)", "2.0": "Double (2.0)" })
|
||
.setValue(String(s.lineHeight))
|
||
.onChange(async (v) => { s.lineHeight = parseFloat(v); await this.markDirty(); }),
|
||
);
|
||
new Setting(containerEl).setName("Paragraph spacing").addDropdown((d) =>
|
||
d.addOptions({ "0": "None", "0.3": "Tight (0.3em)", "0.5": "Normal (0.5em)", "0.65": "Relaxed (0.65em)", "1.0": "Wide (1em)" })
|
||
.setValue(String(s.paragraphSpacing))
|
||
.onChange(async (v) => { s.paragraphSpacing = parseFloat(v); await this.markDirty(); }),
|
||
);
|
||
new Setting(containerEl)
|
||
.setName("Heading scale")
|
||
.setDesc("Multiplier applied to all heading sizes.")
|
||
.addDropdown((d) =>
|
||
d.addOptions({ "0.8": "Small (0.8×)", "0.88": "0.88×", "0.9": "Compact (0.9×)", "0.95": "0.95×", "1.0": "Normal (1.0×)", "1.05": "1.05×", "1.1": "Large (1.1×)", "1.2": "XLarge (1.2×)" })
|
||
.setValue(String(s.headingScale))
|
||
.onChange(async (v) => { s.headingScale = parseFloat(v); await this.markDirty(); }),
|
||
);
|
||
|
||
// ── Colors ────────────────────────────────────────────────────────────────
|
||
containerEl.createEl("h3", { text: "Colors" });
|
||
const colorSetting = (name: string, key: keyof PDFExportSettings) =>
|
||
new Setting(containerEl).setName(name).addColorPicker((cp) =>
|
||
cp.setValue(String(s[key])).onChange(async (v) => {
|
||
(s as any)[key] = v;
|
||
await this.markDirty();
|
||
}),
|
||
);
|
||
colorSetting("Accent color", "accentColor");
|
||
colorSetting("Body text color", "bodyColor");
|
||
colorSetting("Heading color", "headingColor");
|
||
colorSetting("Page background", "pageBackground");
|
||
colorSetting("Blockquote background", "blockquoteBg");
|
||
colorSetting("Blockquote border", "blockquoteBorderColor");
|
||
colorSetting("Table header background", "tableHeaderBg");
|
||
colorSetting("Code background", "codeBackground");
|
||
|
||
// ── Heading style ─────────────────────────────────────────────────────────
|
||
containerEl.createEl("h3", { text: "Heading Style" });
|
||
new Setting(containerEl).setName("H1 bottom border").addToggle((t) =>
|
||
t.setValue(s.h1BorderBottom).onChange(async (v) => { s.h1BorderBottom = v; await this.markDirty(); }),
|
||
);
|
||
new Setting(containerEl).setName("H2 bottom border").addToggle((t) =>
|
||
t.setValue(s.h2BorderBottom).onChange(async (v) => { s.h2BorderBottom = v; await this.markDirty(); }),
|
||
);
|
||
new Setting(containerEl).setName("Center H1").addToggle((t) =>
|
||
t.setValue(s.centerH1).onChange(async (v) => { s.centerH1 = v; await this.markDirty(); }),
|
||
);
|
||
|
||
// ── Tables ────────────────────────────────────────────────────────────────
|
||
containerEl.createEl("h3", { text: "Tables" });
|
||
new Setting(containerEl).setName("Striped rows").addToggle((t) =>
|
||
t.setValue(s.tableStriped).onChange(async (v) => { s.tableStriped = v; await this.markDirty(); }),
|
||
);
|
||
|
||
// ── Header & Footer ───────────────────────────────────────────────────────
|
||
containerEl.createEl("h3", { text: "Header & Footer" });
|
||
new Setting(containerEl).setName("Show header").addToggle((t) =>
|
||
t.setValue(s.showHeader).onChange(async (v) => { s.showHeader = v; await this.markDirty(); }),
|
||
);
|
||
new Setting(containerEl)
|
||
.setName("Header text")
|
||
.setDesc("Appears on every page according to the chosen alignment.")
|
||
.addText((t) => t.setValue(s.headerText).onChange(async (v) => { s.headerText = v; await this.markDirty(); }));
|
||
new Setting(containerEl).setName("Header alignment").addDropdown((d) =>
|
||
d.addOptions({ left: "Left", center: "Center", right: "Right" })
|
||
.setValue(s.headerAlignment)
|
||
.onChange(async (v) => { s.headerAlignment = v as "left"|"center"|"right"; await this.markDirty(); }),
|
||
);
|
||
new Setting(containerEl).setName("Show footer").addToggle((t) =>
|
||
t.setValue(s.showFooter).onChange(async (v) => { s.showFooter = v; await this.markDirty(); }),
|
||
);
|
||
new Setting(containerEl)
|
||
.setName("Footer text")
|
||
.addText((t) => t.setValue(s.footerText).onChange(async (v) => { s.footerText = v; await this.markDirty(); }));
|
||
new Setting(containerEl).setName("Show page numbers").addToggle((t) =>
|
||
t.setValue(s.showPageNumbers).onChange(async (v) => { s.showPageNumbers = v; await this.markDirty(); }),
|
||
);
|
||
new Setting(containerEl).setName("Page number position").addDropdown((d) =>
|
||
d.addOptions({ left: "Left", center: "Center", right: "Right" })
|
||
.setValue(s.pageNumberPosition)
|
||
.onChange(async (v) => { s.pageNumberPosition = v as "left"|"center"|"right"; await this.markDirty(); }),
|
||
);
|
||
new Setting(containerEl)
|
||
.setName("Page number start")
|
||
.setDesc("Number assigned to the first visible page number. Accepts any integer.")
|
||
.addText((t) =>
|
||
t.setValue(String(s.pageNumberStart))
|
||
.onChange(async (v) => {
|
||
const n = parseInt(v, 10);
|
||
s.pageNumberStart = isNaN(n) ? 1 : n;
|
||
await this.markDirty();
|
||
}),
|
||
);
|
||
new Setting(containerEl)
|
||
.setName("Header/footer on first page")
|
||
.setDesc("When off, page 1 has no header, footer, or page number. Numbering begins on page 2 using the start value.")
|
||
.addToggle((t) =>
|
||
t.setValue(s.showHeaderFooterOnFirstPage).onChange(async (v) => {
|
||
s.showHeaderFooterOnFirstPage = v;
|
||
await this.markDirty();
|
||
}),
|
||
);
|
||
|
||
// ── Behaviour ─────────────────────────────────────────────────────────────
|
||
containerEl.createEl("h3", { text: "Behaviour" });
|
||
new Setting(containerEl)
|
||
.setName("Hide frontmatter")
|
||
.setDesc("Strip the YAML frontmatter block (--- … ---) from the preview and exported PDF.")
|
||
.addToggle((t) =>
|
||
t.setValue(s.hideFrontmatter).onChange(async (v) => {
|
||
s.hideFrontmatter = v;
|
||
await this.markDirty();
|
||
}),
|
||
);
|
||
new Setting(containerEl)
|
||
.setName("Include file name as title")
|
||
.setDesc(
|
||
"Prepend the note's file name as an H1 heading at the top of the PDF. " +
|
||
"Mirrors Obsidian's built-in 'Include file name as title' export option.",
|
||
)
|
||
.addToggle((t) =>
|
||
t.setValue(s.includeFilenameAsTitle).onChange(async (v) => {
|
||
s.includeFilenameAsTitle = v;
|
||
await this.markDirty();
|
||
}),
|
||
);
|
||
new Setting(containerEl).setName("Auto page break before H1").addToggle((t) =>
|
||
t.setValue(s.autoBreakH1).onChange(async (v) => { s.autoBreakH1 = v; await this.markDirty(); }),
|
||
);
|
||
new Setting(containerEl).setName("Auto page break before H2").addToggle((t) =>
|
||
t.setValue(s.autoBreakH2).onChange(async (v) => { s.autoBreakH2 = v; await this.markDirty(); }),
|
||
);
|
||
}
|
||
}
|