UNPKG

kdp-book-generator

Version:

Generate KDP-compliant PDFs and EPUBs from Markdown for Amazon book publishing

251 lines (250 loc) 10.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.PDFGenerator = void 0; const playwright_1 = require("playwright"); const style_generator_1 = require("../styles/style-generator"); /** * Generator for creating KDP-compliant PDFs from parsed Markdown content * * Features: * - Uses Playwright (Chromium) for accurate PDF rendering * - Automatic page size configuration based on book format * - Font embedding for print compatibility * - Proper margin handling for KDP requirements * - Support for bleed and page numbers */ class PDFGenerator { constructor() { this.browser = null; this.styleGenerator = new style_generator_1.StyleGenerator(); } /** * Generate a PDF from parsed book content * * @param parsedBook - Parsed book structure from MarkdownParser * @param config - Book configuration including format, margins, and typography * @param options - PDF generation options including output path * @throws {Error} If PDF generation fails * * @example * ```typescript * const generator = new PDFGenerator(); * await generator.generatePDF(parsedBook, config, { * outputPath: 'output.pdf', * debug: true * }); * ``` */ async generatePDF(parsedBook, config, options) { this.browser = await playwright_1.chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'], }); try { const page = await this.browser.newPage(); // Set page size to match book format await page.setViewportSize({ width: this.convertToPixels(config.format.width, config.format.unit), height: this.convertToPixels(config.format.height, config.format.unit), }); // Generate and set content const html = this.generateHTML(parsedBook, config); if (options.debug) { console.log('Generated HTML length:', html.length); console.log('Sample HTML:', html.substring(0, 500)); } await page.setContent(html, { waitUntil: 'networkidle' }); // Wait for fonts to load await page.waitForLoadState('networkidle'); await page.waitForTimeout(2000); // Wait for document fonts try { await page.waitForFunction('document.fonts.ready'); } catch (e) { console.log('Font loading check failed, continuing...'); } // Generate PDF const pdfBuffer = await page.pdf({ path: options.outputPath, format: 'A4', // Will be overridden by CSS @page printBackground: true, preferCSSPageSize: true, displayHeaderFooter: false, margin: { top: 0, right: 0, bottom: 0, left: 0 }, }); if (options.debug) { console.log(`PDF generated successfully: ${options.outputPath}`); console.log(`File size: ${pdfBuffer.length} bytes`); } } finally { await this.browser.close(); this.browser = null; } } generateHTML(parsedBook, config) { const css = this.styleGenerator.generateCSS(config); let title = config.title || parsedBook.frontMatter.title || 'Untitled Book'; // Clean up any artifacts in the title title = title.replace(/\s*-\s*edition\s*\d+.*$/i, '').trim(); const author = config.author || parsedBook.frontMatter.author || ''; return ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>${title}</title> <style> ${css} </style> ${this.generateFontImports(config)} <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Lexend:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet"> <style> /* Emergency fallback to ensure headers are visible */ h1, h2, h3, h4, h5, h6 { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif; color: #000000 !important; opacity: 1 !important; visibility: visible !important; } /* Override with Lexend when loaded */ h1.font-loaded, h2.font-loaded, h3.font-loaded, h4.font-loaded, h5.font-loaded, h6.font-loaded { font-family: 'Lexend', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif !important; } </style> </head> <body> <div class="page-content"> ${this.generateTitlePage(title, author)} <div class="chapter-content"> ${this.processContent(parsedBook.html)} </div> </div> </body> </html>`; } generateFontImports(config) { const fonts = new Set(); // Collect all unique font families Object.values(config.typography).forEach((font) => { // Extract primary font family (before first comma) const primaryFont = font.family.split(',')[0].trim(); fonts.add(primaryFont); }); // Generate Google Fonts imports with proper weights const fontImports = []; if (fonts.has('Lexend')) { fontImports.push('family=Lexend:wght@300;400;500;600;700;800;900'); } if (fonts.has('Nunito')) { fontImports.push('family=Nunito:wght@300;400;500;600;700;800;900'); } // Add other non-standard fonts Array.from(fonts) .filter((font) => !['serif', 'sans-serif', 'monospace', 'Lexend', 'Nunito'].includes(font)) .forEach((font) => { fontImports.push(`family=${font.replace(/\s+/g, '+')}:wght@300;400;500;600;700`); }); if (fontImports.length > 0) { return `<link href="https://fonts.googleapis.com/css2?${fontImports.join('&')}&display=swap" rel="stylesheet">`; } return ''; } generateTitlePage(title, author) { return ` <div class="title-page" style="page-break-after: always; text-align: center; padding-top: 30%;"> <h1 class="title-page-title" style="font-family: 'Lexend', -apple-system, sans-serif; font-size: 28pt; font-weight: bold; margin-bottom: 24pt; page-break-before: avoid; text-transform: none; line-height: 1.3;">${title}</h1> ${author ? `<h2 class="title-page-author" style="font-family: 'Nunito', -apple-system, sans-serif; font-size: 16pt; font-weight: normal; page-break-before: avoid; margin-top: 24pt;">${author}</h2>` : ''} </div>`; } generateTableOfContents(toc) { if (toc.length === 0) return ''; const tocItems = toc .map((item) => { const indent = (item.level - 1) * 20; const className = `toc-level-${item.level}`; return `<li class="${className}" style="margin-left: ${indent}pt;"> <span class="toc-title">${item.title}</span> </li>`; }) .join(''); return ` <div class="table-of-contents"> <h1>Table of Contents</h1> <ol> ${tocItems} </ol> </div>`; } processContent(html) { // Process the HTML to add page breaks and other book-specific formatting let processed = html; // Replace <hr> with a more subtle separator (not a page break) processed = processed.replace(/<hr\s*\/?>/g, '<div class="section-separator"></div>'); // Detect and handle check mark lists processed = processed.replace(/<ul>([\s\S]*?)<\/ul>/g, (match, content) => { // Check if this list contains check marks if (content.includes('✓') || content.includes('✗') || content.includes('☑') || content.includes('☒')) { // Replace with checklist class and remove default bullets return `<ul class="checklist">${content}</ul>`; } return match; }); // Add class to headings that precede tables processed = processed.replace(/(<h[1-3][^>]*>)(.*?)(<\/h[1-3]>)\s*(?:<p[^>]*>.*?<\/p>\s*)?(<table[^>]*>)/g, (match, openTag, content, closeTag, _table) => { // Add class to heading that precedes a table const tagWithClass = openTag.includes('class=') ? openTag.replace(/class="([^"]*)"/, 'class="$1 heading-before-table"') : openTag.replace(/>$/, ' class="heading-before-table">'); return `${tagWithClass}${content}${closeTag}${match.substring(openTag.length + content.length + closeTag.length)}`; }); // Add page breaks before h1 (main chapters only) processed = processed.replace(/(<h1[^>]*>)/g, (match, p1, offset) => { // Don't add page break at the very beginning or right after TOC if (offset < 100) { return p1; } // Look back to see if we just had a page break const lookback = Math.min(offset, 500); const precedingContent = processed.substring(offset - lookback, offset); if (precedingContent.includes('page-break') || precedingContent.includes('table-of-contents')) { return p1; } return `<div class="page-break"></div>${p1}`; }); // Wrap headings followed by tables in a keep-together container processed = processed.replace(/(<h[2-3][^>]*class="[^"]*heading-before-table[^"]*"[^>]*>.*?<\/h[2-3]>)([\s\S]*?)(<table[\s\S]*?<\/table>)/g, '<div class="keep-together">$1$2$3</div>'); return processed; } convertToPixels(value, unit) { switch (unit) { case 'in': return Math.round(value * 96); // 96 DPI case 'mm': return Math.round(value * 3.78); // 96 DPI case 'cm': return Math.round(value * 37.8); // 96 DPI default: return value; } } async close() { if (this.browser) { await this.browser.close(); this.browser = null; } } } exports.PDFGenerator = PDFGenerator; //# sourceMappingURL=pdf-generator.js.map