kdp-book-generator
Version:
Generate KDP-compliant PDFs and EPUBs from Markdown for Amazon book publishing
601 lines (570 loc) • 21.1 kB
JavaScript
"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 = `
<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 = `
<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 = `
<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 = `
<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 `
<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 `
<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, '&');
// Then replace other special characters
result = result
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
// 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(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/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(/ /g, ' ');
processed = processed.replace(/—/g, '—');
processed = processed.replace(/–/g, '–');
processed = processed.replace(/“/g, '“');
processed = processed.replace(/”/g, '”');
processed = processed.replace(/‘/g, '‘');
processed = processed.replace(/’/g, '’');
processed = processed.replace(/…/g, '…');
// 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, '&');
// 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 `
<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