UNPKG

nodepub

Version:

Create valid EPUB (v2) ebooks with metadata, contents and cover image.

222 lines (201 loc) 7.68 kB
const fs = require('fs') const fsPromises = require('fs').promises const path = require('path') const zip = require('archiver') const structuralFiles = require('./constituents/structural.js') const markupFiles = require('./constituents/markup.js') const util = require('./utility.js') /** * Construct a new document. * @param {Object} metadata - the document metadata * @param {Function} generateContentsCallback - optional callback function to * provide the contents page's content markup (not the whole page, just the * actual contents section): fn(links) * @returns an EPUB model */ const document = (metadata, generateContentsCallback) => { const self = this self.CSS = '' self.sections = [] self.images = [] self.metadata = metadata self.generateContentsCallback = generateContentsCallback self.showContents = true self.filesForTOC = [] self.coverImage = '' // Basic validation. const required = ['id', 'title', 'author', 'cover'] if (metadata == null) throw new Error('Missing metadata') required.forEach((field) => { const prop = metadata[field] if (prop == null || typeof (prop) === 'undefined' || prop.toString().trim() === '') throw new Error(`Missing metadata: ${field}`) if (field === 'cover') { self.coverImage = prop } }) if (metadata.showContents !== null && typeof (metadata.showContents) !== 'undefined') { self.showContents = metadata.showContents } // // // If it is Front Matter then it will appear before the contents page. // The overrideFilename is // /** * Add a new section entry (usually a chapter) with the given title and * (HTML) body content. Optionally excludes it from the contents page. * @param {String} title - the section (chapter) title * @param {String} content - the 'body' content as HTML * @param {Boolean} excludeFromContents - don't show in the table of contents * @param {Boolean} isFrontMatter - appears before the contents page? * @param {String} overrideFilename - optional and refers to the name used inside the epub; * by default the filenames are auto-numbered and no file extention should be included */ self.addSection = (title, content, excludeFromContents, isFrontMatter, overrideFilename) => { let filename = overrideFilename if (filename == null || typeof (filename) === 'undefined' || filename.toString().trim() === '') { const i = self.sections.length + 1 filename = `s${i}` } filename = `${filename}.xhtml` self.sections.push({ title, content, excludeFromContents: excludeFromContents || false, isFrontMatter: isFrontMatter || false, filename }) } /** * Add custom CSS to the EPUB. This will be shared by all sections. * @param {String} content - your custom CSS content */ self.addCSS = (content) => { self.CSS = content } /** * Gets the number of sections added so far. * @returns the number of sections added so far */ self.getSectionCount = () => self.sections.length /** * Gets the files needed for the EPUB, as an array of objects. * If you generate an EPUB from the collection of file details you * MUST respect the ordering and the 'compress' option to ensure the * result is valid (otherwise the 'mimetype' file will be broken). * @returns an array of file definitions (name,folder, compress?,content) */ self.getFilesForEPUB = async () => { const syncFiles = [] const asyncFiles = [] // Required files. syncFiles.push({ name: 'mimetype', folder: '', compress: false, content: structuralFiles.getMimetype() }) syncFiles.push({ name: 'container.xml', folder: 'META-INF', compress: true, content: structuralFiles.getContainer(self) }) syncFiles.push({ name: 'ebook.opf', folder: 'OEBPF', compress: true, content: structuralFiles.getOPF(self) }) syncFiles.push({ name: 'navigation.ncx', folder: 'OEBPF', compress: true, content: structuralFiles.getNCX(self) }) syncFiles.push({ name: 'cover.xhtml', folder: 'OEBPF', compress: true, content: markupFiles.getCover(self) }) // Optional files. syncFiles.push({ name: 'ebook.css', folder: 'OEBPF/css', compress: true, content: markupFiles.getCSS(self) }) for (let i = 1; i <= self.sections.length; i += 1) { const fname = self.sections[i - 1].filename syncFiles.push({ name: `${fname}`, folder: 'OEBPF/content', compress: true, content: markupFiles.getSection(self, i) }) } // Table of contents markup. if (self.showContents) { syncFiles.push({ name: 'toc.xhtml', folder: 'OEBPF/content', compress: true, content: markupFiles.getTOC(self) }) } // Extra images - add filename into content property and prepare for async handling. const coverFilename = path.basename(self.coverImage) asyncFiles.push({ name: coverFilename, folder: 'OEBPF/images', compress: true, content: self.coverImage }) if (self.metadata.images && self.metadata.images.length > 0) { self.metadata.images = [...new Set(self.metadata.images)] self.metadata.images.forEach((image) => { const imageFilename = path.basename(image) asyncFiles.push({ name: imageFilename, folder: 'OEBPF/images', compress: true, content: image }) }) } // Now async map to get the file contents. await util.forEachAsync(asyncFiles, async (file) => { const data = await fsPromises.readFile(file.content) const loaded = { name: file.name, folder: file.folder, compress: file.compress, content: data } syncFiles.push(loaded) }) // Return with the files. return syncFiles } // // For valid EPUB files /** * Writes the files needed for the EPUB into a folder structure. * If you compose an EPUB from the files the 'mimetype' MUST be * the first entry in an EPUB (and uncompressed). * @param {String} folder - location to write the files to */ self.writeFilesForEPUB = async (folder) => { const files = await self.getFilesForEPUB() await util.makeFolder(folder) await util.forEachAsync(files, async (file) => { if (file.folder.length > 0) { const f = `${folder}/${file.folder}` await util.makeFolder(f) await fsPromises.writeFile(`${f}/${file.name}`, file.content) } else { await fsPromises.writeFile(`${folder}/${file.name}`, file.content) } }) } /** * Writes the EPUB. The filename should not have an extention. * @param {String} folder - location to write the EPUB into * @param {String} filename - the EPUB filename, without an extention */ self.writeEPUB = async (folder, filename) => { const files = await self.getFilesForEPUB() // Start creating the zip. await util.makeFolder(folder) const output = fs.createWriteStream(`${folder}/${filename}.epub`) const archive = zip('zip', { store: false }) archive.on('error', (archiveErr) => { throw archiveErr }) await new Promise((resolve) => { // Wait for file descriptor to be written. archive.pipe(output) output.on('close', () => resolve()) // Write the file contents. files.forEach((file) => { if (file.folder.length > 0) { archive.append(file.content, { name: `${file.folder}/${file.name}`, store: !file.compress }) } else { archive.append(file.content, { name: file.name, store: !file.compress }) } }) // Done. archive.finalize() }) } return self } exports.document = document