refactor: split main.ts into modular components

This commit is contained in:
Walid 2026-07-12 19:35:27 +06:00
parent d1b733e11b
commit e3612c8bda
15 changed files with 3464 additions and 3385 deletions

View file

@ -1,36 +0,0 @@
{
"pageSize": "A4",
"orientation": "portrait",
"preset": "colorful",
"name": "Colorful",
"fontFamily": "Georgia, serif",
"fontSize": 13,
"lineHeight": 1.85,
"paragraphSpacing": 0.65,
"headingScale": 1.05,
"accentColor": "#e84393",
"bodyColor": "#1a1a2e",
"headingColor": "#2d0a4e",
"h1BorderBottom": false,
"h2BorderBottom": false,
"centerH1": false,
"blockquoteBg": "#fdf0f8",
"blockquoteBorderColor": "#e84393",
"codeBackground": "#f0eaff",
"codeFontSize": 0.85,
"tableHeaderBg": "#2d0a4e",
"tableStriped": true,
"marginTop": 20,
"marginBottom": 20,
"marginLeft": 25,
"marginRight": 25,
"headerText": "",
"footerText": "",
"showHeader": true,
"showFooter": false,
"showPageNumbers": false,
"pageNumberPosition": "right",
"autoBreakH1": false,
"autoBreakH2": false,
"previewScale": 0.8
}

View file

