/**
* Obsidian Templater script to build a book from markdown files.
*
* This script automates the process of generating a publish-ready book from a set of Obsidian markdown files.
* It is designed to work on both Windows and Mac, and can be run from within Obsidian using the Templater plugin.
*
* The script performs the following steps:
* 1. Expands all Obsidian embeds and links, recursively flattening the book structure into two markdown files:
* - One for Kindle (with Obsidian links converted to markdown links)
* - One for Paperback (with Obsidian links as plain text)
* 2. Converts the expanded markdown files to EPUB format using Pandoc, applying custom metadata, CSS, and Lua filters.
* 3. Generates a plain text version of the book.
* 4. Patches the generated EPUB files to fix navigation, add ISBNs, and correct encoding issues.
* 5. Converts the Paperback EPUB to a print-ready PDF using Calibre's ebook-convert, with custom page size, fonts, and footer.
* 6. Opens the generated PDF in the system's default PDF viewer.
*
* Prerequisites:
* - Pandoc must be installed: https://pandoc.org/installing.html
* - Calibre (for ebook-convert) must be installed: https://calibre-ebook.com/download
* - The supporting code directory must contain:
* - metadata-kindle.yaml: metadata for Kindle EPUB
* - metadata-paperback.yaml: metadata for Paperback EPUB
* - export.lua: Lua filter for Pandoc
* - book.css: base CSS for the book
* - book-kindle.css: extra CSS for Kindle
* - book-paperback.css: extra CSS for Paperback
* - fonts/: folder with TTF font files
*
* Usage:
* - Open the root markdown file of your book in Obsidian.
* - Run this script using the Templater plugin.
* - The script will output all generated files to the configured OUTPUT_DIR.
*
* Output files:
* - Expanded-Kindle.md: flattened markdown for Kindle
* - Expanded-Paperback.md: flattened markdown for Paperback
* - Expanded-Audiobook.md: flattened markdown for Audiobook (images replaced with alt-text for narration)
* - <root filename>-Kindle.epub: EPUB for Kindle
* - <root filename>-Paperback.epub: EPUB for Paperback
* - <root filename>-Audiobook.epub: EPUB for Audiobook
* - <root filename>-Paperback.pdf: Print-ready PDF for Paperback
* - <root filename>.txt: Plain text version of the book
*
* Configuration:
* - Adjust OUTPUT_DIR and SUPPORTING_CODE_DIR as needed for your environment.
* - Set the paths to Pandoc and ebook-convert if they are not in your system PATH.
* - Update ISBN_PAPERBACK and ISBN_KINDLE as appropriate for your publication.
*/
// --- Early exit if running on mobile (not supported) ---
if (app.isMobile) return;
// --- Node.js and OS modules ---
const isWindows = process.platform === "win32";
const { exec } = require('child_process');
const path = require('path');
const fs = require('fs');
// ------------------------------
// --- Configurable variables ---
// ------------------------------
// --- Path to binaries (edit as needed) ---
// If Pandoc or ebook-convert are not in your PATH, set the full path here.
const PANDOC_PATH = isWindows ? "pandoc" : "/opt/homebrew/bin/pandoc";
const EBOOK_CONVERT_PATH = isWindows ? "ebook-convert" : "/Applications/calibre.app/Contents/MacOS/ebook-convert";
// --- Output and supporting code directories ---
// OUTPUT_DIR: Where all generated files will be placed.
// SUPPORTING_CODE_DIR: Where metadata, CSS, Lua, and fonts are stored.
const OUTPUT_DIR = isWindows ? "F:\\Sketch Your Mind" : "/Users/zsviczian/Sketch Your Mind";
const SUPPORTING_CODE_DIR = isWindows ? "C:\\Users\\Zsolt\\GitHub\\Beyond-Text\\Pandoc" : "/Users/zsviczian/GitHub/Beyond-Text/Pandoc";
// --- ISBNs for different formats ---
// Set ISBN to null if you don't want to include it in the EPUB title page.
const ISBN_PAPERBACK = "978-615-02-3320-8";
const ISBN_KINDLE = "978-615-02-3323-9";
// --- Get the active file in Obsidian ---
// This is the root markdown file for your book.
const f = app.workspace.activeLeaf?.view?.file;
if (!f) {
new Notice("No active file found! Open the root of the book you want to convert", 0);
return;
}
const inputFile = await app.vault.adapter.getFullRealPath(f.path);
const inputFileName = path.basename(inputFile, path.extname(inputFile));
const rootDir = path.dirname(inputFile);
// --- Output file paths ---
const expandedFileKindle = path.join(OUTPUT_DIR, `${inputFileName}-Expanded-Kindle.md`);
const expandedFilePaperback = path.join(OUTPUT_DIR, `${inputFileName}-Expanded-Paperback.md`);
const expandedFileAudiobook = path.join(OUTPUT_DIR, `${inputFileName}-Expanded-Audiobook.md`);
const epubPathKindle = path.join(OUTPUT_DIR, `${inputFileName}-Kindle.epub`);
const epubPathPaperback = path.join(OUTPUT_DIR, `${inputFileName}-Paperback.epub`);
const epubPathAudiobook = path.join(OUTPUT_DIR, `${inputFileName}-Audiobook.epub`);
const textPath = path.join(OUTPUT_DIR, `${inputFileName}.txt`);
const pdfPath = path.join(OUTPUT_DIR, `${inputFileName}-Paperback.pdf`);
const tempDir = path.join(OUTPUT_DIR, "temp-epub");
// --- Supporting code paths ---
// These files must exist in SUPPORTING_CODE_DIR. Set to null to skip.
const metaKindle = path.join(SUPPORTING_CODE_DIR, "metadata-kindle.yaml");
const metaPaperback = path.join(SUPPORTING_CODE_DIR, "metadata-paperback.yaml");
const metaTxt = path.join(SUPPORTING_CODE_DIR, "metadata-kindle.yaml");
const exportLua = path.join(SUPPORTING_CODE_DIR, "export.lua");
const cssBook = path.join(SUPPORTING_CODE_DIR, "book.css");
const cssKindle = path.join(SUPPORTING_CODE_DIR, "book-kindle.css");
const cssPaperback = path.join(SUPPORTING_CODE_DIR, "book-paperback.css");
const fontsDir = path.join(SUPPORTING_CODE_DIR, "fonts", "*.ttf");
// --- PDF footer template ---
// This HTML/JS snippet is injected into the PDF footer by ebook-convert.
// It handles page numbers, chapter titles, and custom formatting.
const footerTemplate = `<footer>
<div id='pagenum'></div>
<script>
var pageNum = _PAGENUM_;
var displayNum = '';
var section = '_SECTION_';
var title = '_TITLE_';
switch(section) {
case 'Chapter 1': section = 'A New Paradigm for Thinking'; break;
case 'Chapter 2': section = 'The LEGO Approach to Playful Thinking'; break;
case 'Chapter 3': section = 'Notes Reimagined'; break;
case 'Chapter 4': section = 'Visualizing Concepts'; break;
case 'Chapter 5': section = 'Mapping the Mind'; break;
case 'Chapter 6': section = 'The Idea Integration Board'; break;
case 'Chapter 7': section = 'Navigating Knowledge'; break;
case 'Chapter 8': section = 'The Serendipity Machine'; break;
case 'Chapter 9': section = 'Trains of Thought'; break;
case 'Chapter 10': section = 'Create Thinking Loops with Storyboards'; break;
case 'Chapter 11': section = 'Flip the Habit and Free the Genie'; break;
}
/* Determine displayed page number format */
if (pageNum <= 11) {
/* No page number for the first 5 pages */
displayNum = '';
} else if (pageNum >= 12 && pageNum <= 22) {
/* Roman numerals for pages 6-22 */
var romanNumerals = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI', 'XII', 'XIII', 'XIV', 'XV', 'XVI', 'XVII', 'XVIII', 'XIX', 'XX', 'XXI', 'XXII'];
displayNum = romanNumerals[pageNum];
} else {
/* Arabic numerals starting from page 20 (displayed as 1) */
displayNum = (pageNum - 22).toString();
}
var isTitle = [11, 14, 15, 22, 23, 37, 51, 65, 71, 72, 73, 93, 116, 117, 128, 129, 149, 164, 165, 186, 187, 198, 199, 215, 219, 227, 231, 239].includes(pageNum);
if(isTitle) {
displayNum = '';
}
if(displayNum) {
if(pageNum % 2 == 0) {
displayNum = displayNum + '<span style=\\'margin-left:1.2em;\\'>' + title.toLocaleUpperCase() + '</span>';
} else {
displayNum = '<span style=\\'margin-right:1.2em;\\'>' + section.toLocaleUpperCase() + '</span>' + displayNum;
}
}
var div = document.currentScript.parentNode.querySelector('#pagenum');
div.style.fontFamily = 'Fira Sans';
div.style.fontSize = '0.9em';
div.style.width = '100%';
div.style.textAlign = pageNum % 2 ? 'right' : 'left';
div.innerHTML = displayNum;
</script>
</footer>`;
// -------------------------
// --- Utility functions ---
// -------------------------
// --- Single notice management ---
// Use a single persistent notice for status updates (not errors).
let notice;
let noticeEl;
function setSingleNotice(message) {
if(noticeEl?.parentElement) {
notice.setMessage(message);
return;
}
// 0 means the notice will not be automatically removed
notice = new Notice(message, 0);
noticeEl = notice.containerEl ?? notice.noticeEl;
}
function hideSingleNotice() {
if(noticeEl?.parentElement) {
notice.hide();
}
}
// --- PDF viewer management ---
// Detects and closes the default PDF viewer before build, and opens the PDF after build.
// This is necessary to avoid issues with file locks and to ensure the latest version is opened.
async function getDefaultPdfViewer() {
if (isWindows) {
// Step 1: Get default ProgID from HKLM
const cmd = `powershell -command "(Get-ItemProperty 'HKLM:\\SOFTWARE\\Classes\\.pdf').'(default)'"`;
let { ok, stdout } = await runCmd(cmd);
if (ok && stdout.trim()) {
const progId = stdout.trim();
// Step 2: Get associated open command
const exeCmd = `powershell -command "(Get-ItemProperty 'HKLM:\\SOFTWARE\\Classes\\${progId}\\shell\\open\\command').'(default)'"`;
const { ok: exeOk, stdout: exeStdout } = await runCmd(exeCmd);
if (exeOk && exeStdout.trim()) {
const match = exeStdout.match(/"([^"]+)"/);
return match ? match[1] : null;
}
}
// Fallback to common PDF viewers
const commonViewers = [
"C:\\Program Files\\Adobe\\Acrobat DC\\Acrobat\\Acrobat.exe",
"C:\\Program Files (x86)\\Adobe\\Acrobat Reader DC\\Reader\\AcroRd32.exe",
"C:\\Program Files\\SumatraPDF\\SumatraPDF.exe"
];
for (const viewer of commonViewers) {
if (fs.existsSync(viewer)) {
return viewer;
}
}
} else {
// macOS default PDF viewer detection
const cmd = "defaults read com.apple.LaunchServices/com.apple.launchservices.secure LSHandlers | grep -A 3 'LSHandlerURLScheme = pdf' | grep 'LSHandlerRoleAll' | awk -F '=' '{print $2}' | tr -d ' ;'";
const { ok, stdout } = await runCmd(cmd);
if (ok && stdout.trim()) {
const appId = stdout.trim();
if (appId === "com.apple.preview") {
return "/Applications/Preview.app/Contents/MacOS/Preview";
} else if (appId === "com.adobe.Reader") {
return "/Applications/Adobe Acrobat Reader DC.app/Contents/MacOS/AdobeReader";
} else if (appId.includes("adobe")) {
return "/Applications/Adobe Acrobat DC.app/Contents/MacOS/Acrobat";
}
}
// Fallback to common macOS PDF viewers
const commonViewers = [
"/Applications/Preview.app/Contents/MacOS/Preview",
"/Applications/Adobe Acrobat DC.app/Contents/MacOS/Acrobat",
"/Applications/Adobe Acrobat Reader DC.app/Contents/MacOS/AdobeReader"
];
for (const viewer of commonViewers) {
if (fs.existsSync(viewer)) {
return viewer;
}
}
}
return null;
}
async function closePdfViewer() {
const pdfViewer = await getDefaultPdfViewer();
if (!pdfViewer) {
setSingleNotice("Couldn't determine PDF viewer application");
return;
}
const viewerName = path.basename(pdfViewer, path.extname(pdfViewer));
setSingleNotice(`Closing any running instances of ${viewerName}...`);
if (isWindows) {
const cmd = `taskkill /F /IM "${path.basename(pdfViewer)}" /T`;
await runCmd(cmd, {}, true);
} else {
const cmd = `pkill -f "${viewerName}"`;
await runCmd(cmd, {}, true);
}
}
async function openPdf(pdfPath) {
if (!fs.existsSync(pdfPath)) {
setSingleNotice(`PDF file not found: ${pdfPath}`);
return false;
}
setSingleNotice(`Opening PDF: ${path.basename(pdfPath)}`);
let cmd;
if (isWindows) {
cmd = `start "" "${pdfPath}"`;
} else {
cmd = `open "${pdfPath}"`;
}
const { ok } = await runCmd(cmd);
return ok;
}
// --- Shell command runner ---
// Runs a shell command and returns a promise with {ok, stdout, stderr}.
// Shows warnings as notices, but errors as persistent notices.
async function runCmd(cmd, opts = {}, hideError = false) {
return new Promise((resolve) => {
const execOptions = {...opts};
exec(cmd, execOptions, (error, stdout, stderr) => {
if (!hideError && error) {
new Notice(`Error: ${error.message}`, 0);
resolve({ ok: false, stdout, stderr });
} else if (!hideError && stderr) {
// Check if stderr contains only warnings using a simpler pattern
const isOnlyWarning = stderr.match(/warning/i);
if (isOnlyWarning) {
// Just log the warning but don't treat as failure
setSingleNotice(`Warning: ${stderr}`);
resolve({ ok: true, stdout, stderr });
} else {
// Actual error in stderr
new Notice(`Stderr: ${stderr}`, 0);
resolve({ ok: false, stdout, stderr });
}
} else {
resolve({ ok: true, stdout, stderr });
}
});
});
}
// --- Check if required binaries are available ---
// Used to validate that pandoc and ebook-convert are installed and accessible.
async function checkBinary(bin) {
const whichCmd = isWindows ? `where ${bin}` : `which ${bin}`;
const { ok } = await runCmd(whichCmd);
if (!ok) {
new Notice(`Required binary not found: ${bin}`, 0);
return false;
}
return true;
}
// ----------------------------------------
// --- Step 0: Check for required files ---
// ----------------------------------------
// Verify that the required binaries are available
const bins = [PANDOC_PATH, EBOOK_CONVERT_PATH];
for (const bin of bins) {
if (!(await checkBinary(bin))) return;
}
// Ensure OUTPUT_DIR exists before proceeding
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
// Ensure SUPPORTING_CODE_DIR exists before proceeding
if (!fs.existsSync(SUPPORTING_CODE_DIR)) {
new Notice(`Supporting code directory not found: ${SUPPORTING_CODE_DIR}`, 0);
return;
}
// Close PDF viewer before starting the process
// This is necessary to avoid file locks and
// ensure the latest version is opened.
await closePdfViewer();
// ---------------------------------------------
// --- Step 1: Expand Obsidian Links/Embeds ---
// ---------------------------------------------
// Recursively expands all Obsidian embeds and links in the root markdown file.
// Produces two outputs: one for Kindle (links as markdown), one for Paperback (links as plain text).
async function expandObsidianLinks(inputFile, outputFileKindle, outputFilePaperback, outputFileAudiobook) {
setSingleNotice("Step 1: Expanding Obsidian links...");
// Create the Obsidian Vault adapter for file access
const processedFiles = new Set();
/**
*
* @param {*} filePath
* @param {*} targetFormat "kindle" | "paperback" | "audiobook"
* @returns
*/
async function processMarkdownFile(filePath, targetFormat) {
// Avoid infinite loops
if (processedFiles.has(filePath)) {
const output = `\n<!-- Skipping duplicate embed: ${filePath} -->\n`;
return output;
}
processedFiles.add(filePath);
try {
// Read file content
const content = await fs.promises.readFile(filePath, 'utf8');
const contentLines = content.split(/\r?\n/);
// Check if this is a TOC file by examining frontmatter
let hasFrontmatter = contentLines.length > 2 && contentLines[0] === "---";
let isTocFile = false;
if (hasFrontmatter) {
let frontmatterEndIndex = -1;
// Find the closing --- of frontmatter
for (let i = 1; i < contentLines.length; i++) {
if (contentLines[i] === "---") {
frontmatterEndIndex = i;
break;
}
}
if (frontmatterEndIndex > 0) {
// Look for aliases: and Table of Contents in frontmatter
const frontmatter = contentLines.slice(0, frontmatterEndIndex + 1);
const hasAliases = frontmatter.some(line => line.match(/aliases:/));
const hasTocAlias = frontmatter.some(line => line.match(/- "Table of Contents"/));
if (hasAliases && hasTocAlias) {
isTocFile = true;
}
}
}
let insideAbstract = false;
let result = [];
for (const line of contentLines) {
// Process headings to convert to uppercase properly
if (line.match(/^##\s+(.+)$/)) {
const heading = line.replace(/^##\s+/, "");
const uppercaseHeading = heading.replace(/([a-z])/g, (match) => match.toUpperCase());
result.push(`## ${uppercaseHeading}`);
continue;
}
else if (line.match(/^###\s+(.+)$/)) {
const heading = line.replace(/^###\s+/, "");
const uppercaseHeading = heading.replace(/([a-z])/g, (match) => match.toUpperCase());
result.push(`### ${uppercaseHeading}`);
continue;
}
// Transform Example and Mastery callout blocks
// I use multiple different callout blocks
// these are pre-processed in export.lua
// the logic here is only additional transformation to extend specific callouts
// with additional text (e.g., "Example" or "Pro Tip")
/**
* If the prefix does not match the function does nothing.
* @param {*} line markdown line to process
* @param {*} prefix callout prefix (e.g., "Example", "Mastery", "Abstract")
* @param {*} suffix text to append to the callout (e.g., "Example", "Pro Tip")
*/
function processCallout(line, prefix, suffix) {
let calloutMatch = line.match(new RegExp(`^>\\s*\\[!${prefix}\\]\\s+(.+)$`));
if (calloutMatch) {
if(suffix) {
result.push(`> [!${prefix}] ${suffix}: ${calloutMatch[1]}`);
} else {
result.push(`> [!${prefix}] ${calloutMatch[1]}`);
}
return true;
}
if (line.match(new RegExp(`^>\\s*\\[!${prefix}\\]\\s*$`))) {
if(suffix) {
result.push(`> [!${prefix}] ${suffix}`);
} else {
result.push(`> [!${prefix}]`);
}
return true;
}
return false;
}
if (
processCallout(line, "Example", "Example") ||
processCallout(line, "Mastery", "Pro Tip")
) {
continue;
}
if (
targetFormat === "audiobook" && (
processCallout(line, "Story", "Storytime") ||
processCallout(line, "Definition", "Definition") ||
processCallout(line, "Practice", "Practice") ||
processCallout(line, "Takeaway", "Key Takeaways")
)
) {
continue;
}
if (processCallout(line, "Abstract")) {
insideAbstract = true;
continue;
};
// Insert page break AFTER [!Abstract] (after the first blank line following it)
if (insideAbstract && line.match(/^\s*$/)) {
result.push(line);
result.push('<div style="page-break-after: always"></div>');
result.push('');
insideAbstract = false;
continue;
}
// Only transform Obsidian links in TOC file
if (isTocFile && line.includes("[[")) {
let transformedLine = line;
let matches = [...line.matchAll(/\[\[(.+?)(?:\|(.+?))?\]\]([^\n]*)/g)];
if (matches.length > 0) {
transformedLine = "";
let lastIndex = 0;
for (const match of matches) {
// Add text before the match
transformedLine += line.substring(lastIndex, match.index);
const linkedFile = match[1];
const displayText = match[2] || linkedFile;
// Use the display text to generate anchor IDs (same way Pandoc does)
let anchor = displayText.toLowerCase();
anchor = displayText.split(':', 2)[0].trim().toLowerCase();
// Special cases for consistent hyphenation
anchor = anchor.replace("note-taking", "note-taking"); // Ensure proper hyphenation
anchor = anchor.replace("note taking", "note-taking"); // Catch space variant too
// Then apply standard transformations
anchor = anchor.replace(/[^a-z0-9\- ]/g, ''); // Remove special chars but keep hyphens
anchor = anchor.replace(/\s+/g, '-'); // Replace spaces with hyphens
anchor = anchor.replace(/-+/g, '-'); // Remove duplicate hyphens
// Create the transformed link
switch (targetFormat) {
case "kindle":
transformedLine += `[${displayText}](#${anchor})`;
break;
case "paperback":
transformedLine += displayText + match[3];
break;
case "audiobook":
transformedLine += displayText;
break;
}
// Update lastIndex for next iteration
lastIndex = match.index + match[0].length;
}
// Add remaining text after the last match
transformedLine += line.substring(lastIndex);
result.push(transformedLine);
continue;
}
}
// Handle image embeds: `![[image.png|alt text|300]]` or `![[image.png||58%]]`
const imageMatch = line.match(/^(>\s*|\s+)?\!\[\[(.+?\.(png|jpg|jpeg|gif|svg|webp))(\|([^|]*))?(\|(\d+%?))?\]\]\s*$/);
if (imageMatch) {
const prefix = imageMatch[1] || ""; // Capture `>` if present (for blockquotes)
const imagePath = imageMatch[2]; // Extract image filename
const altText = imageMatch[5] || ""; // Extract alt text (if provided)
const width = imageMatch[7] || ""; // Extract width (if provided)
// Determine width attribute format
let widthAttr = "";
if (width.match(/^\d+%$/)) {
widthAttr = `{width=${width}}`; // Percentage-based width
} else if (width.match(/^\d+$/)) {
widthAttr = `{width=${width}px}`; // Pixel-based width
}
// Preserve blockquote (`>` prefix) if present
if (targetFormat === "audiobook") {
result.push(`${prefix} ${altText}`);
} else {
result.push(`${prefix}${widthAttr}`);
}
continue;
}
// Handle Markdown file embeds recursively
const embedMatch = line.match(/^\!\[\[(.+?)\]\]$/);
if (embedMatch) {
const embedText = embedMatch[1];
// Remove alias part if present (e.g., "Chapter-01|The Beginning" → "Chapter-01")
const fileName = embedText.replace(/\|.*$/, "");
// Ensure file has .md extension if missing
const fileNameWithExt = fileName.endsWith(".md") ? fileName : `${fileName}.md`;
// Resolve file path
const embeddedFilePath = path.join(rootDir, fileNameWithExt);
if (await fs.promises.access(embeddedFilePath).then(() => true).catch(() => false)) {
result.push(`\n<!-- Expanded from ${fileNameWithExt} -->\n`);
const embedContent = await processMarkdownFile(embeddedFilePath, targetFormat);
result.push(embedContent);
result.push(`\n<!-- End of ${fileNameWithExt} -->\n`);
} else {
result.push(`\n<!-- Missing File: ${fileNameWithExt} -->\n`);
}
continue;
}
let processedLine = "";
// Replace div markers
if (targetFormat === "audiobook") {
processedLine = line.replaceAll(/<!-- div:([a-zA-Z0-9_-]+) -->/gi, '');
processedLine = processedLine.replaceAll(/<!-- \/div -->/gi, '');
} else {
processedLine = line.replaceAll(/<!-- div:([a-zA-Z0-9_-]+) -->/gi, '<div class="$1">');
processedLine = processedLine.replaceAll(/<!-- \/div -->/gi, '</div>');
}
// Append the line to output
result.push(processedLine);
}
return result.join('\n');
}
catch (err) {
return `\n<!-- Error processing file ${filePath}: ${err.message} -->\n`;
}
}
// Process for Kindle version
processedFiles.clear();
const kindleContent = await processMarkdownFile(inputFile, "kindle");
await fs.promises.writeFile(outputFileKindle, kindleContent, 'utf8');
setSingleNotice(`Expanded markdown saved to: ${outputFileKindle}`);
// Process for Paperback version
processedFiles.clear();
const paperbackContent = await processMarkdownFile(inputFile, "paperback");
await fs.promises.writeFile(outputFilePaperback, paperbackContent, 'utf8');
setSingleNotice(`Expanded markdown saved to: ${outputFilePaperback}`);
// Process for Paperback version
processedFiles.clear();
const audiobookContent = await processMarkdownFile(inputFile, "audiobook");
await fs.promises.writeFile(outputFileAudiobook, audiobookContent, 'utf8');
setSingleNotice(`Expanded markdown saved to: ${outputFileAudiobook}`);
return true;
}
// -----------------------------------
// --- Step 2: Patch EPUB files ---
// -----------------------------------
// Unzips the EPUB, modifies navigation, adds ISBN, fixes encoding, then repackages.
// This is a hack. I found that some changes are easier to implment in the EPUB file directly than in
// the markdown source files or the lua filters or configuring pandoc.
async function patchEpubFile(epubPath, isPaperback = false) {
setSingleNotice(`Patching EPUB file: ${path.basename(epubPath)}...`);
// Create temp directory
if (await fs.promises.access(tempDir).then(() => true).catch(() => false)) {
await fs.promises.rm(tempDir, { recursive: true, force: true });
}
await fs.promises.mkdir(tempDir, { recursive: true });
// Extract EPUB
const zipName = `${epubPath}.zip`;
await fs.promises.copyFile(epubPath, zipName);
// Use unzip for extraction
const unzipCmd = isWindows
? `powershell -command "Expand-Archive -Path '${zipName}' -DestinationPath '${tempDir}'"`
: `unzip -q "${zipName}" -d "${tempDir}"`;
let res = await runCmd(unzipCmd);
if (!res.ok) {
new Notice(`Failed to extract EPUB: ${res.stderr}`, 0);
return false;
}
// Rename back from zip
await fs.promises.unlink(zipName);
// Find and modify TOC file (only for Kindle version)
if (!isPaperback) {
// Find nav.xhtml recursively
const findNavCmd = isWindows
? `powershell -command "Get-ChildItem -Path '${tempDir}' -Recurse -Filter 'nav.xhtml' | Select-Object -First 1 -ExpandProperty FullName"`
: `find "${tempDir}" -name "nav.xhtml" -type f | head -1`;
res = await runCmd(findNavCmd);
if (res.ok && res.stdout.trim()) {
const tocFile = res.stdout.trim();
let content = await fs.promises.readFile(tocFile, 'utf8');
const modifiedContent = content.replace('<nav epub:type="toc"', '<nav epub:type="toc" hidden="hidden"');
await fs.promises.writeFile(tocFile, modifiedContent, 'utf8');
setSingleNotice(`Modified TOC file: ${path.basename(tocFile)}`);
}
}
// Find and modify the title page (title_page.xhtml)
const findTitlePageCmd = isWindows
? `powershell -command "Get-ChildItem -Path '${tempDir}' -Recurse -Filter 'title_page.xhtml' | Select-Object -First 1 -ExpandProperty FullName"`
: `find "${tempDir}" -name "title_page.xhtml" -type f | head -1`;
res = await runCmd(findTitlePageCmd);
if (res.ok && res.stdout.trim()) {
const titlePageFile = res.stdout.trim();
let content = await fs.promises.readFile(titlePageFile, 'utf8');
// Add ISBN based on format
const isbn = isPaperback ? ISBN_PAPERBACK : ISBN_KINDLE;
if(isbn) {
// Insert the ISBN before the rights div
if (content.includes('<div class="rights">')) {
const modifiedContent = content.replace(
'<div class="rights">',
`<p class="identifier">ISBN: ${isbn}</p>\n <div class="rights">`
);
await fs.promises.writeFile(titlePageFile, modifiedContent, 'utf8');
setSingleNotice(`Added ISBN ${isbn} to title page`);
}
}
}
// Find and modify the main title page (ch001.xhtml)
const findMainTitleCmd = isWindows
? `powershell -command "Get-ChildItem -Path '${tempDir}' -Recurse -Filter 'ch001.xhtml' | Select-Object -First 1 -ExpandProperty FullName"`
: `find "${tempDir}" -name "ch001.xhtml" -type f | head -1`;
res = await runCmd(findMainTitleCmd);
if (res.ok && res.stdout.trim()) {
const mainTitleFile = res.stdout.trim();
let content = await fs.promises.readFile(mainTitleFile, 'utf8');
// Check if the file contains the main title
if (content.includes('<h1 class="unnumbered">Sketch Your Mind</h1>')) {
// Create the replacement string
const replacement = '<div class="book-title">\n' +
'<div class="book-title-main">Sketch Your Mind</div>\n' +
'<div class="book-title-subtitle">Nurture a Playful and Creative Brain</div>\n' +
'</div>\n' +
'<div class="chapter-spacing"></div>';
const modifiedContent = content.replace(
'<h1 class="unnumbered">Sketch Your Mind</h1>',
replacement
);
await fs.promises.writeFile(mainTitleFile, modifiedContent, 'utf8');
setSingleNotice(`Modified main title file with subtitle`);
}
}
// Fix encoding issues in all HTML files
const findHtmlCmd = isPaperback
? (isWindows
? `powershell -command "Get-ChildItem -Path '${tempDir}' -Recurse -Filter 'ch001.xhtml' | Select-Object -ExpandProperty FullName"`
: `find "${tempDir}" -name "ch001.xhtml" -type f`)
: (isWindows
? `powershell -command "Get-ChildItem -Path '${tempDir}' -Recurse -Filter '*.xhtml' | Select-Object -ExpandProperty FullName"`
: `find "${tempDir}" -name "*.xhtml" -type f`);
res = await runCmd(findHtmlCmd);
if (res.ok && res.stdout.trim()) {
const htmlFiles = res.stdout.trim().split(/\r?\n/);
for (const htmlFile of htmlFiles) {
if (!htmlFile.trim()) continue;
let htmlContent = await fs.promises.readFile(htmlFile, 'utf8');
// Create proper em-dash character
const dash = "—"; // Unicode em-dash
// Fix various forms of broken em-dashes
htmlContent = htmlContent.replace(/\uFFFD/g, dash); // Unicode replacement character
htmlContent = htmlContent.replace(/�/g, dash); // HTML entity for replacement character
htmlContent = htmlContent.replace(/&#65533;/g, dash); // Doubly-encoded HTML entity
htmlContent = htmlContent.replace(/�/g, dash); // Visible replacement character
// Fix specific patterns for section headers
const pattern1 = '<h3><strong>STEP 1: STRUCTURAL READING';
const replacement1 = `<h3><strong>STEP 1: STRUCTURAL READING${dash}GRASP THE WHOLE</strong></h3>`;
htmlContent = htmlContent.replace(
new RegExp(`${pattern1}.*?GRASP THE WHOLE</strong></h3>`),
replacement1
);
const pattern2 = '<h3><strong>STEP 2: ITERATIVE READING';
const replacement2 = `<h3><strong>STEP 2: ITERATIVE READING${dash}WHOLE, PART, WHOLE</strong></h3>`;
htmlContent = htmlContent.replace(
new RegExp(`${pattern2}.*?WHOLE, PART, WHOLE</strong></h3>`),
replacement2
);
const pattern3 = '<h3><strong>STEP 3: ACTIVE READING';
const replacement3 = `<h3><strong>STEP 3: ACTIVE READING${dash}UNDERSTAND AND CAPTURE</strong></h3>`;
htmlContent = htmlContent.replace(
new RegExp(`${pattern3}.*?UNDERSTAND AND CAPTURE</strong></h3>`),
replacement3
);
await fs.promises.writeFile(htmlFile, htmlContent, 'utf8');
}
}
// Pack back to EPUB
await fs.promises.rename(epubPath, `${epubPath}.old`);
// Create zip file
const zipCmd = isWindows
? `powershell -command "Compress-Archive -Path '${tempDir}/*' -DestinationPath '${epubPath}.zip'"`
: `cd "${tempDir}" && zip -q -r "${epubPath}.zip" ./*`;
res = await runCmd(zipCmd);
if (!res.ok) {
new Notice(`Failed to create zip file: ${res.stderr}`, 0);
await fs.promises.rename(`${epubPath}.old`, epubPath);
return false;
}
// Rename zip to epub
await fs.promises.rename(`${epubPath}.zip`, epubPath);
// Clean up
await fs.promises.unlink(`${epubPath}.old`);
await fs.promises.rm(tempDir, { recursive: true, force: true });
setSingleNotice(`Successfully patched: ${path.basename(epubPath)}`);
return true;
}
// -----------------------------------
// --- Step 3: Main Build Process ---
// -----------------------------------
try {
// Expand all links and embeds
await expandObsidianLinks(inputFile, expandedFileKindle, expandedFilePaperback, expandedFileAudiobook);
// Convert expanded markdown to EPUB using Pandoc
// Convert to Kindle EPUB
// pandoc writer options:
// https://pandoc.org/MANUAL.html#general-writer-options
setSingleNotice("Step 2: Converting to EPUB with Pandoc...");
let pandocCmd = [
PANDOC_PATH,
`"${expandedFileKindle}"`,
`--from markdown+smart`,
`--to epub3`,
`-o "${epubPathKindle}"`,
...metaKindle ? [`--metadata-file "${metaKindle}"`] : [],
...exportLua ? [`--lua-filter="${exportLua}"`] : [],
...cssBook ? [`--css="${cssBook}"`] : [],
...cssKindle ? [`--css="${cssKindle}"`] : [],
`--toc --toc-depth=2`,
`--resource-path="${rootDir}"`,
`--data-dir="${OUTPUT_DIR}"`,
`--epub-chapter-level=1`
].join(" ");
let res = await runCmd(pandocCmd, {cwd: rootDir});
if (!res.ok) return;
setSingleNotice(`EPUB created successfully: ${path.basename(epubPathKindle)}`);
// Convert to Paperback EPUB
pandocCmd = [
PANDOC_PATH,
`"${expandedFilePaperback}"`,
`--from markdown+smart`,
`--to epub3`,
`-o "${epubPathPaperback}"`,
...metaPaperback ? [`--metadata-file "${metaPaperback}"`] : [],
...exportLua ? [`--lua-filter="${exportLua}"`] : [],
...cssBook ? [`--css="${cssBook}"`] : [],
...cssPaperback ? [`--css="${cssPaperback}"`] : [],
`--toc --toc-depth=1`,
`--resource-path="${rootDir}"`,
`--data-dir="${OUTPUT_DIR}"`,
`--epub-chapter-level=1`,
...fontsDir ? [`--epub-embed-font="${fontsDir}"`] : []
].join(" ");
res = await runCmd(pandocCmd, {cwd: rootDir});
if (!res.ok) return;
setSingleNotice(`EPUB created successfully: ${path.basename(epubPathPaperback)}`);
// Convert to Audiobook EPUB
pandocCmd = [
PANDOC_PATH,
`"${expandedFileAudiobook}"`,
`--from markdown+smart`,
`--to epub3`,
`-o "${epubPathAudiobook}"`,
...metaKindle ? [`--metadata-file "${metaKindle}"`] : [],
...exportLua ? [`--lua-filter="${exportLua}"`] : [],
...cssBook ? [`--css="${cssBook}"`] : [],
...cssKindle ? [`--css="${cssKindle}"`] : [],
`--toc=false`,
`--resource-path="${rootDir}"`,
`--data-dir="${OUTPUT_DIR}"`,
`--epub-chapter-level=1`
].join(" ");
res = await runCmd(pandocCmd, {cwd: rootDir});
if (!res.ok) return;
// Generate plain text version
setSingleNotice("Step 2b: Generating plain text version...");
let textCmd = [
PANDOC_PATH,
`"${expandedFileAudiobook}"`,
`--from markdown+smart`,
`--to plain`,
`-o "${textPath}"`,
`--wrap=none`,
...metaTxt ? [`--metadata-file "${metaTxt}"`] : [],
`--resource-path="${rootDir}"`,
`--data-dir="${OUTPUT_DIR}"`
].join(" ");
res = await runCmd(textCmd, {cwd: rootDir});
// Continue even if text fails
// Post-process plain text file to remove callout markers
if (fs.existsSync(textPath)) {
let txt = await fs.promises.readFile(textPath, "utf8");
// Remove leading callout markers like [!Example] from each line
txt = txt.replace(/^ *\[![^\]]+] */gm, "");
// Replace all NBSP (Unicode \u00A0) with normal space
txt = txt.replace(/\u00A0/g, " ");
await fs.promises.writeFile(textPath, txt, "utf8");
}
// Patch EPUB files (navigation, ISBN, encoding)
setSingleNotice("Step 3: Patching EPUB files...");
await patchEpubFile(epubPathKindle, false);
await patchEpubFile(epubPathPaperback, true);
// Convert Paperback EPUB to PDF using Calibre
// Build ebook-convert command
// for valid parameters see:
// https://manual.calibre-ebook.com/generated/en/ebook-convert.html#pdf-output-options
setSingleNotice("Step 4: Converting EPUB to PDF...");
const pdfCmd = [
EBOOK_CONVERT_PATH,
`"${epubPathPaperback}"`,
`"${pdfPath}"`,
`--custom-size 432x648`, //6" x 9" at 72dpi 6*72=432, 9*72=648
`--unit point`,
`--pdf-default-font-size 13`,
`--pdf-mono-font-size 12`,
`--pdf-serif-family "Bitter"`,
`--pdf-sans-family "Fira Sans"`,
`--pdf-mono-family "Fira Mono"`,
`--pdf-standard-font serif`,
`--pdf-page-numbers`,
`--pdf-odd-even-offset 18`, // 18pt = 0.25"
`--pdf-page-margin-left 42`, // 42pt = 0.583"
`--pdf-page-margin-right 42`,
`--pdf-page-margin-top 42`,
`--pdf-page-margin-bottom 54`, // 54pt = 0.75"
`--preserve-cover-aspect-ratio`,
`--embed-all-fonts`,
`--disable-font-rescaling`,
...footerTemplate ? [`--pdf-footer-template "${footerTemplate.replaceAll(/"/g, '\\"').replaceAll(/\s+/g, ' ')}"`] : [],
`--pdf-no-cover`
].join(" ");
res = await runCmd(pdfCmd);
if (!res.ok) {
new Notice(`PDF conversion failed: ${res.stderr}`, 0);
} else {
setSingleNotice(`PDF created successfully: ${path.basename(pdfPath)}`);
}
// Final status and open PDF
new Notice(`All processing complete!\nFinal EPUB: ${path.basename(epubPathKindle)}\nFinal PDF: ${path.basename(pdfPath)}`, 6000);
setTimeout(hideSingleNotice, 2000);
if (fs.existsSync(pdfPath)) {
await openPdf(pdfPath);
} else {
new Notice(`PDF file not found at ${pdfPath}`, 0);
}
} catch (error) {
hideSingleNotice();
new Notice(`Error: ${error.message}`, 0);
} finally {
// If the user selected text in the editor, return that so Templater does not delete it when inserting this template
tR += app.workspace.activeLeaf.view.editor.getSelection();
}
/*