mirror of
https://github.com/shrekbytes/advanced-pdf-export.git
synced 2026-07-22 07:25:03 +00:00
2927 lines
119 KiB
TypeScript
2927 lines
119 KiB
TypeScript
import {
|
||
App, Component, MarkdownRenderer, MarkdownView, Menu, Modal, Notice,
|
||
Plugin, PluginSettingTab, Setting, TFile, setIcon,
|
||
} from "obsidian";
|
||
import { PDFArray, PDFDict, PDFDocument, PDFHexString, PDFName, PDFNull, PDFNumber } from "pdf-lib";
|
||
|
||
// ─── 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;
|
||
codeTheme: string;
|
||
tableHeaderBg: string;
|
||
tableStriped: boolean;
|
||
pageBackground: string;
|
||
marginTop: number;
|
||
marginBottom: number;
|
||
marginLeft: number;
|
||
marginRight: number;
|
||
}
|
||
|
||
// Minimal Electron type shims — just enough for the PDF export path.
|
||
|
||
interface ElectronBrowserWindow {
|
||
loadURL(url: string): void;
|
||
close(): void;
|
||
webContents: {
|
||
once(event: "did-fail-load", listener: (event: unknown, code: number, desc: string) => void): void;
|
||
once(event: "did-finish-load", listener: () => void): void;
|
||
printToPDF(options: {
|
||
pageSize: string;
|
||
landscape: boolean;
|
||
printBackground: boolean;
|
||
preferCSSPageSize?: boolean;
|
||
margins: { marginType: string };
|
||
}): Promise<Buffer>;
|
||
};
|
||
}
|
||
|
||
interface ElectronRemote {
|
||
dialog: {
|
||
showSaveDialog(options: {
|
||
title: string;
|
||
defaultPath: string;
|
||
filters: { name: string; extensions: string[] }[];
|
||
}): Promise<{ canceled: boolean; filePath?: string }>;
|
||
};
|
||
BrowserWindow: new (options: {
|
||
show: boolean;
|
||
webPreferences: { nodeIntegration: boolean };
|
||
}) => ElectronBrowserWindow;
|
||
}
|
||
|
||
interface ElectronFs {
|
||
writeFile(path: string, data: Buffer, cb: (err: Error | null) => void): void;
|
||
}
|
||
|
||
interface ElectronBridge {
|
||
require(module: "@electron/remote"): ElectronRemote;
|
||
require(module: "fs"): ElectronFs;
|
||
require(module: string): unknown;
|
||
}
|
||
|
||
/** One entry in the PDF outline/bookmark tree, extracted from the heading nodes. */
|
||
interface OutlineEntry {
|
||
title: string;
|
||
level: number; // 1–6 matching H1–H6
|
||
page: number; // 1-indexed page number in the exported PDF
|
||
}
|
||
|
||
interface AppWithSettings {
|
||
setting?: {
|
||
open?: () => void;
|
||
openTabById?: (id: string) => void;
|
||
};
|
||
}
|
||
|
||
interface PDFExportSettings extends DocStyle {
|
||
pageSize: string;
|
||
orientation: "portrait" | "landscape";
|
||
preset: string;
|
||
headerText: string;
|
||
footerText: string;
|
||
showHeader: boolean;
|
||
showFooter: boolean;
|
||
showHeaderBorder: boolean;
|
||
showFooterBorder: boolean;
|
||
showPageNumbers: boolean;
|
||
pageNumberPosition: "center" | "left" | "right";
|
||
pageNumberStart: number;
|
||
showHeaderOnFirstPage: boolean;
|
||
showFooterOnFirstPage: boolean;
|
||
headerAlignment: "left" | "center" | "right";
|
||
footerTextAlignment: "left" | "center" | "right";
|
||
headerFontSize: number;
|
||
headerFontColor: string;
|
||
footerFontSize: number;
|
||
footerFontColor: string;
|
||
linkUnderline: boolean;
|
||
frameEnabled: boolean;
|
||
frameColor: string;
|
||
frameThickness: number;
|
||
frameMargin: number;
|
||
frameStyle: "solid" | "dashed" | "dotted" | "double" | "groove" | "ridge";
|
||
hideFrontmatter: boolean;
|
||
customFontName: string;
|
||
autoBreakH1: boolean;
|
||
autoBreakH2: boolean;
|
||
includeFilenameAsTitle: boolean;
|
||
previewScale: number;
|
||
/** Width in mm, used only when pageSize === "Custom". */
|
||
customPageWidth: number;
|
||
/** Height in mm, used only when pageSize === "Custom". */
|
||
customPageHeight: number;
|
||
|
||
// ── Header/footer band sizing ─────────────────────────────────────────────────
|
||
/** Explicit header band height in px. 0 = auto (derived from headerFontSize). */
|
||
headerHeight: number;
|
||
/** Explicit footer band height in px. 0 = auto (derived from footerFontSize). */
|
||
footerHeight: number;
|
||
|
||
// ── Header/footer banner images ──────────────────────────────────────────────
|
||
/** Vault-relative path or https:// URL for a banner image behind the header text. */
|
||
headerImagePath: string;
|
||
/** Left/right gap in px between the banner image and the page edges (0 = edge-to-edge). */
|
||
headerImageMargin: number;
|
||
footerImagePath: string;
|
||
/** Left/right gap in px between the banner image and the page edges. */
|
||
footerImageMargin: number;
|
||
|
||
// ── Page background (color OR image — mutually exclusive) ─────────────────────
|
||
/** When true, backgroundImagePath is used instead of the pageBackground color. */
|
||
backgroundImageEnabled: boolean;
|
||
backgroundImagePath: string;
|
||
/** How the image fills the background zone. */
|
||
backgroundImageSize: "cover" | "contain" | "fill" | "tile";
|
||
/** "full-page": behind header+content+footer. "content-only": text zone only. */
|
||
backgroundImageScope: "full-page" | "content-only";
|
||
/** 0–1 opacity for the background image layer. */
|
||
backgroundImageOpacity: number;
|
||
/** When true, headings H1–H6 are embedded as a bookmark tree in the exported PDF. */
|
||
includeOutline: boolean;
|
||
}
|
||
|
||
// Cached layout — holds everything drawPreview and exportPDF need so that
|
||
// zoom changes can redraw without re-paginating.
|
||
interface LayoutCache {
|
||
layouts: PageLayout[];
|
||
pw: number;
|
||
ph: number;
|
||
mTop: number;
|
||
mLeft: number;
|
||
mRight: number;
|
||
footerH: number;
|
||
headerH: number;
|
||
contentW: number;
|
||
contentH: number;
|
||
docCSS: string;
|
||
fontFamily: string;
|
||
accentColor: string;
|
||
pageBackground: string;
|
||
isRTL: boolean;
|
||
}
|
||
|
||
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,
|
||
codeTheme: "github-light",
|
||
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,
|
||
codeTheme: "none",
|
||
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,
|
||
codeTheme: "solarized-light",
|
||
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,
|
||
codeTheme: "dracula",
|
||
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,
|
||
codeTheme: "github-dark",
|
||
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,
|
||
codeTheme: "none",
|
||
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,
|
||
codeTheme: "tokyo-night",
|
||
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,
|
||
showHeaderBorder: false,
|
||
showFooterBorder: false,
|
||
showPageNumbers: true,
|
||
pageNumberPosition: "right",
|
||
pageNumberStart: 1,
|
||
showHeaderOnFirstPage: true,
|
||
showFooterOnFirstPage: true,
|
||
headerAlignment: "right",
|
||
footerTextAlignment: "left",
|
||
headerFontSize: 9,
|
||
headerFontColor: "#999999",
|
||
footerFontSize: 9,
|
||
footerFontColor: "#aaaaaa",
|
||
linkUnderline: true,
|
||
frameEnabled: false,
|
||
frameColor: "#000000",
|
||
frameThickness: 4,
|
||
frameMargin: 8,
|
||
frameStyle: "solid",
|
||
hideFrontmatter: false,
|
||
customFontName: "",
|
||
autoBreakH1: false,
|
||
autoBreakH2: false,
|
||
includeFilenameAsTitle: false,
|
||
previewScale: 0.90,
|
||
customPageWidth: 210, // A4 width in mm
|
||
customPageHeight: 297, // A4 height in mm
|
||
// Band sizing (0 = auto from font size)
|
||
headerHeight: 0,
|
||
footerHeight: 0,
|
||
// Banner images (header / footer background)
|
||
headerImagePath: "",
|
||
headerImageMargin: 0,
|
||
footerImagePath: "",
|
||
footerImageMargin: 0,
|
||
// Page background
|
||
backgroundImageEnabled: false,
|
||
backgroundImagePath: "",
|
||
backgroundImageSize: "cover",
|
||
backgroundImageScope: "full-page",
|
||
backgroundImageOpacity: 1,
|
||
// Outline / bookmarks
|
||
includeOutline: true,
|
||
};
|
||
|
||
// ─── Utilities ────────────────────────────────────────────────────────────────
|
||
|
||
/** mm → px at 96 dpi, matching Electron's print pipeline. */
|
||
const mmToPx = (mm: number) => (mm / 25.4) * 96;
|
||
|
||
/**
|
||
* Returns the page dimensions in px for the current settings.
|
||
* For "Custom", converts the user-entered mm values; for named sizes, looks up
|
||
* PAGE_SIZES directly. Orientation swapping is handled by callers (doRender).
|
||
*/
|
||
function resolvePageDims(s: PDFExportSettings): { w: number; h: number } {
|
||
if (s.pageSize === "Custom") {
|
||
return {
|
||
w: Math.max(1, Math.round(mmToPx(s.customPageWidth))),
|
||
h: Math.max(1, Math.round(mmToPx(s.customPageHeight))),
|
||
};
|
||
}
|
||
return PAGE_SIZES[s.pageSize] ?? PAGE_SIZES["A4"];
|
||
}
|
||
|
||
function escapeHTML(s: string): string {
|
||
return s
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
// Escapes `</style` in CSS text to prevent the HTML parser from ending the
|
||
// <style> block early, regardless of where the sequence appears.
|
||
function escapeCSSForStyle(css: string): string {
|
||
return css.replace(/<\/style/gi, "<\\/style");
|
||
}
|
||
|
||
/** Converts a vault-relative path or bare filename to an app:// resource URL,
|
||
* or passes https:// / data: URLs through unchanged. Returns "" for blank input. */
|
||
function resolveImageUrl(app: App, imagePath: string): string {
|
||
const trimmed = imagePath.trim();
|
||
if (!trimmed) return "";
|
||
if (/^(https?:|data:)/i.test(trimmed)) return trimmed;
|
||
const direct = app.vault.getAbstractFileByPath(trimmed);
|
||
if (direct instanceof TFile) return app.vault.getResourcePath(direct);
|
||
const resolved = app.metadataCache.getFirstLinkpathDest(trimmed, "");
|
||
if (resolved instanceof TFile) return app.vault.getResourcePath(resolved);
|
||
return trimmed;
|
||
}
|
||
|
||
/** True when RTL script chars (Arabic, Hebrew, etc.) exceed 10 % of all
|
||
* alpha chars — ratio-based so mixed-script notes lean toward the majority. */
|
||
const RTL_CHARS = /[\u0590-\u08FF\uFB1D-\uFDFD\uFE70-\uFEFC]/g;
|
||
const TOTAL_ALPHA = /[A-Za-z\u0590-\u08FF\uFB1D-\uFDFD\uFE70-\uFEFC]/g;
|
||
function isRTLContent(text: string): boolean {
|
||
const rtl = (text.match(RTL_CHARS) ?? []).length;
|
||
const total = (text.match(TOTAL_ALPHA) ?? []).length;
|
||
return total > 0 && rtl / total > 0.1;
|
||
}
|
||
|
||
// ─── Markdown helpers ─────────────────────────────────────────────────────────
|
||
|
||
/** Normalises line endings to LF so the rest of the pipeline never sees CRLF. */
|
||
function normalizeMarkdown(raw: string): string {
|
||
return raw.replace(/\r\n/g, "\n");
|
||
}
|
||
|
||
/** Strips an opening YAML frontmatter block (--- … ---). Input must be LF-normalised. */
|
||
function stripFrontmatter(md: string): string {
|
||
return md.replace(/^---[ \t]*\n[\s\S]*?\n---[ \t]*(\n|$)/, "");
|
||
}
|
||
|
||
/** Splits on `///` manual page-break markers, trimming and dropping empty sections. */
|
||
function splitMarkdownSections(md: string): string[] {
|
||
return md
|
||
.split(/^\/\/\/\s*$/m)
|
||
.map((s) => s.trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
async function renderMarkdownToEl(
|
||
app: App,
|
||
markdown: string,
|
||
sourcePath: string,
|
||
component: Component,
|
||
): Promise<HTMLElement> {
|
||
const temp = activeDocument.createElement("div");
|
||
// Attach offscreen so Obsidian's async post-processors (mermaid, math) run in a real DOM context.
|
||
// Detached again before returning.
|
||
temp.setCssStyles({ position: "fixed", top: "0", left: "-99999px", visibility: "hidden", pointerEvents: "none" });
|
||
activeDocument.body.appendChild(temp);
|
||
try {
|
||
await MarkdownRenderer.render(app, markdown, temp, sourcePath, component);
|
||
await waitForMermaidDiagrams(temp);
|
||
} finally {
|
||
activeDocument.body.removeChild(temp);
|
||
}
|
||
postProcessRenderedHTML(temp);
|
||
return temp;
|
||
}
|
||
|
||
function slugifyHeading(text: string): string {
|
||
return text
|
||
.toLowerCase()
|
||
.replace(/[^\p{L}\p{N}\s-]/gu, "")
|
||
.trim()
|
||
.replace(/\s+/g, "-")
|
||
.replace(/-+/g, "-");
|
||
}
|
||
|
||
// Strips Obsidian-specific artefacts from rendered HTML so the output is
|
||
// clean for pagination and export: assigns stable heading IDs for anchor
|
||
// links, removes external-link decorators and copy-code buttons, force-expands
|
||
// callouts, and strips top-level theme <style>/<script> injections while
|
||
// preserving styles embedded inside SVGs (mermaid stores its theme CSS there).
|
||
function postProcessRenderedHTML(root: HTMLElement): void {
|
||
// Stable, deduplicated IDs so in-page anchor links work across shadow DOMs.
|
||
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);
|
||
el.id = count === 0 ? base : `${base}-${count}`;
|
||
});
|
||
|
||
// The external-link class triggers a ↗ icon via theme CSS — meaningless in print.
|
||
root.querySelectorAll<HTMLAnchorElement>("a").forEach((a) => {
|
||
a.classList.remove("external-link");
|
||
|
||
// Rewrite anchor hrefs to match the slugified IDs assigned above,
|
||
// covering both wikilink data-href and standard markdown anchors.
|
||
const target = a.getAttribute("data-href") ?? a.getAttribute("href");
|
||
if (target?.startsWith("#")) {
|
||
a.setAttribute("href", "#" + slugifyHeading(target.slice(1)));
|
||
}
|
||
});
|
||
|
||
root.querySelectorAll(".copy-code-button").forEach((el) => el.remove());
|
||
|
||
// Force-expand callouts: remove fold controls and collapsed state.
|
||
root.querySelectorAll<HTMLElement>(".callout").forEach((callout) => {
|
||
callout.removeAttribute("data-callout-fold");
|
||
callout.classList.remove("is-collapsed");
|
||
callout.querySelectorAll(".callout-fold").forEach((el) => el.remove());
|
||
});
|
||
|
||
// Drop theme-injected top-level <style>/<script> nodes — they can break the
|
||
// export <head> if they contain `</style>`, and are not needed in the PDF.
|
||
// Styles inside <svg> are kept: mermaid embeds its theme CSS directly there.
|
||
root.querySelectorAll("style, script").forEach((el) => {
|
||
if (!el.closest("svg")) el.remove();
|
||
});
|
||
}
|
||
|
||
/** Waits for mermaid code blocks to be converted to SVGs by Obsidian's post-processor.
|
||
* Resolves immediately if already rendered; times out per diagram after 5 s. */
|
||
async function waitForMermaidDiagrams(el: HTMLElement): Promise<void> {
|
||
const containers = Array.from(el.querySelectorAll<HTMLElement>(".mermaid"));
|
||
if (containers.length === 0) return;
|
||
const TIMEOUT_MS = 5000;
|
||
await Promise.all(
|
||
containers.map(
|
||
(m) =>
|
||
new Promise<void>((resolve) => {
|
||
if (m.querySelector("svg")) { resolve(); return; }
|
||
const timer = window.setTimeout(() => { obs.disconnect(); resolve(); }, TIMEOUT_MS);
|
||
const obs = new MutationObserver(() => {
|
||
if (m.querySelector("svg")) {
|
||
window.clearTimeout(timer);
|
||
obs.disconnect();
|
||
resolve();
|
||
}
|
||
});
|
||
obs.observe(m, { childList: true, subtree: true });
|
||
}),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ─── Code block syntax highlighting ────────────────────────────────────────
|
||
// Obsidian highlights fenced code with Prism `.token.<name>` spans, but the
|
||
// theme CSS that colours them is stripped for export — colours are reapplied
|
||
// here independently via the same classes.
|
||
|
||
type TokenGroup =
|
||
| "comment" | "punctuation" | "property" | "selector"
|
||
| "operator" | "keyword" | "function" | "regex";
|
||
|
||
// Groups Prism's token classes into the small set of categories most colour
|
||
// schemes distinguish. Multiple Prism classes commonly share one colour.
|
||
const TOKEN_GROUP_CLASSES: Record<TokenGroup, string[]> = {
|
||
comment: ["comment", "prolog", "doctype", "cdata"],
|
||
punctuation: ["punctuation"],
|
||
property: ["property", "tag", "boolean", "number", "constant", "symbol", "deleted", "attr-name"],
|
||
selector: ["selector", "string", "char", "builtin", "inserted", "attr-value"],
|
||
operator: ["operator", "entity", "url"],
|
||
keyword: ["atrule", "keyword", "important"],
|
||
function: ["function", "class-name"],
|
||
regex: ["regex", "variable"],
|
||
};
|
||
|
||
interface CodeColorTheme {
|
||
name: string;
|
||
/** `<pre>` background. Falls back to the doc style's "Code background" when unset. */
|
||
background?: string;
|
||
/** Base `<pre><code>` text color. Falls back to the doc style's "Body text color" when unset. */
|
||
text?: string;
|
||
tokens?: Partial<Record<TokenGroup, string>>;
|
||
}
|
||
|
||
const CODE_THEMES: Record<string, CodeColorTheme> = {
|
||
none: { name: "None (plain)" },
|
||
"github-light": {
|
||
name: "GitHub Light",
|
||
background: "#f6f8fa", text: "#24292e",
|
||
tokens: {
|
||
comment: "#6a737d", punctuation: "#24292e", property: "#005cc5",
|
||
selector: "#032f62", operator: "#d73a49", keyword: "#d73a49",
|
||
function: "#6f42c1", regex: "#032f62",
|
||
},
|
||
},
|
||
"github-dark": {
|
||
name: "GitHub Dark",
|
||
background: "#0d1117", text: "#c9d1d9",
|
||
tokens: {
|
||
comment: "#8b949e", punctuation: "#c9d1d9", property: "#79c0ff",
|
||
selector: "#a5d6ff", operator: "#ff7b72", keyword: "#ff7b72",
|
||
function: "#d2a8ff", regex: "#a5d6ff",
|
||
},
|
||
},
|
||
"atom-one-light": {
|
||
name: "Atom One Light",
|
||
background: "#fafafa", text: "#383a42",
|
||
tokens: {
|
||
comment: "#a0a1a7", punctuation: "#383a42", property: "#986801",
|
||
selector: "#50a14f", operator: "#0184bc", keyword: "#a626a4",
|
||
function: "#4078f2", regex: "#50a14f",
|
||
},
|
||
},
|
||
"atom-one-dark": {
|
||
name: "Atom One Dark",
|
||
background: "#282c34", text: "#abb2bf",
|
||
tokens: {
|
||
comment: "#5c6370", punctuation: "#abb2bf", property: "#d19a66",
|
||
selector: "#98c379", operator: "#56b6c2", keyword: "#c678dd",
|
||
function: "#61afef", regex: "#98c379",
|
||
},
|
||
},
|
||
monokai: {
|
||
name: "Monokai",
|
||
background: "#272822", text: "#f8f8f2",
|
||
tokens: {
|
||
comment: "#75715e", punctuation: "#f8f8f2", property: "#ae81ff",
|
||
selector: "#e6db74", operator: "#f92672", keyword: "#f92672",
|
||
function: "#a6e22e", regex: "#e6db74",
|
||
},
|
||
},
|
||
dracula: {
|
||
name: "Dracula",
|
||
background: "#282a36", text: "#f8f8f2",
|
||
tokens: {
|
||
comment: "#6272a4", punctuation: "#f8f8f2", property: "#bd93f9",
|
||
selector: "#f1fa8c", operator: "#ff79c6", keyword: "#ff79c6",
|
||
function: "#50fa7b", regex: "#ff79c6",
|
||
},
|
||
},
|
||
"tokyo-night": {
|
||
name: "Tokyo Night",
|
||
background: "#1a1b26", text: "#a9b1d6",
|
||
tokens: {
|
||
comment: "#565f89", punctuation: "#89ddff", property: "#ff9e64",
|
||
selector: "#9ece6a", operator: "#89ddff", keyword: "#bb9af7",
|
||
function: "#7aa2f7", regex: "#b4f9f8",
|
||
},
|
||
},
|
||
"solarized-light": {
|
||
name: "Solarized Light",
|
||
background: "#fdf6e3", text: "#586e75",
|
||
tokens: {
|
||
comment: "#93a1a1", punctuation: "#586e75", property: "#cb4b16",
|
||
selector: "#2aa198", operator: "#859900", keyword: "#859900",
|
||
function: "#268bd2", regex: "#2aa198",
|
||
},
|
||
},
|
||
"catppuccin-macchiato": {
|
||
name: "Catppuccin Macchiato",
|
||
background: "#24273a", text: "#cad3f5",
|
||
tokens: {
|
||
comment: "#939ab7", punctuation: "#b8c0e0", property: "#f5a97f",
|
||
selector: "#a6da95", operator: "#91d7e3", keyword: "#c6a0f6",
|
||
function: "#8aadf4", regex: "#f5bde6",
|
||
},
|
||
},
|
||
"catppuccin-mocha": {
|
||
name: "Catppuccin Mocha",
|
||
background: "#1e1e2e", text: "#cdd6f4",
|
||
tokens: {
|
||
comment: "#9399b2", punctuation: "#bac2de", property: "#fab387",
|
||
selector: "#a6e3a1", operator: "#89dceb", keyword: "#cba6f7",
|
||
function: "#89b4fa", regex: "#f5c2e7",
|
||
},
|
||
},
|
||
};
|
||
|
||
/** Builds the `<pre>`/`<pre><code>` rules plus Prism `.token.*` colour mappings
|
||
* for the selected code theme. "None" keeps the previous plain styling
|
||
* (code background + body color, no token colours). */
|
||
function buildCodeBlockCSS(s: PDFExportSettings): string {
|
||
const theme = CODE_THEMES[s.codeTheme] ?? CODE_THEMES.none;
|
||
const background = theme.background ?? s.codeBackground;
|
||
const text = theme.text ?? s.bodyColor;
|
||
|
||
const tokenRules = Object.entries(theme.tokens ?? {})
|
||
.map(([group, color]) => {
|
||
const selector = TOKEN_GROUP_CLASSES[group as TokenGroup]
|
||
.map((cls) => `.mpdf-doc pre code .token.${cls}`)
|
||
.join(", ");
|
||
const italic = group === "comment" ? " font-style: italic;" : "";
|
||
return `${selector} { color: ${color};${italic} }`;
|
||
})
|
||
.join("\n ");
|
||
|
||
return `
|
||
.mpdf-doc pre {
|
||
background: ${background};
|
||
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: ${text};
|
||
white-space: pre-wrap;
|
||
overflow-wrap: break-word;
|
||
background: none;
|
||
padding: 0;
|
||
}
|
||
${tokenRules}`;
|
||
}
|
||
|
||
// ─── CSS generator ────────────────────────────────────────────────────────────
|
||
|
||
/** Returns relative luminance (0–1) of a CSS hex color; non-hex values return 1 (treat as light). */
|
||
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;
|
||
}
|
||
|
||
/** Returns the resolved CSS font-family string for the current settings. */
|
||
function resolveFont(s: PDFExportSettings): string {
|
||
return s.fontFamily === "__custom__" ? (s.customFontName.trim() || "inherit") : s.fontFamily;
|
||
}
|
||
|
||
function buildDocCSS(s: PDFExportSettings, isRTL = false): string {
|
||
const hs = s.headingScale;
|
||
const fontFamily = resolveFont(s);
|
||
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;
|
||
${isRTL ? "direction: rtl;" : ""}
|
||
}
|
||
.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-inline-start: 1.4em; margin: 0 0 ${s.paragraphSpacing}em; }
|
||
.mpdf-doc li { margin-bottom: 0.2em; line-height: ${s.lineHeight}; }
|
||
.mpdf-doc blockquote {
|
||
border-inline-start: 3px solid ${s.blockquoteBorderColor};
|
||
background: ${s.blockquoteBg};
|
||
padding-block: 4px;
|
||
padding-inline: 1em 0;
|
||
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};
|
||
}
|
||
${buildCodeBlockCSS(s)}
|
||
.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}; ${s.linkUnderline ? "" : "text-decoration: none;"} }
|
||
.mpdf-doc a.external-link::after { display: none !important; content: none !important; }
|
||
/* Hide copy-code buttons that survive postProcessRenderedHTML (e.g. re-injected by themes). */
|
||
.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: start;
|
||
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 — override theme styles with !important so preview and export are
|
||
* identical regardless of the active Obsidian theme. */
|
||
.mpdf-doc .callout {
|
||
border-inline-start: 4px solid ${s.accentColor} !important;
|
||
border-start-start-radius: 0 !important;
|
||
border-start-end-radius: 5px !important;
|
||
border-end-end-radius: 5px !important;
|
||
border-end-start-radius: 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-inline-start-color: ${s.accentColor}66 !important;
|
||
background: transparent !important;
|
||
}
|
||
|
||
/* Mermaid diagrams — centre the SVG and prevent it overflowing the content
|
||
* column. The <style> block inside the SVG is intentionally left untouched;
|
||
* mermaid embeds its own theme CSS there. */
|
||
.mpdf-doc .mermaid {
|
||
display: flex;
|
||
justify-content: center;
|
||
margin: ${s.paragraphSpacing}em 0;
|
||
overflow: hidden;
|
||
}
|
||
.mpdf-doc .mermaid svg {
|
||
max-width: 100%;
|
||
height: auto;
|
||
display: block;
|
||
}
|
||
`.trim();
|
||
}
|
||
|
||
/** Extracts only the DocStyle fields from the broader settings object. */
|
||
function extractDocStyle(s: PDFExportSettings): DocStyle {
|
||
return {
|
||
name: s.name, fontFamily: s.fontFamily, fontSize: s.fontSize,
|
||
lineHeight: s.lineHeight, paragraphSpacing: s.paragraphSpacing,
|
||
headingScale: s.headingScale, accentColor: s.accentColor,
|
||
bodyColor: s.bodyColor, headingColor: s.headingColor,
|
||
h1BorderBottom: s.h1BorderBottom, h2BorderBottom: s.h2BorderBottom,
|
||
centerH1: s.centerH1, blockquoteBg: s.blockquoteBg,
|
||
blockquoteBorderColor: s.blockquoteBorderColor, codeBackground: s.codeBackground,
|
||
codeFontSize: s.codeFontSize, codeTheme: s.codeTheme, tableHeaderBg: s.tableHeaderBg,
|
||
tableStriped: s.tableStriped, pageBackground: s.pageBackground,
|
||
marginTop: s.marginTop, marginBottom: s.marginBottom,
|
||
marginLeft: s.marginLeft, marginRight: s.marginRight,
|
||
};
|
||
}
|
||
|
||
/** Collects MathJax's injected stylesheets for inclusion in shadow DOMs and
|
||
* the export HTML. Targets `MJX-` prefixed IDs to avoid capturing unrelated
|
||
* theme stylesheets that Obsidian injects into the document head. */
|
||
function getMathJaxCSS(): string {
|
||
return Array.from(
|
||
activeDocument.head.querySelectorAll<HTMLStyleElement>("style[id^='MJX-']"),
|
||
).map((el) => el.textContent ?? "").join("\n");
|
||
}
|
||
|
||
// ─── Paginator ────────────────────────────────────────────────────────────────
|
||
// Distributes rendered block children into page-height buckets. Oversized
|
||
// elements are split by natural unit: line (PRE), row (TABLE), item (UL/OL),
|
||
// word or character (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",
|
||
]);
|
||
|
||
// PRE is absent — it gets its own line-based splitter.
|
||
const UNSPLITTABLE_TAGS = new Set([
|
||
"CODE", "IMG", "HR", "H1", "H2", "H3", "H4", "H5", "H6",
|
||
]);
|
||
|
||
// 2px guards against sub-pixel rendering differences between the light-DOM
|
||
// paginator sandbox and the shadow DOM preview context.
|
||
const HEIGHT_EPS = 2;
|
||
|
||
function measureNodesHeight(nodes: HTMLElement[], measureEl: HTMLElement): number {
|
||
measureEl.empty();
|
||
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 = activeDocument.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,
|
||
trimSecond = true,
|
||
): [HTMLElement, HTMLElement] | null {
|
||
const walker = activeDocument.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 = activeDocument.createRange();
|
||
range1.selectNodeContents(el);
|
||
range1.setEnd(target, localOffset);
|
||
|
||
const range2 = activeDocument.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());
|
||
if (trimSecond) 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;
|
||
// Preserve OL numbering across page breaks.
|
||
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;
|
||
|
||
// Respect an existing start attribute on continuation fragments.
|
||
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 = activeDocument.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 code block by line. When a <code> child is present (true for all
|
||
// fenced code blocks), buildInlineSplitAt's Range-based split preserves the
|
||
// nested Prism `.token.*` span structure across the break.
|
||
|
||
/** Character offsets (within the element's full text content) marking the
|
||
* start of the line *after* each line — i.e. valid split points. */
|
||
function getLineEndOffsets(lines: string[]): number[] {
|
||
const offsets: number[] = [];
|
||
let offset = 0;
|
||
for (const line of lines) {
|
||
offset += line.length + 1; // +1 for the newline separating this line from the next
|
||
offsets.push(offset);
|
||
}
|
||
return offsets;
|
||
}
|
||
|
||
function splitPreElement(
|
||
preEl: HTMLElement,
|
||
fits: (node: HTMLElement) => boolean,
|
||
forceSplit: boolean,
|
||
): [HTMLElement, HTMLElement] | null {
|
||
const codeEl = preEl.querySelector("code");
|
||
const lines = (codeEl ?? 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;
|
||
|
||
const lineEndOffsets = getLineEndOffsets(lines);
|
||
|
||
// Splits at the boundary after the first `n` lines, preserving syntax
|
||
// highlighting via Range.cloneContents() when a <code> child is present.
|
||
const buildSplit = (n: number): [HTMLElement, HTMLElement] | null => {
|
||
if (!codeEl) {
|
||
const clone = (text: string) => {
|
||
const el = preEl.cloneNode(false) as HTMLElement;
|
||
el.textContent = text;
|
||
return el;
|
||
};
|
||
return [clone(lines.slice(0, n).join("\n")), clone(lines.slice(n).join("\n"))];
|
||
}
|
||
const split = buildInlineSplitAt(codeEl, lineEndOffsets[n - 1], false);
|
||
if (!split) return null;
|
||
const first = preEl.cloneNode(false) as HTMLElement;
|
||
first.appendChild(split[0]);
|
||
const second = preEl.cloneNode(false) as HTMLElement;
|
||
second.appendChild(split[1]);
|
||
return [first, second];
|
||
};
|
||
|
||
let best: [HTMLElement, HTMLElement] | null = null;
|
||
let fitCount = 0;
|
||
for (let i = 1; i <= lines.length; i++) {
|
||
const candidate = buildSplit(i);
|
||
if (!candidate || !fits(candidate[0])) break;
|
||
best = candidate;
|
||
fitCount = i;
|
||
}
|
||
|
||
if (fitCount <= 0) {
|
||
if (!forceSplit) return null;
|
||
best = buildSplit(1);
|
||
fitCount = 1;
|
||
}
|
||
if (fitCount >= lines.length || !best) return null;
|
||
|
||
return best;
|
||
}
|
||
|
||
// ── 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 paginateEl(
|
||
sourceEl: HTMLElement,
|
||
contentWidthPx: number,
|
||
contentHeightPx: number,
|
||
docCSS: string,
|
||
): HTMLElement[][] {
|
||
// Hidden shadow-root sandbox: scoped CSS prevents host-document pollution.
|
||
const sandboxHost = activeDocument.createElement("div");
|
||
sandboxHost.setCssStyles({
|
||
position: "fixed", top: "0", left: "-99999px",
|
||
width: `${contentWidthPx}px`,
|
||
visibility: "hidden", pointerEvents: "none", zIndex: "-1",
|
||
});
|
||
|
||
const sandboxShadow = sandboxHost.attachShadow({ mode: "open" });
|
||
|
||
const sandboxSheet = new CSSStyleSheet();
|
||
sandboxSheet.replaceSync(docCSS);
|
||
sandboxShadow.adoptedStyleSheets = [sandboxSheet];
|
||
|
||
const inner = activeDocument.createElement("div");
|
||
inner.className = "mpdf-doc";
|
||
for (const child of Array.from(sourceEl.children)) {
|
||
inner.appendChild(child.cloneNode(true));
|
||
}
|
||
sandboxShadow.appendChild(inner);
|
||
|
||
// Measurement div: same width, always empty before each measurement.
|
||
const measure = activeDocument.createElement("div");
|
||
measure.className = "mpdf-doc";
|
||
measure.setCssStyles({ position: "absolute", top: "0", left: "0", width: `${contentWidthPx}px`, visibility: "hidden" });
|
||
sandboxShadow.appendChild(measure);
|
||
|
||
activeDocument.body.appendChild(sandboxHost);
|
||
|
||
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)) {
|
||
currentPage.push(child.cloneNode(true) as HTMLElement);
|
||
idx++;
|
||
continue;
|
||
}
|
||
|
||
// Element doesn't fit. Try to split it across the page boundary.
|
||
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 {
|
||
activeDocument.body.removeChild(sandboxHost);
|
||
}
|
||
return pages.length > 0 ? pages : [[]];
|
||
}
|
||
|
||
// ─── Page layout builder ──────────────────────────────────────────────────────
|
||
|
||
/** Converts paginated page-node arrays into fully-resolved PageLayout objects,
|
||
* computing header/footer text and page number strings for each page. */
|
||
function buildPageLayouts(allPages: HTMLElement[][], s: PDFExportSettings): PageLayout[] {
|
||
const totalPages = allPages.length;
|
||
return allPages.map((pageNodes, i) => {
|
||
const pageNum = i + 1;
|
||
const showHeader = s.showHeaderOnFirstPage || i > 0;
|
||
const showFooter = s.showFooterOnFirstPage || i > 0;
|
||
|
||
// Page-number offset: when footer is hidden on page 1 the numbering shifts by 1.
|
||
const displayNum = s.showFooterOnFirstPage ? s.pageNumberStart + i : s.pageNumberStart + (i - 1);
|
||
const displayTotal = s.showFooterOnFirstPage ? s.pageNumberStart + totalPages - 1 : s.pageNumberStart + totalPages - 2;
|
||
const numStr = `${displayNum} / ${displayTotal}`;
|
||
|
||
let footerLeft = "", footerRight = "", footerCenter = "";
|
||
let headerLeft = "", headerCenter = "", headerRight = "";
|
||
|
||
if (showFooter) {
|
||
// Place footer text in its alignment zone.
|
||
if (s.footerText) {
|
||
if (s.footerTextAlignment === "center") footerCenter = s.footerText;
|
||
else if (s.footerTextAlignment === "left") footerLeft = s.footerText;
|
||
else footerRight = s.footerText;
|
||
}
|
||
// Place page number in its own zone; merge with a separator when both land in the same slot.
|
||
if (s.showPageNumbers) {
|
||
const join = (existing: string) => existing ? existing + " — " + numStr : numStr;
|
||
if (s.pageNumberPosition === "center") footerCenter = join(footerCenter);
|
||
else if (s.pageNumberPosition === "left") footerLeft = join(footerLeft);
|
||
else footerRight = join(footerRight);
|
||
}
|
||
}
|
||
|
||
if (showHeader) {
|
||
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 };
|
||
});
|
||
}
|
||
|
||
// ─── PDF outline (bookmarks) ──────────────────────────────────────────────────
|
||
// Electron's printToPDF does not generate bookmarks — we post-process the raw
|
||
// PDF bytes with pdf-lib to inject a hierarchical outline derived from the
|
||
// heading elements already present in the paginated layout.
|
||
|
||
/**
|
||
* Walks every page's node list and collects heading elements in document order.
|
||
* Headings can sit at the top level of pageNodes (the common case) or be nested
|
||
* inside a container fragment produced by the page-splitter.
|
||
*/
|
||
function extractOutlineEntries(layouts: PageLayout[]): OutlineEntry[] {
|
||
const entries: OutlineEntry[] = [];
|
||
for (const layout of layouts) {
|
||
for (const node of layout.pageNodes) {
|
||
const topLevel: HTMLElement[] = /^H[1-6]$/.test(node.tagName) ? [node] : [];
|
||
const nested = Array.from(node.querySelectorAll<HTMLElement>("h1,h2,h3,h4,h5,h6"));
|
||
for (const el of [...topLevel, ...nested]) {
|
||
const level = parseInt(el.tagName[1], 10);
|
||
const title = (el.textContent ?? "").trim();
|
||
if (title) entries.push({ title, level, page: layout.pageNum });
|
||
}
|
||
}
|
||
}
|
||
return entries;
|
||
}
|
||
|
||
/**
|
||
* Post-processes a PDF buffer produced by `printToPDF` and injects a
|
||
* hierarchical bookmark outline built from the supplied entries.
|
||
*
|
||
* Heading nesting (H1 → H2 → H3 …) is preserved. Each item links to its page
|
||
* via an XYZ destination that inherits the reader's current zoom. Sub-trees are
|
||
* collapsed by default (negative /Count per PDF spec). PageMode is set to
|
||
* UseOutlines so readers open the bookmarks panel on load.
|
||
*/
|
||
async function injectPDFOutline(pdfBuffer: Buffer, entries: OutlineEntry[]): Promise<Buffer> {
|
||
if (!entries.length) return pdfBuffer;
|
||
|
||
const pdfDoc = await PDFDocument.load(pdfBuffer);
|
||
const pages = pdfDoc.getPages();
|
||
const ctx = pdfDoc.context;
|
||
const n = entries.length;
|
||
|
||
// ── Compute tree relationships ───────────────────────────────────────────
|
||
// parentIdx[i] = index of the nearest ancestor entry with a lower heading
|
||
// level, or -1 when the entry sits at the root of the outline.
|
||
const parentIdx = new Array<number>(n).fill(-1);
|
||
for (let i = 1; i < n; i++) {
|
||
for (let j = i - 1; j >= 0; j--) {
|
||
if (entries[j].level < entries[i].level) { parentIdx[i] = j; break; }
|
||
}
|
||
}
|
||
|
||
// Sibling linkage (prev / next among entries that share the same parent).
|
||
const prevSib = new Array<number>(n).fill(-1);
|
||
const nextSib = new Array<number>(n).fill(-1);
|
||
for (let i = 0; i < n; i++) {
|
||
for (let j = i - 1; j >= 0; j--) {
|
||
if (parentIdx[j] === parentIdx[i]) { prevSib[i] = j; break; }
|
||
}
|
||
for (let j = i + 1; j < n; j++) {
|
||
if (parentIdx[j] === parentIdx[i]) { nextSib[i] = j; break; }
|
||
}
|
||
}
|
||
|
||
// First / last direct child of each entry.
|
||
const firstChild = new Array<number>(n).fill(-1);
|
||
const lastChild = new Array<number>(n).fill(-1);
|
||
for (let i = 0; i < n; i++) {
|
||
const p = parentIdx[i];
|
||
if (p >= 0) {
|
||
if (firstChild[p] < 0) firstChild[p] = i;
|
||
lastChild[p] = i;
|
||
}
|
||
}
|
||
|
||
// ── Allocate PDF indirect references ─────────────────────────────────────
|
||
const outlineRef = ctx.nextRef();
|
||
const itemRefs = entries.map(() => ctx.nextRef());
|
||
|
||
// ── Build each outline item object ───────────────────────────────────────
|
||
for (let i = 0; i < n; i++) {
|
||
const pageIdx = Math.min(entries[i].page - 1, pages.length - 1);
|
||
|
||
// XYZ destination: navigate to this page, null left/top inherits scroll, 0 zoom inherits zoom.
|
||
const dest = PDFArray.withContext(ctx);
|
||
dest.push(pages[pageIdx].ref);
|
||
dest.push(PDFName.of("XYZ"));
|
||
dest.push(PDFNull);
|
||
dest.push(PDFNull);
|
||
dest.push(PDFNumber.of(0));
|
||
|
||
const itemDict = PDFDict.withContext(ctx);
|
||
itemDict.set(PDFName.of("Title"), PDFHexString.fromText(entries[i].title)); // UTF-16 → full Unicode
|
||
itemDict.set(PDFName.of("Parent"), parentIdx[i] >= 0 ? itemRefs[parentIdx[i]] : outlineRef);
|
||
itemDict.set(PDFName.of("Dest"), dest);
|
||
if (prevSib[i] >= 0) itemDict.set(PDFName.of("Prev"), itemRefs[prevSib[i]]);
|
||
if (nextSib[i] >= 0) itemDict.set(PDFName.of("Next"), itemRefs[nextSib[i]]);
|
||
if (firstChild[i] >= 0) {
|
||
itemDict.set(PDFName.of("First"), itemRefs[firstChild[i]]);
|
||
itemDict.set(PDFName.of("Last"), itemRefs[lastChild[i]]);
|
||
// Negative /Count = subtree is collapsed by default in the PDF reader.
|
||
let childCount = 0;
|
||
for (let j = 0; j < n; j++) if (parentIdx[j] === i) childCount++;
|
||
itemDict.set(PDFName.of("Count"), PDFNumber.of(-childCount));
|
||
}
|
||
ctx.assign(itemRefs[i], itemDict);
|
||
}
|
||
|
||
// ── Build the outline root dictionary ────────────────────────────────────
|
||
let rootFirst = -1, rootLast = -1, rootCount = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
if (parentIdx[i] < 0) {
|
||
if (rootFirst < 0) rootFirst = i;
|
||
rootLast = i;
|
||
rootCount++;
|
||
}
|
||
}
|
||
const rootDict = PDFDict.withContext(ctx);
|
||
rootDict.set(PDFName.of("Type"), PDFName.of("Outlines"));
|
||
rootDict.set(PDFName.of("First"), itemRefs[rootFirst]);
|
||
rootDict.set(PDFName.of("Last"), itemRefs[rootLast]);
|
||
rootDict.set(PDFName.of("Count"), PDFNumber.of(rootCount));
|
||
ctx.assign(outlineRef, rootDict);
|
||
|
||
// ── Wire into the document catalog ───────────────────────────────────────
|
||
pdfDoc.catalog.set(PDFName.of("Outlines"), outlineRef);
|
||
pdfDoc.catalog.set(PDFName.of("PageMode"), PDFName.of("UseOutlines"));
|
||
|
||
return Buffer.from(await pdfDoc.save());
|
||
}
|
||
|
||
// ─── Header / footer / frame rendering helpers ───────────────────────────────
|
||
|
||
/** Shorthand `border` value for the page frame, shared by the preview and export paths. */
|
||
function frameBorderCSS(s: PDFExportSettings): string {
|
||
return `${s.frameThickness}px ${s.frameStyle} ${s.frameColor}`;
|
||
}
|
||
|
||
/** Builds the page-edge frame element for the live preview shadow DOM.
|
||
* Inset from the page boundary by frameMargin (equal on all sides) so it
|
||
* sits outside the margin-bound header, footer, and content — the
|
||
* outermost decoration on the page. Returns null when the frame is disabled. */
|
||
function buildFrameOverlayEl(s: PDFExportSettings): HTMLElement | null {
|
||
if (!s.frameEnabled) return null;
|
||
const inset = `${s.frameMargin}px`;
|
||
const frame = activeDocument.createElement("div");
|
||
frame.setCssStyles({
|
||
position: "absolute", top: inset, left: inset, right: inset, bottom: inset,
|
||
pointerEvents: "none", boxSizing: "border-box",
|
||
border: frameBorderCSS(s),
|
||
});
|
||
return frame;
|
||
}
|
||
|
||
/** Returns the page-edge frame markup for the export HTML. Empty string when disabled. */
|
||
function buildFrameOverlayHTML(s: PDFExportSettings): string {
|
||
if (!s.frameEnabled) return "";
|
||
const inset = `${s.frameMargin}px`;
|
||
return `<div style="position:absolute;top:${inset};left:${inset};right:${inset};bottom:${inset};pointer-events:none;box-sizing:border-box;border:${frameBorderCSS(s)};"></div>`;
|
||
}
|
||
|
||
/** Appends center-or-left/right span nodes into a header/footer container element. */
|
||
function appendHFNodes(container: HTMLElement, center: string, left: string, right: string): void {
|
||
if (!center && !left && !right) return;
|
||
if (center) {
|
||
const span = activeDocument.createElement("span");
|
||
span.className = "mpdf-hf-center";
|
||
span.textContent = center;
|
||
container.appendChild(span);
|
||
} else {
|
||
const leftSpan = activeDocument.createElement("span");
|
||
leftSpan.textContent = left;
|
||
container.appendChild(leftSpan);
|
||
const rightSpan = activeDocument.createElement("span");
|
||
rightSpan.className = "mpdf-hf-right";
|
||
rightSpan.textContent = right;
|
||
container.appendChild(rightSpan);
|
||
}
|
||
}
|
||
|
||
/** Returns the inner HTML string for a header/footer bar (used in export HTML). */
|
||
function buildHFInnerHTML(center: string, left: string, right: string): string {
|
||
if (!center && !left && !right) return "";
|
||
return center
|
||
? `<span style="flex:1;text-align:center;">${escapeHTML(center)}</span>`
|
||
: `<span>${escapeHTML(left)}</span><span style="margin-left:auto;">${escapeHTML(right)}</span>`;
|
||
}
|
||
|
||
// ─── Plugin ───────────────────────────────────────────────────────────────────
|
||
|
||
export default class MarkdownPDFPlugin extends Plugin {
|
||
declare settings: PDFExportSettings;
|
||
activeModal: PDFExportModal | null = null;
|
||
presetSnapshots: Record<string, DocStyle> = {};
|
||
|
||
async onload() {
|
||
await this.loadSettings();
|
||
this.addCommand({
|
||
id: "open-panel",
|
||
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();
|
||
}
|
||
|
||
openModal(file?: TFile) {
|
||
if (this.activeModal) this.activeModal.close();
|
||
new PDFExportModal(this.app, this, file).open();
|
||
}
|
||
|
||
async loadSettings() {
|
||
const data = (await this.loadData() ?? {}) as Partial<PDFExportSettings> & { presetSnapshots?: Record<string, DocStyle> };
|
||
this.settings = Object.assign({}, DEFAULT_SETTINGS, data);
|
||
// Migration: codeTheme was added per-preset; absent value adopts the active preset's default.
|
||
if (data.codeTheme === undefined) {
|
||
this.settings.codeTheme = PRESETS[this.settings.preset]?.codeTheme ?? DEFAULT_SETTINGS.codeTheme;
|
||
}
|
||
this.presetSnapshots = data.presetSnapshots ?? {};
|
||
}
|
||
|
||
async saveSettings() {
|
||
await this.saveData({ ...this.settings, presetSnapshots: this.presetSnapshots });
|
||
}
|
||
|
||
async saveSettingsAndRender() {
|
||
await this.saveSettings();
|
||
this.activeModal?.render();
|
||
}
|
||
|
||
applyPreset(key: string, reset = false) {
|
||
const p = PRESETS[key];
|
||
if (!p) return;
|
||
if (reset) {
|
||
delete this.presetSnapshots[key];
|
||
} else {
|
||
this.presetSnapshots[this.settings.preset] = extractDocStyle(this.settings);
|
||
}
|
||
this.settings.preset = key;
|
||
Object.assign(this.settings, p, reset ? {} : (this.presetSnapshots[key] ?? {}));
|
||
}
|
||
}
|
||
|
||
// ─── File resolver ────────────────────────────────────────────────────────────
|
||
|
||
// Four-level cascade: explicit file → active MarkdownView → active file → most recent leaf.
|
||
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.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: number | 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;
|
||
this.render(true);
|
||
}
|
||
}
|
||
|
||
onClose() {
|
||
if (this.renderDebounceTimer !== null) {
|
||
window.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" +
|
||
"Mermaid diagrams are supported:\n```mermaid\nflowchart LR\n A --> B --> C\n```\n\n" +
|
||
"Markdown tables:\n| Col A | Col B |\n|-------|-------|\n| Cell | Cell |";
|
||
|
||
// Preview container keeps the loading overlay fixed (non-scrolling) over the panel.
|
||
const previewContainer = main.createEl("div", { cls: "mpdf-preview-container" });
|
||
this.previewEl = previewContainer.createEl("div", { cls: "mpdf-preview" });
|
||
|
||
this.loadingOverlayEl = previewContainer.createEl("div", { cls: "mpdf-loading-overlay" });
|
||
this.loadingOverlayEl.createEl("div", { cls: "mpdf-spinner" });
|
||
this.loadingOverlayEl.createEl("span", { cls: "mpdf-loading-text", text: "Rendering…" });
|
||
|
||
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" });
|
||
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));
|
||
sizeOpts["Custom"] = "Custom";
|
||
|
||
// Build the size control manually so we can show/hide the custom-dims inputs.
|
||
const sizeCtrl = left.createEl("div", { cls: "mpdf-ctrl" });
|
||
sizeCtrl.createEl("span", { cls: "mpdf-ctrl-label", text: "Size" });
|
||
const sizeSelect = sizeCtrl.createEl("select", { cls: "mpdf-select" });
|
||
for (const [v, t] of Object.entries(sizeOpts)) {
|
||
const o = sizeSelect.createEl("option", { text: t, value: v });
|
||
if (v === s.pageSize) o.selected = true;
|
||
}
|
||
|
||
// Custom W×H inputs — visible only when "Custom" is active.
|
||
const customCtrl = left.createEl("div", { cls: "mpdf-ctrl" });
|
||
customCtrl.toggleClass("mpdf-is-hidden", s.pageSize !== "Custom");
|
||
|
||
const makeNumInput = (label: string, val: number): HTMLInputElement => {
|
||
customCtrl.createEl("span", { cls: "mpdf-ctrl-label", text: label });
|
||
const inp = customCtrl.createEl("input", { cls: "mpdf-custom-size-input" });
|
||
inp.type = "number"; inp.min = "10"; inp.step = "1"; inp.value = String(val);
|
||
return inp;
|
||
};
|
||
const wInp = makeNumInput("W", s.customPageWidth);
|
||
const hInp = makeNumInput("H", s.customPageHeight);
|
||
customCtrl.createEl("span", { cls: "mpdf-ctrl-label", text: "mm" });
|
||
|
||
sizeSelect.addEventListener("change", () => {
|
||
this.plugin.settings.pageSize = sizeSelect.value;
|
||
customCtrl.toggleClass("mpdf-is-hidden", sizeSelect.value !== "Custom");
|
||
void this.plugin.saveSettingsAndRender();
|
||
});
|
||
|
||
const applyCustomDims = async () => {
|
||
this.plugin.settings.customPageWidth = Math.max(10, parseFloat(wInp.value) || 210);
|
||
this.plugin.settings.customPageHeight = Math.max(10, parseFloat(hInp.value) || 297);
|
||
await this.plugin.saveSettingsAndRender();
|
||
};
|
||
wInp.addEventListener("change", () => void applyCustomDims());
|
||
hInp.addEventListener("change", () => void applyCustomDims());
|
||
|
||
makeSelect("", { 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", () => {
|
||
const v = parseFloat(zoomSlider.value);
|
||
this.plugin.settings.previewScale = v;
|
||
zoomLabel.textContent = Math.round(v * 100) + "%";
|
||
void this.plugin.saveSettings().then(() => { 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.noteTitleEl = left.createEl("div", { cls: "mpdf-topbar-title", text: "—" });
|
||
|
||
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 App & AppWithSettings).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 ──────────────────────────────────────────────────────────────────
|
||
|
||
// immediate=true → run on the next frame (button click, Ctrl+Enter, onOpen).
|
||
// immediate=false → debounce 150 ms (settings panel changes).
|
||
render(immediate = false) {
|
||
const token = ++this.renderToken;
|
||
if (this.renderDebounceTimer !== null) {
|
||
window.clearTimeout(this.renderDebounceTimer);
|
||
this.renderDebounceTimer = null;
|
||
}
|
||
this.showLoading();
|
||
|
||
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) {
|
||
// Double rAF: ensures the spinner paints before synchronous pagination blocks the thread.
|
||
window.requestAnimationFrame(() => window.requestAnimationFrame(() => void safeDo()));
|
||
} else {
|
||
this.renderDebounceTimer = window.setTimeout(() => {
|
||
this.renderDebounceTimer = null;
|
||
window.requestAnimationFrame(() => window.requestAnimationFrame(() => void safeDo()));
|
||
}, 150);
|
||
}
|
||
}
|
||
|
||
// Inserts `///` before H1/H2 headings, skipping headings inside fenced
|
||
// code blocks and the very first heading in the document.
|
||
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) {
|
||
if (breakH1 && /^# /.test(line)) out.push("///");
|
||
else if (breakH2 && /^## /.test(line)) out.push("///");
|
||
}
|
||
} else {
|
||
// Close fence: same character, 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);
|
||
|
||
if (s.hideFrontmatter) {
|
||
md = stripFrontmatter(md);
|
||
}
|
||
|
||
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 = resolvePageDims(s);
|
||
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 && (s.showPageNumbers || !!s.footerText || s.showFooterBorder || !!s.footerImagePath)
|
||
? (s.footerHeight > 0 ? s.footerHeight : Math.max(28, s.footerFontSize + 14))
|
||
: 0;
|
||
const headerH = s.showHeader && (!!s.headerText || s.showHeaderBorder || !!s.headerImagePath)
|
||
? (s.headerHeight > 0 ? s.headerHeight : Math.max(20, s.headerFontSize + 10))
|
||
: 0;
|
||
// Clamp to at least 1 px so the paginator sandbox never has zero dimensions.
|
||
const contentW = Math.max(1, pw - mLeft - mRight);
|
||
const contentH = Math.max(1, ph - mTop - mBottom - footerH - headerH);
|
||
const isRTL = isRTLContent(this.editorEl.value);
|
||
const docCSS = buildDocCSS(s, isRTL);
|
||
const sourcePath = this.currentFile?.path ?? "pdf-export";
|
||
|
||
const sectionEls = await Promise.all(
|
||
sections.map((sec) => renderMarkdownToEl(this.app, sec, sourcePath, this.renderComponent)),
|
||
);
|
||
|
||
if (token !== this.renderToken) return;
|
||
|
||
// Forward MathJax styles so math renders correctly in shadow DOMs and export HTML.
|
||
const mathCSS = getMathJaxCSS();
|
||
const fullCSS = mathCSS ? `${mathCSS}\n${docCSS}` : docCSS;
|
||
|
||
const allPages: HTMLElement[][] = [];
|
||
for (const sectionEl of sectionEls) {
|
||
allPages.push(...paginateEl(sectionEl, contentW, contentH, fullCSS));
|
||
}
|
||
|
||
const layouts = buildPageLayouts(allPages, s);
|
||
this.layoutCache = { layouts, pw, ph, mTop, mLeft, mRight, footerH, headerH, contentW, contentH, docCSS: fullCSS, fontFamily: resolveFont(s), accentColor: s.accentColor, pageBackground: s.pageBackground, isRTL };
|
||
|
||
this.drawPreview(this.layoutCache, s.previewScale);
|
||
this.pageCountEl.textContent = `${layouts.length} page${layouts.length !== 1 ? "s" : ""}`;
|
||
this.hideLoading();
|
||
}
|
||
|
||
private renderPreviewOnly() {
|
||
if (!this.layoutCache) return;
|
||
this.drawPreview(this.layoutCache, this.plugin.settings.previewScale);
|
||
}
|
||
|
||
private showLoading() {
|
||
this.loadingOverlayEl.addClass("mpdf-is-active");
|
||
this.renderBtn.disabled = true;
|
||
this.renderBtn.textContent = "Rendering…";
|
||
}
|
||
|
||
private hideLoading() {
|
||
this.loadingOverlayEl.removeClass("mpdf-is-active");
|
||
this.renderBtn.disabled = false;
|
||
this.renderBtn.textContent = "⟳ Render PDF";
|
||
}
|
||
|
||
private drawPreview(c: LayoutCache, scale: number) {
|
||
const { layouts, pw, ph, mTop, mLeft, mRight, footerH, headerH, contentW, contentH, docCSS, fontFamily, accentColor, pageBackground, isRTL } = c;
|
||
const s = this.plugin.settings;
|
||
this.previewEl.empty();
|
||
|
||
// Map heading IDs → page-wrap index for in-preview anchor scrolling.
|
||
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);
|
||
}
|
||
});
|
||
|
||
const pageWraps: HTMLElement[] = [];
|
||
|
||
// All pages share identical CSS — build once and adopt by reference in each shadow root.
|
||
const shadowCSS = `
|
||
: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; }
|
||
.mpdf-hf-center { flex: 1; text-align: center; }
|
||
.mpdf-hf-right { margin-left: auto; }
|
||
${docCSS}
|
||
`;
|
||
const pageSheet = new CSSStyleSheet();
|
||
pageSheet.replaceSync(shadowCSS);
|
||
|
||
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.setCssStyles({ width: `${scaledW}px`, height: `${scaledH}px` });
|
||
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.setCssStyles({ width: `${scaledW}px`, height: `${scaledH}px` });
|
||
|
||
// Shadow root isolates page content from Obsidian's theme CSS.
|
||
const shadowHost = activeDocument.createElement("div");
|
||
shadowHost.addClass("mpdf-shadow-host");
|
||
shadowHost.setCssStyles({ width: `${pw}px`, height: `${ph}px`, transform: `scale(${scale})` });
|
||
scaleWrap.appendChild(shadowHost);
|
||
|
||
const shadow = shadowHost.attachShadow({ mode: "open" });
|
||
shadow.adoptedStyleSheets = [pageSheet];
|
||
|
||
// ── Page background image (appended first — behind everything) ─────────────
|
||
if (s.backgroundImageEnabled && s.backgroundImagePath) {
|
||
const bgUrl = resolveImageUrl(this.app, s.backgroundImagePath);
|
||
if (bgUrl) {
|
||
const isContentOnly = s.backgroundImageScope === "content-only";
|
||
const bgEl = activeDocument.createElement("div");
|
||
bgEl.setCssStyles({
|
||
position: "absolute",
|
||
...(isContentOnly
|
||
? { top: `${mTop + headerH}px`, left: `${mLeft}px`, width: `${contentW}px`, height: `${contentH}px` }
|
||
: { inset: "0" }),
|
||
backgroundImage: `url('${bgUrl}')`,
|
||
backgroundRepeat: s.backgroundImageSize === "tile" ? "repeat" : "no-repeat",
|
||
backgroundSize: s.backgroundImageSize === "cover" ? "cover"
|
||
: s.backgroundImageSize === "contain" ? "contain"
|
||
: s.backgroundImageSize === "fill" ? "100% 100%"
|
||
: "auto",
|
||
backgroundPosition: "center",
|
||
opacity: String(s.backgroundImageOpacity),
|
||
pointerEvents: "none",
|
||
});
|
||
shadow.appendChild(bgEl);
|
||
}
|
||
}
|
||
|
||
const pageShowsHeader = s.showHeaderOnFirstPage || layout.pageNum > 1;
|
||
const pageShowsFooter = s.showFooterOnFirstPage || layout.pageNum > 1;
|
||
|
||
// ── Header banner image (behind header text) ──────────────────────────────
|
||
if (pageShowsHeader && s.showHeader && s.headerImagePath) {
|
||
const imgUrl = resolveImageUrl(this.app, s.headerImagePath);
|
||
if (imgUrl) {
|
||
const bannerEl = activeDocument.createElement("div");
|
||
bannerEl.setCssStyles({
|
||
position: "absolute",
|
||
top: `${mTop * 0.4}px`,
|
||
left: `${s.headerImageMargin}px`,
|
||
right: `${s.headerImageMargin}px`,
|
||
height: `${headerH}px`,
|
||
backgroundImage: `url('${imgUrl}')`,
|
||
backgroundSize: "cover",
|
||
backgroundPosition: "center",
|
||
backgroundRepeat: "no-repeat",
|
||
pointerEvents: "none",
|
||
});
|
||
shadow.appendChild(bannerEl);
|
||
}
|
||
}
|
||
|
||
// ── Header text ──────────────────────────────────────────────────────────
|
||
const hasHeader = s.showHeader && pageShowsHeader && (layout.headerLeft || layout.headerCenter || layout.headerRight || s.showHeaderBorder);
|
||
if (hasHeader) {
|
||
const hdr = activeDocument.createElement("div");
|
||
hdr.setCssStyles({
|
||
position: "absolute", top: `${mTop * 0.4}px`, left: `${mLeft}px`, right: `${mRight}px`,
|
||
height: `${headerH}px`,
|
||
display: "flex", alignItems: "center",
|
||
fontSize: `${s.headerFontSize}px`, color: s.headerFontColor, fontFamily: fontFamily, whiteSpace: "nowrap",
|
||
...(s.showHeaderBorder ? { borderBottom: `0.5px solid ${accentColor}33` } : {}),
|
||
});
|
||
appendHFNodes(hdr, layout.headerCenter, layout.headerLeft, layout.headerRight);
|
||
shadow.appendChild(hdr);
|
||
}
|
||
|
||
// ── Content ──────────────────────────────────────────────────────────────
|
||
const contentDiv = activeDocument.createElement("div");
|
||
contentDiv.className = "mpdf-doc";
|
||
if (isRTL) contentDiv.setAttribute("dir", "rtl");
|
||
// No explicit height or overflow:hidden — :host clips at the page edge.
|
||
// Adding a second clip here caused bottom lines to be cut off in preview
|
||
// due to sub-pixel rounding differences between the paginator sandbox
|
||
// (light DOM) and the shadow DOM rendering context.
|
||
contentDiv.setCssStyles({
|
||
position: "absolute", top: `${mTop + headerH}px`, left: `${mLeft}px`,
|
||
width: `${contentW}px`,
|
||
});
|
||
for (const node of layout.pageNodes) contentDiv.appendChild(node.cloneNode(true));
|
||
shadow.appendChild(contentDiv);
|
||
|
||
// Wire internal anchor links via light-DOM page-wrap scrollIntoView.
|
||
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 banner image (behind footer text) ──────────────────────────────
|
||
if (pageShowsFooter && s.showFooter && s.footerImagePath) {
|
||
const imgUrl = resolveImageUrl(this.app, s.footerImagePath);
|
||
if (imgUrl) {
|
||
const bannerEl = activeDocument.createElement("div");
|
||
bannerEl.setCssStyles({
|
||
position: "absolute",
|
||
bottom: "0",
|
||
left: `${s.footerImageMargin}px`,
|
||
right: `${s.footerImageMargin}px`,
|
||
height: `${footerH}px`,
|
||
backgroundImage: `url('${imgUrl}')`,
|
||
backgroundSize: "cover",
|
||
backgroundPosition: "center",
|
||
backgroundRepeat: "no-repeat",
|
||
pointerEvents: "none",
|
||
});
|
||
shadow.appendChild(bannerEl);
|
||
}
|
||
}
|
||
|
||
// ── Footer text ───────────────────────────────────────────────────────────
|
||
const hasFooter = s.showFooter && pageShowsFooter && (layout.footerLeft || layout.footerRight || layout.footerCenter || s.showFooterBorder);
|
||
if (hasFooter) {
|
||
const footer = activeDocument.createElement("div");
|
||
footer.setCssStyles({
|
||
position: "absolute", bottom: "0", left: "0", right: "0",
|
||
height: `${footerH}px`, display: "flex", alignItems: "center",
|
||
...(s.showFooterBorder ? { borderTop: `0.5px solid ${accentColor}33` } : {}),
|
||
padding: `0 ${mRight}px 0 ${mLeft}px`, fontSize: `${s.footerFontSize}px`, color: s.footerFontColor, fontFamily: fontFamily,
|
||
});
|
||
appendHFNodes(footer, layout.footerCenter, layout.footerLeft, layout.footerRight);
|
||
shadow.appendChild(footer);
|
||
}
|
||
|
||
// ── Frame ────────────────────────────────────────────────────────────────
|
||
// Drawn last so it overlays the page edge on top of header/footer/content.
|
||
const frame = buildFrameOverlayEl(s);
|
||
if (frame) shadow.appendChild(frame);
|
||
}
|
||
}
|
||
|
||
// ── Export ──────────────────────────────────────────────────────────────────
|
||
|
||
private async exportPDF() {
|
||
const s = this.plugin.settings;
|
||
|
||
this.exportBtn.disabled = true;
|
||
this.exportBtn.textContent = "⬇ Exporting…";
|
||
|
||
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.
|
||
// The export button is already disabled, so no paint yield is needed.
|
||
if (!this.layoutCache) {
|
||
// Cancel any pending debounced render — this export render supersedes it.
|
||
if (this.renderDebounceTimer !== null) {
|
||
window.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, mRight, footerH, headerH, contentW, contentH, docCSS, fontFamily, accentColor: exportAccent, pageBackground, isRTL } = cache;
|
||
|
||
const frameHTML = buildFrameOverlayHTML(s);
|
||
|
||
// ── Resolve image URLs once (app:// works in Electron's BrowserWindow) ──────
|
||
const resolvedHeaderBannerUrl = (s.showHeader && s.headerImagePath)
|
||
? resolveImageUrl(this.app, s.headerImagePath) : "";
|
||
const resolvedFooterBannerUrl = (s.showFooter && s.footerImagePath)
|
||
? resolveImageUrl(this.app, s.footerImagePath) : "";
|
||
const resolvedBgImgUrl = (s.backgroundImageEnabled && s.backgroundImagePath)
|
||
? resolveImageUrl(this.app, s.backgroundImagePath) : "";
|
||
|
||
// Page background image HTML (identical on every page; rendered first so it's behind everything)
|
||
const bgImgHTML = resolvedBgImgUrl ? (() => {
|
||
const sz = s.backgroundImageSize === "cover" ? "cover"
|
||
: s.backgroundImageSize === "contain" ? "contain"
|
||
: s.backgroundImageSize === "fill" ? "100% 100%"
|
||
: "auto";
|
||
const rp = s.backgroundImageSize === "tile" ? "repeat" : "no-repeat";
|
||
const pos = s.backgroundImageScope === "content-only"
|
||
? `top:${mTop + headerH}px;left:${mLeft}px;width:${contentW}px;height:${contentH}px;`
|
||
: `inset:0;`;
|
||
const common = `background-image:url('${resolvedBgImgUrl}');background-size:${sz};background-repeat:${rp};background-position:center;opacity:${s.backgroundImageOpacity};pointer-events:none;`;
|
||
return `<div style="position:absolute;${pos}${common}"></div>`;
|
||
})() : "";
|
||
|
||
const pageHTMLParts = layouts.map((layout) => {
|
||
const contentHTML = layout.pageNodes.map((n) => {
|
||
const clone = n.cloneNode(true) as HTMLElement;
|
||
// Styles inside SVGs are kept — mermaid embeds its theme CSS there.
|
||
clone.querySelectorAll("style, script").forEach((el) => {
|
||
if (!el.closest("svg")) el.remove();
|
||
});
|
||
return clone.outerHTML;
|
||
}).join("\n");
|
||
|
||
const pageShowsHeader = s.showHeaderOnFirstPage || layout.pageNum > 1;
|
||
const pageShowsFooter = s.showFooterOnFirstPage || layout.pageNum > 1;
|
||
|
||
const hasExportHeader = s.showHeader && pageShowsHeader && (layout.headerLeft || layout.headerCenter || layout.headerRight || s.showHeaderBorder);
|
||
const headerBorder = s.showHeaderBorder ? `border-bottom:0.5px solid ${exportAccent}33;` : "";
|
||
const headerHTML = hasExportHeader
|
||
? `<div style="position:absolute;top:${mTop * 0.4}px;left:${mLeft}px;right:${mRight}px;display:flex;align-items:center;font-size:${s.headerFontSize}px;color:${s.headerFontColor};font-family:${fontFamily};white-space:nowrap;${headerBorder}">${buildHFInnerHTML(layout.headerCenter, layout.headerLeft, layout.headerRight)}</div>`
|
||
: "";
|
||
|
||
const hasExportFooter = s.showFooter && pageShowsFooter && (layout.footerLeft || layout.footerRight || layout.footerCenter || s.showFooterBorder);
|
||
const footerBorder = s.showFooterBorder ? `border-top:0.5px solid ${exportAccent}33;` : "";
|
||
const footerHTML = hasExportFooter
|
||
? `<div style="position:absolute;bottom:0;left:0;right:0;height:${footerH}px;display:flex;align-items:center;${footerBorder}padding:0 ${mRight}px 0 ${mLeft}px;font-size:${s.footerFontSize}px;color:${s.footerFontColor};font-family:${fontFamily};">${buildHFInnerHTML(layout.footerCenter, layout.footerLeft, layout.footerRight)}</div>`
|
||
: "";
|
||
|
||
const contentDivHTML = `<div class="mpdf-doc"${isRTL ? ' dir="rtl"' : ''} style="position:absolute;top:${mTop + headerH}px;left:${mLeft}px;width:${contentW}px;">${contentHTML}</div>`;
|
||
|
||
// Banner divs precede their text divs so DOM order puts text on top.
|
||
const headerBannerHTML = (pageShowsHeader && resolvedHeaderBannerUrl)
|
||
? `<div style="position:absolute;top:${mTop * 0.4}px;left:${s.headerImageMargin}px;right:${s.headerImageMargin}px;height:${headerH}px;background-image:url('${resolvedHeaderBannerUrl}');background-size:cover;background-position:center;background-repeat:no-repeat;pointer-events:none;"></div>`
|
||
: "";
|
||
const footerBannerHTML = (pageShowsFooter && resolvedFooterBannerUrl)
|
||
? `<div style="position:absolute;bottom:0;left:${s.footerImageMargin}px;right:${s.footerImageMargin}px;height:${footerH}px;background-image:url('${resolvedFooterBannerUrl}');background-size:cover;background-position:center;background-repeat:no-repeat;pointer-events:none;"></div>`
|
||
: "";
|
||
|
||
return `<div class="mpdf-export-page">${bgImgHTML}${headerBannerHTML}${headerHTML}${contentDivHTML}${footerBannerHTML}${footerHTML}${frameHTML}</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>${escapeCSSForStyle(printCSS)}</style>
|
||
</head>
|
||
<body>
|
||
${pageHTMLParts.join("\n")}
|
||
</body>
|
||
</html>`;
|
||
|
||
try {
|
||
// Obsidian uses @electron/remote; the legacy `electron.remote` property is
|
||
// undefined in the renderer process of modern Electron versions.
|
||
const electron = window as unknown as ElectronBridge;
|
||
const remote = electron.require("@electron/remote") as ElectronRemote | null;
|
||
if (!remote?.dialog) throw new Error("no remote");
|
||
|
||
const res = 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;
|
||
}
|
||
|
||
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);
|
||
|
||
// Both events can fire for the same load — a flag ensures only the first one acts.
|
||
let exportHandled = false;
|
||
const cleanupWin = () => { URL.revokeObjectURL(url); win.close(); resetExportBtn(); };
|
||
|
||
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;
|
||
|
||
// Custom sizes embed @page dimensions in the HTML; preferCSSPageSize lets
|
||
// Electron honour that rule. Named sizes pass the size string directly.
|
||
const isCustom = s.pageSize === "Custom";
|
||
win.webContents
|
||
.printToPDF({
|
||
pageSize: isCustom ? "A4" : s.pageSize,
|
||
landscape: !isCustom && s.orientation === "landscape",
|
||
preferCSSPageSize: isCustom,
|
||
printBackground: true,
|
||
margins: { marginType: "none" },
|
||
})
|
||
.then(async (data: Buffer) => {
|
||
// printToPDF produces a flat PDF with no outline; inject one via pdf-lib.
|
||
if (s.includeOutline) {
|
||
try {
|
||
data = await injectPDFOutline(data, extractOutlineEntries(layouts));
|
||
} catch (outlineErr) {
|
||
console.warn("[advanced-pdf-export] outline injection failed:", outlineErr);
|
||
}
|
||
}
|
||
electron.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;
|
||
|
||
/** True when any setting changed while this tab is open; triggers a single render on hide(). */
|
||
private dirty = false;
|
||
|
||
constructor(app: App, plugin: MarkdownPDFPlugin) {
|
||
super(app, plugin);
|
||
this.plugin = plugin;
|
||
}
|
||
|
||
display(): void {
|
||
// Start fresh: if nothing changes before hide(), no render will happen.
|
||
this.dirty = false;
|
||
this.buildSettings();
|
||
}
|
||
|
||
/** Called by Obsidian when the user leaves this tab. Fires one render if settings changed. */
|
||
hide(): void {
|
||
if (this.dirty) {
|
||
this.dirty = false;
|
||
this.plugin.activeModal?.render(true);
|
||
}
|
||
}
|
||
|
||
private async markDirty(): Promise<void> {
|
||
this.dirty = true;
|
||
await this.plugin.saveSettings();
|
||
}
|
||
|
||
/** Builds (or rebuilds) the settings UI. Separated from display() so preset/reset
|
||
* handlers can refresh the form without resetting the dirty flag. */
|
||
private buildSettings(): void {
|
||
const { containerEl } = this;
|
||
containerEl.empty();
|
||
const s = this.plugin.settings;
|
||
|
||
// Shared helpers ────────────────────────────────────────────────────────────
|
||
type NumericFieldKey = "marginTop" | "marginBottom" | "marginLeft" | "marginRight"
|
||
| "headerFontSize" | "footerFontSize" | "frameThickness" | "frameMargin"
|
||
| "headerHeight" | "footerHeight" | "headerImageMargin" | "footerImageMargin";
|
||
const numberSetting = (name: string, key: NumericFieldKey, min?: number, desc?: string) => {
|
||
const st = new Setting(containerEl).setName(name);
|
||
if (desc) st.setDesc(desc);
|
||
return st.addText((t) =>
|
||
t.setValue(String(s[key])).onChange((v) => {
|
||
const n = parseInt(v, 10) || 0;
|
||
s[key] = min !== undefined ? Math.max(min, n) : n;
|
||
void this.markDirty();
|
||
}),
|
||
);
|
||
};
|
||
|
||
type ColorKey = "accentColor" | "bodyColor" | "headingColor" | "pageBackground"
|
||
| "blockquoteBg" | "blockquoteBorderColor" | "tableHeaderBg" | "codeBackground"
|
||
| "headerFontColor" | "footerFontColor" | "frameColor";
|
||
const colorSetting = (name: string, key: ColorKey) =>
|
||
new Setting(containerEl).setName(name).addColorPicker((cp) =>
|
||
cp.setValue(s[key]).onChange((v) => { s[key] = v; void this.markDirty(); }),
|
||
);
|
||
|
||
// ── 1. Style Preset ───────────────────────────────────────────────────────
|
||
new Setting(containerEl).setName("Style Preset").setHeading();
|
||
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((v) => {
|
||
this.plugin.applyPreset(v);
|
||
void this.markDirty().then(() => { this.buildSettings(); });
|
||
});
|
||
})
|
||
.addButton((b) =>
|
||
b.setButtonText("Reset Preset")
|
||
.setTooltip("Reset current preset to its default values")
|
||
.onClick(() => {
|
||
this.plugin.applyPreset(s.preset, true);
|
||
void this.markDirty().then(() => { this.buildSettings(); });
|
||
}),
|
||
);
|
||
|
||
// ── 2. Page ───────────────────────────────────────────────────────────────
|
||
new Setting(containerEl).setName("Page").setHeading();
|
||
let customPageSizeSetting: Setting;
|
||
new Setting(containerEl).setName("Page size").addDropdown((d) => {
|
||
Object.keys(PAGE_SIZES).forEach((k) => { d.addOption(k, k); });
|
||
d.addOption("Custom", "Custom…");
|
||
d.setValue(s.pageSize).onChange((v) => {
|
||
s.pageSize = v;
|
||
customPageSizeSetting.settingEl.toggleClass("mpdf-is-hidden", v !== "Custom");
|
||
void this.markDirty();
|
||
});
|
||
});
|
||
customPageSizeSetting = new Setting(containerEl)
|
||
.setName("Custom page size (mm)")
|
||
.setDesc("Width × Height in millimetres.")
|
||
.addText((t) =>
|
||
t.setPlaceholder("Width").setValue(String(s.customPageWidth))
|
||
.onChange((v) => { s.customPageWidth = Math.max(1, parseFloat(v) || 210); void this.markDirty(); }),
|
||
)
|
||
.addText((t) =>
|
||
t.setPlaceholder("Height").setValue(String(s.customPageHeight))
|
||
.onChange((v) => { s.customPageHeight = Math.max(1, parseFloat(v) || 297); void this.markDirty(); }),
|
||
);
|
||
customPageSizeSetting.settingEl.toggleClass("mpdf-is-hidden", s.pageSize !== "Custom");
|
||
new Setting(containerEl).setName("Orientation").addDropdown((d) =>
|
||
d.addOptions({ portrait: "Portrait", landscape: "Landscape" })
|
||
.setValue(s.orientation)
|
||
.onChange((v) => { s.orientation = v as "portrait" | "landscape"; void this.markDirty(); }),
|
||
);
|
||
|
||
// ── 3. Margin & Frame ─────────────────────────────────────────────────────
|
||
new Setting(containerEl).setName("Margin & Frame").setHeading();
|
||
new Setting(containerEl)
|
||
.setName("Margins")
|
||
.setDesc("Top · Bottom · Left · Right")
|
||
.addText((t) =>
|
||
t.setPlaceholder("Top").setValue(String(s.marginTop))
|
||
.onChange((v) => { s.marginTop = parseInt(v, 10) || 0; void this.markDirty(); }),
|
||
)
|
||
.addText((t) =>
|
||
t.setPlaceholder("Bottom").setValue(String(s.marginBottom))
|
||
.onChange((v) => { s.marginBottom = parseInt(v, 10) || 0; void this.markDirty(); }),
|
||
)
|
||
.addText((t) =>
|
||
t.setPlaceholder("Left").setValue(String(s.marginLeft))
|
||
.onChange((v) => { s.marginLeft = parseInt(v, 10) || 0; void this.markDirty(); }),
|
||
)
|
||
.addText((t) =>
|
||
t.setPlaceholder("Right").setValue(String(s.marginRight))
|
||
.onChange((v) => { s.marginRight = parseInt(v, 10) || 0; void this.markDirty(); }),
|
||
);
|
||
|
||
let frameColorSetting: Setting;
|
||
let frameThicknessSetting: Setting;
|
||
let frameMarginSetting: Setting;
|
||
let frameStyleSetting: Setting;
|
||
const toggleFrameSettings = (visible: boolean) => {
|
||
[frameColorSetting, frameThicknessSetting, frameMarginSetting, frameStyleSetting]
|
||
.forEach((st) => st.settingEl.toggleClass("mpdf-is-hidden", !visible));
|
||
};
|
||
new Setting(containerEl)
|
||
.setName("Enable frame")
|
||
.setDesc("Draws a border around the outer edge of every page.")
|
||
.addToggle((t) =>
|
||
t.setValue(s.frameEnabled).onChange((v) => {
|
||
s.frameEnabled = v;
|
||
toggleFrameSettings(v);
|
||
void this.markDirty();
|
||
}),
|
||
);
|
||
frameColorSetting = colorSetting("Frame color", "frameColor");
|
||
frameThicknessSetting = numberSetting("Frame thickness (px)", "frameThickness", 1);
|
||
frameMarginSetting = numberSetting(
|
||
"Frame margin (px)", "frameMargin", 0,
|
||
"Gap between the page edge and the frame, applied equally on all four sides.",
|
||
);
|
||
frameStyleSetting = new Setting(containerEl).setName("Frame style").addDropdown((d) =>
|
||
d.addOptions({ solid: "Solid", dashed: "Dashed", dotted: "Dotted", double: "Double", groove: "Groove", ridge: "Ridge" })
|
||
.setValue(s.frameStyle)
|
||
.onChange((v) => { s.frameStyle = v as PDFExportSettings["frameStyle"]; void this.markDirty(); }),
|
||
);
|
||
toggleFrameSettings(s.frameEnabled);
|
||
|
||
// ── 4. Typography ─────────────────────────────────────────────────────────
|
||
new Setting(containerEl).setName("Typography").setHeading();
|
||
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((v) => {
|
||
s.fontFamily = v;
|
||
customFontSetting.settingEl.toggleClass("mpdf-is-hidden", v !== "__custom__");
|
||
void 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((v) => { s.customFontName = v; void this.markDirty(); }),
|
||
);
|
||
customFontSetting.settingEl.toggleClass("mpdf-is-hidden", s.fontFamily !== "__custom__");
|
||
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((v) => { s.fontSize = parseInt(v); void 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((v) => { s.codeFontSize = parseFloat(v); void 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((v) => { s.lineHeight = parseFloat(v); void 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((v) => { s.paragraphSpacing = parseFloat(v); void 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((v) => { s.headingScale = parseFloat(v); void this.markDirty(); }),
|
||
);
|
||
|
||
// ── 5. Background ─────────────────────────────────────────────────────────
|
||
new Setting(containerEl).setName("Background").setHeading();
|
||
let bgColorSetting: Setting;
|
||
let bgImgPathSetting: Setting;
|
||
let bgImgSizeSetting: Setting;
|
||
let bgImgScopeSetting: Setting;
|
||
let bgImgOpacitySetting: Setting;
|
||
const showBgMode = (useImage: boolean) => {
|
||
bgColorSetting.settingEl.toggleClass("mpdf-is-hidden", useImage);
|
||
[bgImgPathSetting, bgImgSizeSetting, bgImgScopeSetting, bgImgOpacitySetting]
|
||
.forEach((st) => st.settingEl.toggleClass("mpdf-is-hidden", !useImage));
|
||
};
|
||
new Setting(containerEl)
|
||
.setName("Use image background")
|
||
.setDesc("When on, a background image is used instead of the solid background color.")
|
||
.addToggle((t) =>
|
||
t.setValue(s.backgroundImageEnabled).onChange((v) => {
|
||
s.backgroundImageEnabled = v;
|
||
showBgMode(v);
|
||
void this.markDirty();
|
||
}),
|
||
);
|
||
bgColorSetting = new Setting(containerEl)
|
||
.setName("Page background color")
|
||
.addColorPicker((cp) =>
|
||
cp.setValue(s.pageBackground).onChange((v) => { s.pageBackground = v; void this.markDirty(); }),
|
||
);
|
||
bgImgPathSetting = new Setting(containerEl)
|
||
.setName("Background image")
|
||
.setDesc("Vault-relative path or https:// URL.")
|
||
.addText((t) =>
|
||
t.setPlaceholder("assets/background.png").setValue(s.backgroundImagePath)
|
||
.onChange((v) => { s.backgroundImagePath = v; void this.markDirty(); }),
|
||
);
|
||
bgImgSizeSetting = new Setting(containerEl).setName("Fit").addDropdown((d) =>
|
||
d.addOptions({
|
||
cover: "Cover (fill, crop edges)",
|
||
contain: "Contain (fit, show gaps)",
|
||
fill: "Fill (stretch to exact size)",
|
||
tile: "Tile (repeat)",
|
||
})
|
||
.setValue(s.backgroundImageSize)
|
||
.onChange((v) => { s.backgroundImageSize = v as PDFExportSettings["backgroundImageSize"]; void this.markDirty(); }),
|
||
);
|
||
bgImgScopeSetting = new Setting(containerEl)
|
||
.setName("Scope")
|
||
.setDesc("Full page includes header and footer bands. Content area only restricts the background to the text zone between them.")
|
||
.addDropdown((d) =>
|
||
d.addOptions({ "full-page": "Full page", "content-only": "Content area only" })
|
||
.setValue(s.backgroundImageScope)
|
||
.onChange((v) => { s.backgroundImageScope = v as PDFExportSettings["backgroundImageScope"]; void this.markDirty(); }),
|
||
);
|
||
bgImgOpacitySetting = new Setting(containerEl).setName("Opacity").addDropdown((d) =>
|
||
d.addOptions({ "0.05": "5%", "0.1": "10%", "0.15": "15%", "0.2": "20%", "0.3": "30%", "0.4": "40%", "0.5": "50%", "0.6": "60%", "0.7": "70%", "0.8": "80%", "0.9": "90%", "1": "100%" })
|
||
.setValue(String(s.backgroundImageOpacity))
|
||
.onChange((v) => { s.backgroundImageOpacity = parseFloat(v); void this.markDirty(); }),
|
||
);
|
||
showBgMode(s.backgroundImageEnabled);
|
||
|
||
// ── 6. Colors ─────────────────────────────────────────────────────────────
|
||
new Setting(containerEl).setName("Colors").setHeading();
|
||
colorSetting("Accent color", "accentColor");
|
||
colorSetting("Body text color", "bodyColor");
|
||
colorSetting("Heading color", "headingColor");
|
||
colorSetting("Blockquote background", "blockquoteBg");
|
||
colorSetting("Blockquote border", "blockquoteBorderColor");
|
||
colorSetting("Table header background", "tableHeaderBg");
|
||
colorSetting("Code background", "codeBackground");
|
||
new Setting(containerEl)
|
||
.setName("Code syntax theme")
|
||
.setDesc("Independent of your Obsidian theme. \"None\" uses the body text color and code background above with no highlighting.")
|
||
.addDropdown((d) => {
|
||
const opts: Record<string, string> = {};
|
||
for (const [key, theme] of Object.entries(CODE_THEMES)) opts[key] = theme.name;
|
||
d.addOptions(opts).setValue(s.codeTheme).onChange((v) => { s.codeTheme = v; void this.markDirty(); });
|
||
});
|
||
|
||
// ── 7. Header ─────────────────────────────────────────────────────────────
|
||
new Setting(containerEl).setName("Header").setHeading();
|
||
new Setting(containerEl).setName("Show header").addToggle((t) =>
|
||
t.setValue(s.showHeader).onChange((v) => { s.showHeader = v; void this.markDirty(); }),
|
||
);
|
||
new Setting(containerEl)
|
||
.setName("Show on first page")
|
||
.setDesc("When off, the header is hidden on page 1. Useful for title pages.")
|
||
.addToggle((t) =>
|
||
t.setValue(s.showHeaderOnFirstPage).onChange((v) => { s.showHeaderOnFirstPage = v; void this.markDirty(); }),
|
||
);
|
||
new Setting(containerEl)
|
||
.setName("Header text")
|
||
.addText((t) => t.setValue(s.headerText).onChange((v) => { s.headerText = v; void this.markDirty(); }));
|
||
new Setting(containerEl).setName("Alignment").addDropdown((d) =>
|
||
d.addOptions({ left: "Left", center: "Center", right: "Right" })
|
||
.setValue(s.headerAlignment)
|
||
.onChange((v) => { s.headerAlignment = v as "left"|"center"|"right"; void this.markDirty(); }),
|
||
);
|
||
numberSetting("Font size (px)", "headerFontSize", 1);
|
||
numberSetting("Height (px)", "headerHeight", 0, "Explicit band height (0 = auto).");
|
||
colorSetting("Font color", "headerFontColor");
|
||
new Setting(containerEl).setName("Border").setDesc("Separator line below the header.").addToggle((t) =>
|
||
t.setValue(s.showHeaderBorder).onChange((v) => { s.showHeaderBorder = v; void this.markDirty(); }),
|
||
);
|
||
new Setting(containerEl)
|
||
.setName("Image")
|
||
.setDesc("Vault-relative path or https:// URL. Fills the header band as a background banner.")
|
||
.addText((t) =>
|
||
t.setPlaceholder("assets/header-banner.png").setValue(s.headerImagePath)
|
||
.onChange((v) => { s.headerImagePath = v; void this.markDirty(); }),
|
||
);
|
||
numberSetting("Image left/right margin (px)", "headerImageMargin", 0, "Insets the banner from the left and right page edges.");
|
||
|
||
// ── 8. Footer ─────────────────────────────────────────────────────────────
|
||
new Setting(containerEl).setName("Footer").setHeading();
|
||
new Setting(containerEl).setName("Show footer").addToggle((t) =>
|
||
t.setValue(s.showFooter).onChange((v) => { s.showFooter = v; void this.markDirty(); }),
|
||
);
|
||
new Setting(containerEl)
|
||
.setName("Show on first page")
|
||
.setDesc("When off, the footer and page numbers are hidden on page 1. Page numbering starts from page 2.")
|
||
.addToggle((t) =>
|
||
t.setValue(s.showFooterOnFirstPage).onChange((v) => { s.showFooterOnFirstPage = v; void this.markDirty(); }),
|
||
);
|
||
new Setting(containerEl)
|
||
.setName("Footer text")
|
||
.addText((t) => t.setValue(s.footerText).onChange((v) => { s.footerText = v; void this.markDirty(); }));
|
||
new Setting(containerEl).setName("Alignment").addDropdown((d) =>
|
||
d.addOptions({ left: "Left", center: "Center", right: "Right" })
|
||
.setValue(s.footerTextAlignment)
|
||
.onChange((v) => { s.footerTextAlignment = v as "left" | "center" | "right"; void this.markDirty(); }),
|
||
);
|
||
numberSetting("Font size (px)", "footerFontSize", 1);
|
||
numberSetting("Height (px)", "footerHeight", 0, "Explicit band height (0 = auto).");
|
||
colorSetting("Font color", "footerFontColor");
|
||
new Setting(containerEl).setName("Border").setDesc("Separator line above the footer.").addToggle((t) =>
|
||
t.setValue(s.showFooterBorder).onChange((v) => { s.showFooterBorder = v; void this.markDirty(); }),
|
||
);
|
||
new Setting(containerEl)
|
||
.setName("Image")
|
||
.setDesc("Vault-relative path or https:// URL. Fills the footer band as a background banner.")
|
||
.addText((t) =>
|
||
t.setPlaceholder("assets/footer-banner.png").setValue(s.footerImagePath)
|
||
.onChange((v) => { s.footerImagePath = v; void this.markDirty(); }),
|
||
);
|
||
numberSetting("Image left/right margin (px)", "footerImageMargin", 0, "Insets the banner from the left and right page edges.");
|
||
new Setting(containerEl).setName("Show page numbers").addToggle((t) =>
|
||
t.setValue(s.showPageNumbers).onChange((v) => { s.showPageNumbers = v; void this.markDirty(); }),
|
||
);
|
||
new Setting(containerEl).setName("Page number position").addDropdown((d) =>
|
||
d.addOptions({ left: "Left", center: "Center", right: "Right" })
|
||
.setValue(s.pageNumberPosition)
|
||
.onChange((v) => { s.pageNumberPosition = v as "left"|"center"|"right"; void this.markDirty(); }),
|
||
);
|
||
new Setting(containerEl)
|
||
.setName("Page number start")
|
||
.setDesc("Number assigned to the first visible page number.")
|
||
.addText((t) =>
|
||
t.setValue(String(s.pageNumberStart))
|
||
.onChange((v) => { s.pageNumberStart = parseInt(v, 10) || 1; void this.markDirty(); }),
|
||
);
|
||
|
||
// ── 9. Behaviour ──────────────────────────────────────────────────────────
|
||
new Setting(containerEl).setName("Behaviour").setHeading();
|
||
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((v) => { s.hideFrontmatter = v; void 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.")
|
||
.addToggle((t) =>
|
||
t.setValue(s.includeFilenameAsTitle).onChange((v) => { s.includeFilenameAsTitle = v; void this.markDirty(); }),
|
||
);
|
||
new Setting(containerEl)
|
||
.setName("Underline links")
|
||
.setDesc("Applies to both internal and external links.")
|
||
.addToggle((t) =>
|
||
t.setValue(s.linkUnderline).onChange((v) => { s.linkUnderline = v; void this.markDirty(); }),
|
||
);
|
||
new Setting(containerEl).setName("Auto page break before H1").addToggle((t) =>
|
||
t.setValue(s.autoBreakH1).onChange((v) => { s.autoBreakH1 = v; void this.markDirty(); }),
|
||
);
|
||
new Setting(containerEl).setName("Auto page break before H2").addToggle((t) =>
|
||
t.setValue(s.autoBreakH2).onChange((v) => { s.autoBreakH2 = v; void this.markDirty(); }),
|
||
);
|
||
new Setting(containerEl).setName("H1 bottom border").addToggle((t) =>
|
||
t.setValue(s.h1BorderBottom).onChange((v) => { s.h1BorderBottom = v; void this.markDirty(); }),
|
||
);
|
||
new Setting(containerEl).setName("H2 bottom border").addToggle((t) =>
|
||
t.setValue(s.h2BorderBottom).onChange((v) => { s.h2BorderBottom = v; void this.markDirty(); }),
|
||
);
|
||
new Setting(containerEl).setName("Center H1").addToggle((t) =>
|
||
t.setValue(s.centerH1).onChange((v) => { s.centerH1 = v; void this.markDirty(); }),
|
||
);
|
||
new Setting(containerEl).setName("Striped table rows").addToggle((t) =>
|
||
t.setValue(s.tableStriped).onChange((v) => { s.tableStriped = v; void this.markDirty(); }),
|
||
);
|
||
new Setting(containerEl)
|
||
.setName("Include PDF outline (bookmarks)")
|
||
.setDesc(
|
||
"Embeds a bookmark tree into the exported PDF. " +
|
||
"Most PDF readers display it in a side panel for quick navigation.",
|
||
)
|
||
.addToggle((t) =>
|
||
t.setValue(s.includeOutline).onChange((v) => { s.includeOutline = v; void this.markDirty(); }),
|
||
);
|
||
}
|
||
}
|