@ -19,7 +19,7 @@ const external = [
];
const ctx = await esbuild.context({
entryPoints: ["main.ts"],
entryPoints: ["src/main.ts"],
bundle: true,
outfile: "main.js",
external,

112
main.js

File diff suppressed because one or more lines are too long

3287
main.ts

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{
"id": "advanced-pdf-export",
"name": "Advanced PDF Export",
"version": "4.2.0",
"version": "4.2.1",
"minAppVersion": "1.11.1",
"description": "Export notes as pixel-perfect PDFs with live preview, style presets, manual page breaks, tables, and full layout control.",
"author": "ShrekBytes",

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "obsidian-advanced-pdf-export",
"version": "4.2.0",
"version": "4.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-advanced-pdf-export",
"version": "4.2.0",
"version": "4.2.1",
"dependencies": {
"pdf-lib": "^1.17.1"
},

View file

@ -1,6 +1,6 @@
{
"name": "obsidian-advanced-pdf-export",
"version": "4.2.0",
"version": "4.2.1",
"description": "Obsidian plugin for advanced PDF export",
"author": "ShrekBytes",
"repository": {
@ -21,5 +21,8 @@
},
"dependencies": {
"pdf-lib": "^1.17.1"
},
"allowScripts": {
"esbuild@0.25.5": true
}
}

578
src/css-builder.ts Normal file
View file

@ -0,0 +1,578 @@
// ─────────────────────────────────────────────────────────────────────────────
// CSS generation.
//
// Turns a PDFExportSettings object into the actual CSS strings the preview
// (shadow DOM) and export (print HTML) paths both consume — plus the MathJax
// stylesheet-extraction helpers needed to make typeset formulas show up
// correctly in either path. Everything here is pure string-building; it
// doesn't touch the DOM beyond reading already-rendered MathJax stylesheets.
// ─────────────────────────────────────────────────────────────────────────────
import { App, TFile, requestUrl } from "obsidian";
import { PAGE_SIZES, PDFExportSettings } from "./settings";
// ─── Small utilities ──────────────────────────────────────────────────────────
/** mm → px at 96 dpi, matching Electron's print pipeline. */
export 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).
*/
export 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"];
}
export function escapeHTML(s: string): string {
return s
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// Escapes `</style` in CSS text to prevent the HTML parser from ending the
// <style> block early, regardless of where the sequence appears.
export 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. */
export 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);
console.warn("[advanced-pdf-export] Could not resolve image path:", trimmed);
return trimmed;
}
/** Maps a `backgroundImageSize` setting value to its CSS `background-size`
* and `background-repeat` values. Centralises logic that would otherwise be
* duplicated across the preview and export render paths. */
export function bgImageCssProps(size: PDFExportSettings["backgroundImageSize"]): { size: string; repeat: string } {
return {
size: size === "fill" ? "100% 100%" : size === "tile" ? "auto" : size, // "cover" | "contain" pass through
repeat: size === "tile" ? "repeat" : "no-repeat",
};
}
// ─── 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>>;
}
export 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}`;
}
// ─── Doc CSS builder ──────────────────────────────────────────────────────────
/** Returns relative luminance (01) 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. */
export function resolveFont(s: PDFExportSettings): string {
return s.fontFamily === "__custom__" ? (s.customFontName.trim() || "inherit") : s.fontFamily;
}
/** Builds the full `.mpdf-doc` stylesheet (typography, tables, callouts,
* mermaid, code blocks) shared verbatim by the preview shadow DOM and the
* export print HTML. */
export 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;"} }
/* postProcessRenderedHTML removes the external-link class, but some themes
re-inject it at render time. This rule is cheap defense-in-depth. */
.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();
}
// ─── MathJax CSS extraction ───────────────────────────────────────────────────
// MathJax's CHTML output renders formulas via per-glyph CSS rules it writes
// incrementally at typeset time, rather than a static stylesheet — these
// helpers pull that generated CSS out so the preview shadow DOM and the
// export print HTML can both include it.
/** Collects MathJax's CSS for inclusion in shadow DOMs and the export HTML.
* Checked from three places missing any one silently drops specific
* glyphs, with no error, while everything else renders fine:
* 1. `style[id^='MJX-']` elements' `.textContent` static `@font-face`
* rules plus whatever common characters were in the tag's original text.
* 2. Those same elements' live `.sheet.cssRules` — MathJax's `adaptiveCSS`
* mode adds per-glyph rules via `insertRule()`, which updates the live
* sheet but never touches `.textContent`. Reading it directly makes
* source 1 redundant whenever `.sheet` is accessible; textContent is
* kept only as a fallback for the rare case a sheet can't be reached.
* 3. `document.adoptedStyleSheets` Constructable Stylesheets have no
* corresponding `<style>` DOM node, so no amount of
* `querySelectorAll('style')` can see them regardless of which
* property is read. */
export function getMathJaxCSS(): string {
const styleEls = Array.from(
activeDocument.head.querySelectorAll<HTMLStyleElement>("style[id^='MJX-']"),
);
const styleTagCSS = styleEls
.map((el) => {
try {
if (el.sheet?.cssRules) {
return Array.from(el.sheet.cssRules).map((rule) => rule.cssText).join("\n");
}
} catch {
// sheet inaccessible on this element; fall back to textContent below
}
return el.textContent ?? "";
})
.join("\n");
let adoptedCSS = "";
try {
const sheets = activeDocument.adoptedStyleSheets ?? [];
adoptedCSS = sheets
.map((sheet) => {
try {
return Array.from(sheet.cssRules).map((rule) => rule.cssText).join("\n");
} catch {
return ""; // rules inaccessible on this sheet; skip it, keep the rest
}
})
.filter(Boolean)
.join("\n");
} catch (err) {
// adoptedStyleSheets unsupported in this Electron/Chromium build — degrade
// to style-tag CSS only.
console.warn("[advanced-pdf-export] adoptedStyleSheets read failed:", err);
}
return adoptedCSS ? `${styleTagCSS}\n${adoptedCSS}` : styleTagCSS;
}
/** Strips @font-face rules from CSS before injecting into shadow-DOM adoptedStyleSheets.
* Some Electron builds silently fail to load fonts declared there; MathJax's own
* document-head @font-face rules remain accessible to shadow-DOM content per spec. */
export function stripAtFontFaces(css: string): string {
return css.replace(/@font-face\s*\{[^{}]*(?:\{[^{}]*\}[^{}]*)?\}/g, "");
}
// Matches url(...) references in CSS — used by getMathJaxCSSInlined to collect
// and replace font URLs with base64 data URIs. String.replace() and matchAll()
// both reset lastIndex to 0 on each call, so this g-flagged constant is safe to share.
const CSS_URL_RE = /url\(\s*["']?([^"')]+)["']?\s*\)/g;
/** Returns MathJax CSS with all font url() references replaced by base64 data URIs.
* The export BrowserWindow (loaded from a blob: URL) cannot resolve app:// font
* paths; inlining makes math characters visible. Falls back to the original URL
* on fetch error. */
export async function getMathJaxCSSInlined(): Promise<string> {
const css = getMathJaxCSS();
if (!css) return "";
const urlSet = new Set<string>();
for (const [, u] of css.matchAll(CSS_URL_RE)) {
const trimmedU = u.trim();
if (!trimmedU.startsWith("data:")) urlSet.add(trimmedU);
}
if (!urlSet.size) return css;
const toDataUri = async (url: string): Promise<string> => {
try {
const res = await requestUrl(url);
if (res.status !== 200) return url;
const bytes = new Uint8Array(res.arrayBuffer);
let bin = "";
for (let i = 0; i < bytes.length; i += 8192) // chunk to avoid stack overflow
bin += String.fromCharCode(...bytes.subarray(i, Math.min(i + 8192, bytes.length)));
const b64 = btoa(bin);
const low = url.toLowerCase();
const mime = low.includes("woff2") ? "font/woff2"
: low.includes("woff") ? "font/woff"
: low.includes(".ttf") ? "font/ttf"
: "font/otf";
return `data:${mime};base64,${b64}`;
} catch {
return url;
}
};
const entries = await Promise.all(
Array.from(urlSet).map(async (url) => [url, await toDataUri(url)] as const),
);
const resolved = new Map(entries);
return css.replace(CSS_URL_RE, (match, raw: string) => {
const data = resolved.get(raw.trim());
return data && data !== raw.trim() ? `url('${data}')` : match;
});
}
/** Polls `getMathJaxCSS()`'s output size until it stops changing for two
* consecutive checks, confirming MathJax's `adaptiveCSS` has finished writing
* per-glyph rules for this render pass DOM insertion of `mjx-container`
* alone doesn't guarantee that. Bounded by MAX_WAIT_MS so an already-settled
* stylesheet doesn't stall the export; resolves in ~2 polls in that case. */
export async function waitForMathJaxStylesheetStable(): Promise<void> {
const POLL_MS = 60;
const STABLE_ROUNDS = 2;
const MAX_WAIT_MS = 2500;
const start = Date.now();
let lastLen = getMathJaxCSS().length;
let stableRounds = 0;
while (stableRounds < STABLE_ROUNDS && Date.now() - start < MAX_WAIT_MS) {
await new Promise<void>((resolve) => window.setTimeout(resolve, POLL_MS));
const len = getMathJaxCSS().length;
if (len === lastLen) {
stableRounds++;
} else {
stableRounds = 0;
lastLen = len;
}
}
}

972
src/export-modal.ts Normal file
View file

@ -0,0 +1,972 @@
// ─────────────────────────────────────────────────────────────────────────────
// Export panel modal: UI, render orchestration, live preview, and PDF export.
//
// drawPreview() and exportPDF() intentionally live in the same file: they
// build the *same* page structure (background → header banner → header text
// → content → footer banner → footer text → frame) from the same
// LayoutCache, just into two different targets — a live shadow-DOM preview
// vs. an HTML string handed to Electron's print pipeline. Keeping them
// together means a layout change only needs to be made once, in one place.
// ─────────────────────────────────────────────────────────────────────────────
import {
App, Component, MarkdownView, Modal, Notice, TFile, setIcon,
} from "obsidian";
import type MarkdownPDFPlugin from "./main";
import { PAGE_SIZES, PRESETS, PDFExportSettings } from "./settings";
import {
mmToPx, resolvePageDims, escapeHTML, escapeCSSForStyle, resolveImageUrl,
bgImageCssProps, buildDocCSS, resolveFont, getMathJaxCSS, stripAtFontFaces,
getMathJaxCSSInlined, waitForMathJaxStylesheetStable,
} from "./css-builder";
import {
normalizeMarkdown, stripFrontmatter, splitMarkdownSections, isRTLContent,
renderMarkdownToEl,
} from "./markdown";
import {
paginateEl, buildPageLayouts, extractOutlineEntries, injectPDFOutline, PageLayout,
} from "./paginator";
// ─── Electron type shims ────────────────────────────────────────────────────────
// Minimal shims — just enough for the PDF export path below.
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;
executeJavaScript(code: string): Promise<unknown>;
printToPDF(options: {
pageSize: string;
landscape: boolean;
printBackground: boolean;
preferCSSPageSize?: boolean;
margins: { marginType: string };
}): Promise<Uint8Array>;
};
}
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: Uint8Array, cb: (err: Error | null) => void): void;
}
interface ElectronBridge {
require(module: "@electron/remote"): ElectronRemote;
require(module: "fs"): ElectronFs;
require(module: string): unknown;
}
interface AppWithSettings {
setting?: {
open?: () => void;
openTabById?: (id: string) => void;
};
}
// 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;
}
// ─── Header / footer / frame rendering helpers ───────────────────────────────
// Shared by drawPreview (DOM) and exportPDF (HTML string) below.
/** 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>`;
}
// ─── File resolver ────────────────────────────────────────────────────────────
// Two-level cascade: explicit file → active file (if markdown) → most recent MarkdownView leaf.
function resolveActiveMarkdownFile(app: App, initialFile?: TFile | null): TFile | null {
if (initialFile) return initialFile;
// getActiveFile() returns the focused file regardless of view type;
// the extension check is enough to confirm it is a markdown file.
const activeFile = app.workspace.getActiveFile();
if (activeFile?.extension === "md") return activeFile;
// Fall back to the most recently used leaf that holds a MarkdownView,
// covering cases where focus is on a non-file pane (search, settings, etc.).
const leaf = app.workspace.getMostRecentLeaf();
if (leaf?.view instanceof MarkdownView) return leaf.view.file ?? null;
return null;
}
// ─── Modal ────────────────────────────────────────────────────────────────────
export class PDFExportModal extends Modal {
plugin: MarkdownPDFPlugin;
// These, like renderBtn/exportBtn/loadingOverlayEl below, are assigned
// unconditionally in buildUI() (called first thing in onOpen()) — never
// read before then, so a definite-assignment assertion is accurate here.
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) {
let content: string;
try {
content = await this.app.vault.read(file);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
new Notice(`Failed to read file "${file.basename}": ${msg}`);
return;
}
// Guard: user may have closed the modal while vault.read was in flight.
if (!this.plugin.activeModal) return;
this.currentFile = 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("Advanced PDF Export — render failed: " + 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;
// Re-confirm the MathJax stylesheet is settled immediately before reading it.
await waitForMathJaxStylesheetStable();
// Strip @font-face before adding MathJax CSS to adoptedStyleSheets — document.head
// fonts are accessible to shadow DOM per spec, and some Electron builds silently
// fail to load fonts declared inside an adopted CSSStyleSheet.
const rawMathCSS = getMathJaxCSS();
const shadowMathCSS = rawMathCSS ? stripAtFontFaces(rawMathCSS) : "";
const fullCSS = shadowMathCSS ? `${shadowMathCSS}\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");
const bgCss = bgImageCssProps(s.backgroundImageSize);
bgEl.setCssStyles({
position: "absolute",
...(isContentOnly
? { top: `${mTop + headerH}px`, left: `${mLeft}px`, width: `${contentW}px`, height: `${contentH}px` }
: { inset: "0" }),
backgroundImage: `url('${bgUrl}')`,
backgroundRepeat: bgCss.repeat,
backgroundSize: bgCss.size,
backgroundPosition: "center",
opacity: String(s.backgroundImageOpacity),
pointerEvents: "none",
});
shadow.appendChild(bgEl);
}
}
const pageShowsHeader = layout.pageShowsHeader;
const pageShowsFooter = layout.pageShowsFooter;
// ── 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 = layout.hasHeader;
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 = layout.hasFooter;
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("Advanced PDF Export — render failed: " + 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 bgCss = bgImageCssProps(s.backgroundImageSize);
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:${bgCss.size};background-repeat:${bgCss.repeat};background-position:center;opacity:${s.backgroundImageOpacity};pointer-events:none;`;
return `<div style="position:absolute;${pos}${common}"></div>`;
})() : "";
const pageHTMLParts = layouts.map((layout) => {
// pageNodes have already been through postProcessRenderedHTML, which
// strips style/script tags (preserving those inside SVGs for mermaid).
// No further sanitisation needed — serialize directly.
const contentHTML = layout.pageNodes.map((n) => n.outerHTML).join("\n");
const hasExportHeader = layout.hasHeader;
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;height:${headerH}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 = layout.hasFooter;
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 = (layout.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 = (layout.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>`;
});
// Re-confirm the MathJax stylesheet is settled immediately before the
// capture that ends up in the exported PDF.
await waitForMathJaxStylesheetStable();
// Inline MathJax fonts as base64 data URIs — the export BrowserWindow's blob:
// origin cannot resolve the app:// font paths MathJax normally references.
const inlinedMathCSS = await getMathJaxCSSInlined();
const printCSS = `
*, *::before, *::after { box-sizing: border-box; }
@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>
${inlinedMathCSS ? `<style>${escapeCSSForStyle(inlinedMathCSS)}</style>` : ""}
<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("Advanced PDF Export — 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("Advanced PDF Export — failed to load page: " + desc);
cleanupWin();
});
win.webContents.once("did-finish-load", () => {
if (exportHandled) return;
exportHandled = true;
// Ensure all @font-face fonts (including inlined MathJax fonts) are loaded
// before capturing the PDF so math characters are not invisible.
win.webContents
.executeJavaScript("document.fonts.ready.then(() => true)")
.catch(() => true)
.then(() => {
// 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";
return win.webContents.printToPDF({
pageSize: isCustom ? "A4" : s.pageSize,
landscape: !isCustom && s.orientation === "landscape",
preferCSSPageSize: isCustom,
printBackground: true,
margins: { marginType: "none" },
});
})
.then(async (data: Uint8Array) => {
// 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("Advanced PDF Export — failed to save: " + err.message);
else new Notice("✓ PDF saved — " + res.filePath);
cleanupWin();
});
})
.catch((err: Error) => {
new Notice("Advanced PDF Export — failed to render: " + err.message);
cleanupWin();
});
});
} catch {
new Notice("Advanced PDF Export requires the Obsidian desktop app.");
resetExportBtn();
}
}
}

