UNPKG

kdp-book-generator

Version:

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

601 lines (570 loc) 21.1 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.EPUBGenerator = void 0; const uuid_1 = require("uuid"); const jszip_1 = __importDefault(require("jszip")); const fs_1 = require("fs"); const path_1 = require("path"); class EPUBGenerator { constructor() { this.chapters = []; this.zip = new jszip_1.default(); this.contentOpfId = (0, uuid_1.v4)(); } async generateEPUB(parsedBook, config, options) { // Add mimetype (must be first file, uncompressed) this.zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' }); // Create META-INF directory this.createContainerXML(); // Handle cover image if provided let coverImageName; if (options.coverImage && (0, fs_1.existsSync)(options.coverImage)) { coverImageName = await this.addCoverImage(options.coverImage); } // Create OEBPS directory structure // Create chapters first so we know how many there are this.createChapterFiles(parsedBook, config, coverImageName); this.createStylesheet(config); // Now create manifest and TOC with correct chapter count this.createContentOPF(parsedBook, config, options, coverImageName); this.createTOC(parsedBook, config); this.createNCX(parsedBook, config); // Add NCX for KDP compatibility // Generate the EPUB file const content = await this.zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', compressionOptions: { level: 9 }, }); (0, fs_1.writeFileSync)(options.outputPath, content); } async addCoverImage(imagePath) { const imageData = (0, fs_1.readFileSync)(imagePath); const ext = (0, path_1.extname)(imagePath).toLowerCase(); const imageName = `cover${ext}`; // Add image to EPUB this.zip.file(`OEBPS/images/${imageName}`, imageData); return imageName; } createContainerXML() { const containerXML = `<?xml version="1.0" encoding="UTF-8"?> <container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"> <rootfiles> <rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/> </rootfiles> </container>`; this.zip.file('META-INF/container.xml', containerXML); } createContentOPF(parsedBook, config, options, coverImageName) { const title = config.title || parsedBook.frontMatter.title || 'Untitled'; const author = config.author || parsedBook.frontMatter.author || 'Unknown Author'; const language = options.language || 'en'; const publisher = options.publisher || 'KDP Book Generator'; const date = new Date().toISOString().split('T')[0]; const manifestItems = []; const spineItems = []; // Add cover if provided if (coverImageName) { const mediaType = this.getImageMediaType(coverImageName); manifestItems.push(`<item id="cover-image" href="images/${coverImageName}" media-type="${mediaType}"/>`); manifestItems.push(`<item id="cover" href="cover.xhtml" media-type="application/xhtml+xml"/>`); spineItems.push(`<itemref idref="cover"/>`); } // Add title page manifestItems.push(`<item id="title-page" href="title.xhtml" media-type="application/xhtml+xml"/>`); spineItems.push(`<itemref idref="title-page"/>`); // Add TOC manifestItems.push(`<item id="toc" href="toc.xhtml" media-type="application/xhtml+xml"/>`); spineItems.push(`<itemref idref="toc"/>`); // Add NCX for KDP compatibility manifestItems.push(`<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>`); // Add chapters to manifest based on actual extracted chapters this.chapters.forEach((chapter, index) => { const chapterId = `chapter-${index + 1}`; manifestItems.push(`<item id="${chapterId}" href="${chapterId}.xhtml" media-type="application/xhtml+xml"/>`); spineItems.push(`<itemref idref="${chapterId}"/>`); }); // Add stylesheet manifestItems.push(`<item id="stylesheet" href="styles.css" media-type="text/css"/>`); const contentOPF = `<?xml version="1.0" encoding="UTF-8"?> <package xmlns="http://www.idpf.org/2007/opf" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" unique-identifier="book-id" version="2.0"> <metadata> <dc:identifier id="book-id">urn:uuid:${this.contentOpfId}</dc:identifier> ${options.isbn ? `<dc:identifier id="isbn">urn:isbn:${options.isbn}</dc:identifier>` : ''} <dc:title>${this.escapeXML(title)}</dc:title> <dc:creator>${this.escapeXML(author)}</dc:creator> <dc:language>${language}</dc:language> <dc:publisher>${this.escapeXML(publisher)}</dc:publisher> <dc:date>${date}</dc:date> <dc:rights>All rights reserved</dc:rights> ${coverImageName ? '<meta name="cover" content="cover-image"/>' : ''} </metadata> <manifest> ${manifestItems.join('\n ')} </manifest> <spine toc="ncx"> ${spineItems.join('\n ')} </spine> <guide> ${coverImageName ? '<reference type="cover" title="Cover" href="cover.xhtml"/>' : ''} <reference type="toc" title="Table of Contents" href="toc.xhtml"/> ${this.chapters.length > 0 ? '<reference type="text" title="Begin Reading" href="chapter-1.xhtml"/>' : ''} </guide> </package>`; this.zip.file('OEBPS/content.opf', contentOPF); } createTOC(_parsedBook, _config) { const tocItems = this.chapters .map((chapter, index) => { return ` <li> <a href="chapter-${index + 1}.xhtml">${this.escapeXML(chapter.title)}</a> </li>`; }) .join(''); const tocXHTML = `<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"> <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en"> <head> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/> <title>Table of Contents</title> <link rel="stylesheet" type="text/css" href="styles.css"/> </head> <body> <div class="toc"> <h1>Table of Contents</h1> <ol> <li><a href="title.xhtml">Title Page</a></li> ${tocItems} </ol> </div> </body> </html>`; this.zip.file('OEBPS/toc.xhtml', tocXHTML); } createNCX(parsedBook, config) { const title = config.title || parsedBook.frontMatter.title || 'Untitled'; const author = config.author || parsedBook.frontMatter.author || 'Unknown Author'; let playOrder = 1; const navPoints = []; // Add title page navPoints.push(` <navPoint id="navpoint-${playOrder}" playOrder="${playOrder}"> <navLabel><text>Title Page</text></navLabel> <content src="title.xhtml"/> </navPoint>`); playOrder++; // Add TOC navPoints.push(` <navPoint id="navpoint-${playOrder}" playOrder="${playOrder}"> <navLabel><text>Table of Contents</text></navLabel> <content src="toc.xhtml"/> </navPoint>`); playOrder++; // Add chapters this.chapters.forEach((chapter, index) => { navPoints.push(` <navPoint id="navpoint-${playOrder}" playOrder="${playOrder}"> <navLabel><text>${this.escapeXML(chapter.title)}</text></navLabel> <content src="chapter-${index + 1}.xhtml"/> </navPoint>`); playOrder++; }); const ncxContent = `<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE ncx PUBLIC "-//NISO//DTD ncx 2005-1//EN" "http://www.daisy.org/z3986/2005/ncx-2005-1.dtd"> <ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1"> <head> <meta name="dtb:uid" content="urn:uuid:${this.contentOpfId}"/> <meta name="dtb:depth" content="1"/> <meta name="dtb:totalPageCount" content="0"/> <meta name="dtb:maxPageNumber" content="0"/> </head> <docTitle> <text>${this.escapeXML(title)}</text> </docTitle> <docAuthor> <text>${this.escapeXML(author)}</text> </docAuthor> <navMap> ${navPoints.join('')} </navMap> </ncx>`; this.zip.file('OEBPS/toc.ncx', ncxContent); } extractChaptersFromHTML(html) { const h1Regex = /<h1[^>]*>(.*?)<\/h1>/gi; const chapters = []; let lastIndex = 0; let match; let currentChapter = null; let skipNextH1 = false; while ((match = h1Regex.exec(html)) !== null) { const h1Title = this.stripHTML(match[1]); // Check if this is a duplicate chapter title (bilingual book) if (currentChapter && this.isSameChapter(currentChapter.title, h1Title)) { // This is the same chapter in a different language, merge it skipNextH1 = true; continue; } if (currentChapter && !skipNextH1) { // Save the content up to this new H1 currentChapter.content = html.substring(lastIndex, match.index); chapters.push(currentChapter); } if (!skipNextH1) { // Start new chapter currentChapter = { title: h1Title, content: '', }; lastIndex = match.index; } skipNextH1 = false; } // Add the last chapter if (currentChapter && lastIndex < html.length) { currentChapter.content = html.substring(lastIndex); chapters.push(currentChapter); } return chapters; } createChapterFiles(parsedBook, config, coverImageName) { // Create cover page if cover image is provided if (coverImageName) { const coverXHTML = this.createCoverPage(coverImageName); this.zip.file('OEBPS/cover.xhtml', coverXHTML); } // Create title page const titlePageXHTML = this.createTitlePage(config); this.zip.file('OEBPS/title.xhtml', titlePageXHTML); // Extract chapters from HTML const chapters = this.extractChaptersFromHTML(parsedBook.html); // Create chapter files chapters.forEach((chapter, index) => { const chapterNumber = index + 1; const processedContent = this.processHTMLForEPUB(chapter.content); const chapterXHTML = this.createChapterXHTML(chapter.title, processedContent); this.zip.file(`OEBPS/chapter-${chapterNumber}.xhtml`, chapterXHTML); }); // Store chapters for use in manifest this.chapters = chapters; } createTitlePage(config) { const title = config.title || 'Untitled'; const author = config.author || ''; return `<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"> <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en"> <head> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/> <title>${this.escapeXML(title)}</title> <link rel="stylesheet" type="text/css" href="styles.css"/> </head> <body> <div class="title-page"> <h1 class="book-title">${this.escapeXML(title)}</h1> ${author ? `<h2 class="book-author">${this.escapeXML(author)}</h2>` : ''} </div> </body> </html>`; } createChapterXHTML(title, content) { return `<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"> <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en"> <head> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/> <title>${this.escapeXML(title)}</title> <link rel="stylesheet" type="text/css" href="styles.css"/> </head> <body> ${content} </body> </html>`; } createStylesheet(_config) { const css = `/* EPUB Stylesheet for Kindle */ @page { margin: 0; padding: 0; } body { font-family: Georgia, serif; font-size: 1em; line-height: 1.6; margin: 0; padding: 0.5em; text-align: left; widows: 2; orphans: 2; } h1, h2, h3, h4, h5, h6 { font-family: Helvetica, Arial, sans-serif; font-weight: bold; margin-top: 1em; margin-bottom: 0.5em; page-break-after: avoid; } h1 { font-size: 1.8em; text-align: left; margin-top: 0; } h2 { font-size: 1.4em; } h3 { font-size: 1.2em; } p { margin-top: 0; margin-bottom: 1em; text-indent: 0; } .title-page { text-align: center; padding: 20% 0; } .book-title { font-size: 2.5em; margin-bottom: 0.5em; } .book-author { font-size: 1.5em; font-weight: normal; font-style: italic; } blockquote { margin: 1em 2em; padding-left: 1em; border-left: 3px solid #ccc; font-style: italic; } code { font-family: monospace; font-size: 0.9em; background-color: #f0f0f0; padding: 0.1em 0.3em; } pre { font-family: monospace; font-size: 0.9em; background-color: #f0f0f0; padding: 1em; overflow-x: auto; white-space: pre-wrap; } table { border-collapse: collapse; width: 100%; margin: 1em 0; } th, td { border: 1px solid #ddd; padding: 0.5em; text-align: left; } th { background-color: #f0f0f0; font-weight: bold; } ul, ol { margin: 1em 0; padding-left: 2em; } li { margin-bottom: 0.3em; } img { max-width: 100%; height: auto; display: block; margin: 1em auto; } a { color: #0066cc; text-decoration: underline; } /* Cover page */ .cover-page { text-align: center; margin: 0; padding: 0; height: 100%; } .cover-page img { width: 100%; height: 100%; object-fit: contain; max-width: 100%; max-height: 100%; } /* Kindle-specific optimizations */ @media amzn-kf8 { body { margin: 0; padding: 0; } h1 { font-size: 2em; } } @media amzn-mobi { body { margin: 0; padding: 0; } img { width: 100%; } }`; this.zip.file('OEBPS/styles.css', css); } escapeXML(str) { if (!str) return ''; // First handle already encoded entities to avoid double encoding let result = str; // Replace ampersands that aren't part of an entity result = result.replace(/&(?!(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);)/g, '&amp;'); // Then replace other special characters result = result .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&apos;'); // Remove any control characters that might cause issues // eslint-disable-next-line no-control-regex result = result.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); return result; } stripHTML(html) { // First decode any HTML entities let text = html .replace(/&amp;/g, '&') .replace(/&lt;/g, '<') .replace(/&gt;/g, '>') .replace(/&quot;/g, '"') .replace(/&#039;/g, "'"); // Then strip all HTML tags text = text.replace(/<[^>]*>/g, ''); // Clean up any extra whitespace text = text.trim(); return text; } cleanIdAttribute(id) { // Replace colons and other invalid characters with hyphens const cleaned = id.replace(/[^a-zA-Z0-9_.-]/g, '-').replace(/^-+|-+$/g, ''); // Debug: Log any IDs that had colons if (id.includes(':')) { console.log(`Cleaning ID: "${id}" -> "${cleaned}"`); } return cleaned; } processHTMLForEPUB(html) { let processed = html; // Remove any page break divs processed = processed.replace(/<div class="page-break"[^>]*><\/div>/g, ''); processed = processed.replace(/<div class="section-separator"[^>]*><\/div>/g, '<hr/>'); // Remove anchor tags that were causing issues processed = processed.replace(/<a[^>]*class="header-anchor[^>]*>.*?<\/a>/g, ''); // Clean up any empty paragraphs processed = processed.replace(/<p>\s*<\/p>/g, ''); // Ensure proper XHTML formatting - self-closing tags processed = processed.replace(/<br\s*\/?>/gi, '<br/>'); processed = processed.replace(/<hr\s*\/?>/gi, '<hr/>'); processed = processed.replace(/<img([^>]+?)(?:\s*\/)?>(?:<\/img>)?/gi, '<img$1/>'); processed = processed.replace(/<input([^>]+?)(?:\s*\/)?>(?:<\/input>)?/gi, '<input$1/>'); processed = processed.replace(/<meta([^>]+?)(?:\s*\/)?>(?:<\/meta>)?/gi, '<meta$1/>'); processed = processed.replace(/<link([^>]+?)(?:\s*\/)?>(?:<\/link>)?/gi, '<link$1/>'); // Fix common HTML entities that might cause issues processed = processed.replace(/&nbsp;/g, '&#160;'); processed = processed.replace(/&mdash;/g, '&#8212;'); processed = processed.replace(/&ndash;/g, '&#8211;'); processed = processed.replace(/&ldquo;/g, '&#8220;'); processed = processed.replace(/&rdquo;/g, '&#8221;'); processed = processed.replace(/&lsquo;/g, '&#8216;'); processed = processed.replace(/&rsquo;/g, '&#8217;'); processed = processed.replace(/&hellip;/g, '&#8230;'); // Remove any style attributes (Kindle will use its own) processed = processed.replace(/\sstyle="[^"]*"/gi, ''); // Ensure all ampersands are properly encoded processed = processed.replace(/&(?!(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);)/g, '&amp;'); // Fix invalid ID attributes (remove colons and other invalid characters) // First pass: Match ID attributes with optional preceding space or at start of tag processed = processed.replace(/(\s?)\b(id|ID)\s*=\s*["']([^"']+)["']/gi, (match, space, attr, id) => { const cleanId = this.cleanIdAttribute(id); return cleanId ? `${space}${attr.toLowerCase()}="${cleanId}"` : ''; }); // Second pass: More aggressive pattern to catch any remaining IDs with colons // This catches IDs that might have been missed by the first pass processed = processed.replace(/\bid\s*=\s*["']([^"']*:[^"']*?)["']/gi, (match, id) => { const cleanId = this.cleanIdAttribute(id); return cleanId ? `id="${cleanId}"` : ''; }); // Third pass: Ultra-aggressive - find any id= pattern regardless of context // This will catch IDs even without word boundaries processed = processed.replace(/id\s*=\s*["']([^"']+)["']/gi, (match, id) => { if (id.includes(':')) { const cleanId = this.cleanIdAttribute(id); return cleanId ? `id="${cleanId}"` : ''; } return match; // Return unchanged if no colon }); return processed; } createCoverPage(coverImageName) { return `<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"> <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en"> <head> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/> <title>Cover</title> <link rel="stylesheet" type="text/css" href="styles.css"/> </head> <body> <div class="cover-page"> <img src="images/${coverImageName}" alt="Book Cover"/> </div> </body> </html>`; } getImageMediaType(filename) { const ext = (0, path_1.extname)(filename).toLowerCase(); switch (ext) { case '.jpg': case '.jpeg': return 'image/jpeg'; case '.png': return 'image/png'; case '.gif': return 'image/gif'; case '.svg': return 'image/svg+xml'; default: return 'image/jpeg'; } } isSameChapter(title1, title2) { // Extract chapter numbers from titles const getChapterNumber = (title) => { // Match patterns like "Chapter 1", "Capítulo 1", "Theory Summary 1", etc. const match = title.match(/(?:chapter|capítulo|section|sección|theory summary|resumen teórico)\s*(\d+)/i); return match ? match[1] : null; }; const num1 = getChapterNumber(title1); const num2 = getChapterNumber(title2); // If both have chapter numbers and they match, it's the same chapter if (num1 && num2 && num1 === num2) { return true; } // Also check if one title contains the other (for bilingual titles) const clean1 = title1.toLowerCase().replace(/[^\w\s]/g, ''); const clean2 = title2.toLowerCase().replace(/[^\w\s]/g, ''); return clean1.includes(clean2) || clean2.includes(clean1); } } exports.EPUBGenerator = EPUBGenerator; //# sourceMappingURL=epub-generator.js.map