kdp-book-generator
Version:
Generate KDP-compliant PDFs and EPUBs from Markdown for Amazon book publishing
251 lines (250 loc) • 10.6 kB
JavaScript
;
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