126
src/main.ts Normal file
View file

@ -0,0 +1,126 @@
// ─────────────────────────────────────────────────────────────────────────────
// Plugin entry point.
//
// Thin bootstrap: registers the command/context-menu entry, owns the loaded
// settings object, and hands off to PDFExportModal (export-modal.ts) and
// PDFExportSettingTab (settings-tab.ts) for everything UI-related.
// ─────────────────────────────────────────────────────────────────────────────
import { Menu, Plugin, TFile } from "obsidian";
import {
DocStyle, PDFExportSettings, PRESETS, DEFAULT_SETTINGS, extractDocStyle,
} from "./settings";
import { PDFExportModal } from "./export-modal";
import { PDFExportSettingTab } from "./settings-tab";
import { warmUpMathJax } from "./markdown";
export default class MarkdownPDFPlugin extends Plugin {
declare settings: PDFExportSettings;
activeModal: PDFExportModal | null = null;
presetSnapshots: Record<string, DocStyle> = {};
async onload() {
await this.loadSettings();
// Fire-and-forget: pay MathJax's one-time cold-start cost now, in the
// background, so it's already done by the time the user opens the export
// modal instead of adding several seconds to their first real render.
void warmUpMathJax(this.app);
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 ?? {};
this.validateSettings();
}
/** Clamps critical numeric settings to safe ranges and repairs invalid
* enum values. Called after load so corrupt or manually-edited JSON
* cannot produce invisible text, zero-size pages, or bad layout. */
private validateSettings(): void {
const s = this.settings;
// Unknown preset key → fall back to default.
if (!(s.preset in PRESETS)) {
console.warn(`[advanced-pdf-export] Unknown preset "${s.preset}", resetting to "default".`);
s.preset = "default";
}
// Font sizes must be positive to produce visible text.
s.fontSize = Math.max(1, s.fontSize);
s.headerFontSize = Math.max(1, s.headerFontSize);
s.footerFontSize = Math.max(1, s.footerFontSize);
// Margins must be non-negative; zero is allowed (full-bleed layouts).
s.marginTop = Math.max(0, s.marginTop);
s.marginBottom = Math.max(0, s.marginBottom);
s.marginLeft = Math.max(0, s.marginLeft);
s.marginRight = Math.max(0, s.marginRight);
// Custom page dimensions need a printable minimum.
s.customPageWidth = Math.max(10, s.customPageWidth);
s.customPageHeight = Math.max(10, s.customPageHeight);
// Preview scale must be a usable positive fraction.
s.previewScale = Math.max(0.1, Math.min(3, s.previewScale));
// Page-number start must be at least 1.
s.pageNumberStart = Math.max(1, s.pageNumberStart);
// Page-number format: fall back to default when cleared.
if (!s.pageNumberFormat || !s.pageNumberFormat.trim()) {
s.pageNumberFormat = DEFAULT_SETTINGS.pageNumberFormat;
}
}
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] ?? {}));
}
}

261
src/markdown.ts Normal file
View file

@ -0,0 +1,261 @@
// ─────────────────────────────────────────────────────────────────────────────
// Markdown preprocessing + render pipeline.
//
// Everything involved in turning a note's raw markdown text into clean,
// paginator-ready HTML: text-level prep (line-ending normalisation,
// frontmatter stripping, manual page-break splitting, RTL detection),
// then handing sections off to Obsidian's MarkdownRenderer and waiting out
// its async post-processors (Mermaid, MathJax) before cleaning up the result.
// ─────────────────────────────────────────────────────────────────────────────
import { App, Component, MarkdownRenderer, finishRenderMath } from "obsidian";
import { waitForMathJaxStylesheetStable } from "./css-builder";
// ─── Markdown text helpers ─────────────────────────────────────────────────────
/** Normalises line endings to LF so the rest of the pipeline never sees CRLF or CR. */
export function normalizeMarkdown(raw: string): string {
return raw.replace(/\r\n|\r/g, "\n");
}
/** Strips an opening YAML frontmatter block (--- … ---). Input must be LF-normalised. */
export 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. */
export function splitMarkdownSections(md: string): string[] {
return md
.split(/^\/\/\/\s*$/m)
.map((s) => s.trim())
.filter(Boolean);
}
/** 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;
export 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;
}
// ─── Rendered-HTML cleanup ──────────────────────────────────────────────────────
// Pre-compiled once. String.replace() and String.matchAll() both reset a
// regex's lastIndex to 0 on each call, so module-level g-flagged constants are safe.
const SLUG_STRIP = /[^\p{L}\p{N}\s-]/gu;
const SLUG_SPACE = /\s+/g;
const SLUG_DASH = /-+/g;
function slugifyHeading(text: string): string {
return text
.toLowerCase()
.replace(SLUG_STRIP, "")
.trim()
.replace(SLUG_SPACE, "-")
.replace(SLUG_DASH, "-");
}
// 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();
});
}
// ─── Async post-processor waits ────────────────────────────────────────────────
/** 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 });
}),
),
);
}
/** Waits for MathJax to finish typesetting `.math` spans (native math and
* Latex Suite equations share the same renderer).
*
* `MathJax.startup.promise` is awaited first as a cheap early gate it
* resolves once, at MathJax's initial boot, so it proves nothing about
* whether this particular render pass is finished. The real wait is a
* per-element `mjx-container` observer, followed by
* `waitForMathJaxStylesheetStable()`: MathJax's CHTML `adaptiveCSS` mode
* writes per-glyph rules to a shared stylesheet incrementally as new
* characters are encountered, and DOM insertion of `mjx-container` alone
* doesn't guarantee that write has landed yet. Bounded at 8s + 2.5s so a
* stuck render can't hang the export indefinitely. */
async function waitForMathRendering(el: HTMLElement): Promise<void> {
const containers = Array.from(el.querySelectorAll<HTMLElement>(".math"));
if (containers.length === 0) return;
const mathJax = (window as unknown as {
MathJax?: { startup?: { promise?: Promise<unknown> } };
}).MathJax;
if (mathJax?.startup?.promise) {
try { await mathJax.startup.promise; } catch { /* fall through to per-element wait */ }
}
const TIMEOUT_MS = 8000;
await Promise.all(
containers
.filter((m) => !m.querySelector("mjx-container"))
.map(
(m) =>
new Promise<void>((resolve) => {
const timer = window.setTimeout(() => { obs.disconnect(); resolve(); }, TIMEOUT_MS);
const obs = new MutationObserver(() => {
if (m.querySelector("mjx-container")) {
window.clearTimeout(timer);
obs.disconnect();
resolve();
}
});
obs.observe(m, { childList: true, subtree: true });
}),
),
);
await waitForMathJaxStylesheetStable();
}
// ─── Render pipeline ────────────────────────────────────────────────────────────
/** Renders a markdown string to a detached HTML element via Obsidian's
* MarkdownRenderer, waiting for Mermaid/MathJax post-processors and web
* fonts to finish before returning the cleaned-up result. */
export async function renderMarkdownToEl(
app: App,
markdown: string,
sourcePath: string,
component: Component,
): Promise<HTMLElement> {
const temp = activeDocument.createElement("div");
// Attached offscreen so Obsidian's async post-processors (mermaid, math) run in a real DOM context.
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);
// Flushes Obsidian's MathJax render queue — MarkdownRenderer.render()'s own
// promise can resolve before queued math has actually finished typesetting.
// Bounded with a race: on a cold Obsidian session (MathJax not yet used
// anywhere in this window), this has been observed to hang far longer than
// expected instead of resolving, blocking the whole render indefinitely.
// waitForMathRendering()/waitForMathJaxStylesheetStable() below are the
// real, already-bounded correctness check regardless of whether this
// resolves in time — so there's no harm in giving up on it early.
const FINISH_RENDER_MATH_TIMEOUT_MS = 3000;
try {
let timer: number;
await Promise.race([
finishRenderMath().finally(() => window.clearTimeout(timer)),
new Promise<void>((resolve) => { timer = window.setTimeout(resolve, FINISH_RENDER_MATH_TIMEOUT_MS); }),
]);
} catch (err) {
console.warn("[advanced-pdf-export] finishRenderMath failed:", err);
}
await waitForMermaidDiagrams(temp);
await waitForMathRendering(temp);
// Wait for MathJax's lazily-loaded @font-face files to arrive before we
// clone nodes; glyphs in unloaded font ranges render as invisible characters.
const docFonts = (activeDocument as Document & { fonts?: { ready?: Promise<unknown> } }).fonts;
if (docFonts) {
try { await docFonts.ready; } catch { /* non-critical */ }
}
} finally {
activeDocument.body.removeChild(temp);
}
postProcessRenderedHTML(temp);
return temp;
}
/** Fires MathJax's one-time cold-start boot and `adaptiveCSS` setup for the
* font families most often missing on a session's first real render (bold,
* AMS symbols, blackboard-bold, fraktur, calligraphic, sans-serif,
* typewriter, vector) as soon as the plugin loads, in the background
* rather than paying that cost the first time the user actually renders.
*
* Deliberately goes through the same `renderMarkdownToEl` pipeline used for
* real renders instead of calling `renderMath()`/`finishRenderMath()`
* directly: Obsidian only creates its global `MathJax` object lazily, the
* first time something is typeset through its markdown post-processor
* pipeline. Calling `renderMath()` directly before that has happened throws
* `ReferenceError: MathJax is not defined` instead of triggering the load.
*
* Fire-and-forget: nothing downstream awaits this, and the real render path
* still verifies completion correctly on its own regardless of whether this
* has finished. */
export async function warmUpMathJax(app: App): Promise<void> {
const component = new Component();
component.load();
try {
await renderMarkdownToEl(
app,
"$\\mathbf{A}+\\mathfrak{A}+\\mathcal{A}+\\mathsf{A}+\\mathtt{A}+\\mathbb{A}+\\aleph+\\vec{v}$",
"",
component,
);
} catch (err) {
console.warn("[advanced-pdf-export] MathJax warm-up failed (non-fatal):", err);
} finally {
component.unload();
}
}

655
src/paginator.ts Normal file
View file

@ -0,0 +1,655 @@
// ─────────────────────────────────────────────────────────────────────────────
// Pagination engine + PDF outline (bookmarks).
//
// Takes rendered HTML sections and distributes their block children into
// page-height buckets, splitting oversized elements by natural unit (line,
// row, list item, word/character). Also builds the per-page header/footer
// text (buildPageLayouts) and, for export, injects a bookmark tree into the
// finished PDF bytes derived from the same paginated headings. This is one
// self-contained algorithm — nothing here talks to Obsidian or the DOM
// outside of measuring already-rendered elements.
// ─────────────────────────────────────────────────────────────────────────────
import { PDFArray, PDFDict, PDFDocument, PDFHexString, PDFName, PDFNull, PDFNumber } from "pdf-lib";
import { PDFExportSettings } from "./settings";
/** One entry in the PDF outline/bookmark tree, extracted from the heading nodes. */
export interface OutlineEntry {
title: string;
level: number; // 16 matching H1H6
page: number; // 1-indexed page number in the exported PDF
}
export interface PageLayout {
pageNodes: HTMLElement[];
pageNum: number;
totalPages: number;
pageShowsHeader: boolean;
pageShowsFooter: boolean;
hasHeader: boolean;
hasFooter: boolean;
headerLeft: string;
headerCenter: string;
headerRight: string;
footerLeft: string;
footerRight: string;
footerCenter: string;
}
// ─── Shared measurement helpers ─────────────────────────────────────────────────
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 ─────────────────────────────────────────────────────
/** Distributes a rendered section's block children into page-height buckets,
* splitting oversized elements by natural unit (line, row, list item, word,
* or character) when they don't fit whole. Returns one HTMLElement[] per page. */
export 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 ──────────────────────────────────────────────────────
/** Resolves a page-number format template by substituting the {{current}}
* and {{total}} placeholders with the given values. Falls back to the
* default "current / total" template when the format is empty. */
function resolvePageNumberFormat(format: string, current: number, total: number): string {
const template = format && format.trim() ? format : "{{current}} / {{total}}";
return template
.replace(/\{\{\s*current\s*\}\}/g, String(current))
.replace(/\{\{\s*total\s*\}\}/g, String(total));
}
/** Converts paginated page-node arrays into fully-resolved PageLayout objects,
* computing header/footer text and page number strings for each page. */
export function buildPageLayouts(allPages: HTMLElement[][], s: PDFExportSettings): PageLayout[] {
const totalPages = allPages.length;
return allPages.map((pageNodes, i) => {
const pageNum = i + 1;
const pageShowsHeader = s.showHeaderOnFirstPage || i > 0;
const pageShowsFooter = 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 = resolvePageNumberFormat(s.pageNumberFormat, displayNum, displayTotal);
let footerLeft = "", footerRight = "", footerCenter = "";
let headerLeft = "", headerCenter = "", headerRight = "";
if (pageShowsFooter) {
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 (pageShowsHeader) {
if (s.headerText) {
if (s.headerAlignment === "center") {
headerCenter = s.headerText;
} else if (s.headerAlignment === "left") {
headerLeft = s.headerText;
} else {
headerRight = s.headerText;
}
}
}
// Compute once here so both preview and export paths can read directly from
// the layout object instead of re-deriving the same boolean expressions.
const hasHeader = s.showHeader && pageShowsHeader && !!(headerLeft || headerCenter || headerRight || s.showHeaderBorder);
const hasFooter = s.showFooter && pageShowsFooter && !!(footerLeft || footerRight || footerCenter || s.showFooterBorder);
return { pageNodes, pageNum, totalPages, pageShowsHeader, pageShowsFooter, hasHeader, hasFooter, 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.
*/
export 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.
*/
export async function injectPDFOutline(pdfBuffer: Uint8Array, entries: OutlineEntry[]): Promise<Uint8Array> {
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, and direct child count.
// directChildCount is computed here in O(n) so the item-building loop
// below doesn't need an inner scan (which would be O(n²) overall).
const firstChild = new Array<number>(n).fill(-1);
const lastChild = new Array<number>(n).fill(-1);
const directChildCount = new Array<number>(n).fill(0);
for (let i = 0; i < n; i++) {
const p = parentIdx[i];
if (p >= 0) {
if (firstChild[p] < 0) firstChild[p] = i;
lastChild[p] = i;
directChildCount[p]++;
}
}
// ── 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.
itemDict.set(PDFName.of("Count"), PDFNumber.of(-directChildCount[i]));
}
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++;
}
}
// Safety: if every entry has a parent (e.g. the document starts with H2 and
// never has an H1), there are no root-level items. Injecting an empty or
// half-wired outline dict would produce a malformed PDF — bail out instead.
if (rootFirst < 0) return pdfBuffer;
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 await pdfDoc.save();
}

453
src/settings-tab.ts Normal file
View file

@ -0,0 +1,453 @@
// ─────────────────────────────────────────────────────────────────────────────
// Plugin settings tab (Settings → Advanced PDF Export).
//
// One long, linear form grouped into the same sections shown in the README
// (Style Preset, Page, Margin & Frame, Typography, Background, Colors,
// Header, Footer, Behaviour). Kept separate from export-modal.ts: this is a
// different UI surface (global defaults vs. a single export session) that
// doesn't share rendering logic with the modal, only the settings object.
// ─────────────────────────────────────────────────────────────────────────────
import { App, PluginSettingTab, Setting } from "obsidian";
import type MarkdownPDFPlugin from "./main";
import { PAGE_SIZES, PRESETS, PDFExportSettings } from "./settings";
import { CODE_THEMES } from "./css-builder";
export 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(10, parseFloat(v) || 210); void this.markDirty(); }),
)
.addText((t) =>
t.setPlaceholder("Height").setValue(String(s.customPageHeight))
.onChange((v) => { s.customPageHeight = Math.max(10, 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 format")
.setDesc("Use {{current}} and {{total}} as placeholders, e.g. \"Page {{current}} of {{total}}\", \"{{current}}/{{total}}\", or just \"{{current}}\".")
.addText((t) =>
t.setPlaceholder("{{current}} / {{total}}").setValue(s.pageNumberFormat)
.onChange((v) => { s.pageNumberFormat = v; 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(); }),
);
}
}

354
src/settings.ts Normal file
View file

@ -0,0 +1,354 @@
// ─────────────────────────────────────────────────────────────────────────────
// Settings schema, style presets, and defaults.
//
// This is the plugin's data model: the shape of a saved settings object, the
// seven built-in style presets, and the values a fresh install starts with.
// Pure data + types — no Obsidian imports, no rendering logic. Anything that
// *turns* these settings into CSS lives in css-builder.ts; anything that
// *edits* them lives in settings-tab.ts.
// ─────────────────────────────────────────────────────────────────────────────
export 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 },
};
export 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;
}
export 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;
/** Template for rendering the page number string. Supports {{current}}
* and {{total}} placeholders, e.g. "Page {{current}} of {{total}}". */
pageNumberFormat: string;
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";
/** 01 opacity for the background image layer. */
backgroundImageOpacity: number;
/** When true, headings H1H6 are embedded as a bookmark tree in the exported PDF. */
includeOutline: boolean;
}
// ─── Style Presets ────────────────────────────────────────────────────────────
export 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,
},
};
export 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,
pageNumberFormat: "{{current}} / {{total}}",
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.9,
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,
};
/** Extracts only the DocStyle fields from the broader settings object.
* Used to snapshot the current look before switching presets, so each
* preset can remember per-user tweaks independently. */
export 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,
};
}

View file

@ -13,5 +13,5 @@
"skipLibCheck": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["**/*.ts"]
"include": ["src/**/*.ts"]
}