UNPKG

@himojuku/react-native-epub-creator

Version:

This react-native library create epub file for android and IOS

583 lines (511 loc) 18.8 kB
import EpubFile, { EpubSettings, } from '@himojuku/epub-constructor'; import JSZip from 'jszip'; import { File, Directory, Paths } from 'expo-file-system/next'; /** * Utility function to validate and convert a string into a valid file name * @param name - The name to be validated and converted into a valid file name * @returns A valid file name string, replacing invalid characters with underscores */ export const getValidFileNameByTitle = (name: string): string => { if (!name || typeof name !== 'string') { return 'default'; } return name.replace(/[^a-zA-Z0-9]/g, '_'); }; const validateDir = async (path: string): Promise<Directory | null> => { if (!path) return null; try { const dir = new Directory(path); if (!dir.exists) { dir.create(); } return dir; } catch (error) { console.error(`Failed to validate directory: ${path}`, error); throw error; } }; const validateParentDir = async (filePath: string): Promise<void> => { const dirPath = filePath.substring(0, filePath.lastIndexOf('/') + 1); await validateDir(dirPath); }; const removeDir = async (path: string) => { if (!path) return; try { const dir = new Directory(path); if (dir.exists) { dir.delete(); } } catch (error) { console.error(`Failed to remove directory: ${path}`, error); } }; const createDirectoryRecursively = async (fullPath: string) => { if (!fullPath.startsWith(Paths.document.uri) && !fullPath.startsWith(Paths.cache.uri)) { throw new Error('Path must be within application directories'); } const basePath = fullPath.startsWith(Paths.document.uri) ? Paths.document.uri : Paths.cache.uri; const relativePath = fullPath.substring(basePath.length); const segments = relativePath.split('/').filter(segment => segment.length > 0); let currentPath = basePath; for (const segment of segments) { currentPath += segment + '/'; const dir = new Directory(currentPath); if (!dir.exists) { dir.create(); } } }; /** * Class for building EPUB files from provided settings */ export default class EpubBuilder { private epub: EpubFile; private outputPath: string; private tempPath?: string; private tempOutputPath: string; private fileName: string; private dProgress: number = 0; private prepared: boolean = false; /** * Progress callback for monitoring EPUB creation */ static onProgress?: ( progress: number, epubFile: string, operation: 'constructEpub' | 'SaveFile' | 'Finished', ) => void; /** * Creates a new EPUB builder instance * @param settings - EPUB settings * @param destinationFolderPath - Where to save the final EPUB file */ constructor(settings: EpubSettings, destinationFolderPath: string) { this.epub = new EpubFile(settings); this.fileName = this.epub.epubSettings.fileName || 'default'; // Ensure path ends with a slash this.outputPath = destinationFolderPath.endsWith('/') ? destinationFolderPath : destinationFolderPath + '/'; // Use document directory as temporary output location this.tempOutputPath = Paths.document.uri + 'temp_epub_output/'; } /** * Returns the current EPUB settings */ public getEpubSettings() { return this.epub.epubSettings; } /** * Prepares the environment for EPUB creation * @returns The builder instance for chaining */ public async prepare() { this.prepared = true; await this.createTempFolder(); if (!this.epub.epubSettings.chapters) { this.epub.epubSettings.chapters = []; } // Clean chapter content, remove unnecessary tags this.sanitizeChapterContent(); return this; } /** * Discards all temporary files created during the EPUB building process */ public async discardChanges() { try { if (this.tempPath) { await removeDir(this.tempPath); } await removeDir(this.tempOutputPath); this.tempPath = undefined; } catch (error) { console.error("Error discarding changes:", error); } } /** * Creates temporary folders needed for the EPUB creation process */ private async createTempFolder() { const safeFileName = this.fileName.replace(/[^a-zA-Z0-9]/g, '_'); this.tempPath = Paths.document.uri + 'epub_creation/' + safeFileName + '/'; await createDirectoryRecursively(this.tempPath); this.tempOutputPath = Paths.document.uri + 'temp_epub_output/'; await createDirectoryRecursively(this.tempOutputPath); } /** * Populates the temporary directory with EPUB content files */ private async populate(): Promise<void> { if (!this.tempPath) { throw new Error('Temporary path not created, please call prepare() first'); } // Get files from epub constructor const files = await this.epub.constructEpub(); // Process each file for (let i = 0; i < files.length; i++) { const file = files[i]; const fullPath = `${this.tempPath}${file.path}`; try { // Update progress this.dProgress = ((i + 1) / files.length) * 100; console.log(`Processing ${i+1}/${files.length}: ${file.path}`); // Distinguish between directories and files if (file.path.endsWith('/')) { // This is a directory await validateDir(fullPath); continue; } // Create parent directory for file await validateParentDir(fullPath); // Special handling for mimetype file if (file.path === 'mimetype') { const mimetypeFile = new File(fullPath); if (!mimetypeFile.exists) { mimetypeFile.create(); } mimetypeFile.write('application/epub+zip'); continue; } // Handle image files if (file.isImage && typeof file.content === 'string') { const sourcePath = file.content; const sourceFile = new File(sourcePath); if (sourceFile.exists) { const targetFile = new File(fullPath); if (targetFile.exists) { targetFile.delete(); } targetFile.create(); // Copy image file sourceFile.copy(new File(fullPath)); } else { console.warn(`Source image not found: ${sourcePath}`); } } // Handle text content files else if (typeof file.content === 'string') { const targetFile = new File(fullPath); if (targetFile.exists) { targetFile.delete(); } targetFile.create(); targetFile.write(file.content); } // Report progress EpubBuilder.onProgress?.(this.dProgress, this.fileName, 'SaveFile'); } catch (error) { console.error(`Error processing file ${file.path}:`, error); } } // Remove unwanted script.js files const scriptPaths = [ `${this.tempPath}script.js`, `${this.tempPath}EPUB/script.js` ]; for (const scriptPath of scriptPaths) { const scriptFile = new File(scriptPath); if (scriptFile.exists) { scriptFile.delete(); } } } /** * Saves the EPUB file to the specified output path * @returns The full path to the saved EPUB file */ public async save(): Promise<string> { const epubFileName = `${this.fileName}.epub`; if (!this.prepared) { await this.prepare(); } try { await this.populate(); await this.fixEpubStructure(); await validateDir(this.outputPath); const outputFilePath = `${this.outputPath}${epubFileName}`; if (this.tempPath) { // Create a new JSZip instance const zip = new JSZip(); // First add mimetype file (uncompressed) const mimetypeFile = new File(`${this.tempPath}mimetype`); if (mimetypeFile.exists) { const content = mimetypeFile.text(); zip.file('mimetype', content, { compression: 'STORE' }); } // Add other files according to EPUB specification order const epubStructure = [ 'META-INF/', 'META-INF/container.xml', 'OEBPS/', 'OEBPS/content.opf', 'OEBPS/toc.ncx' ]; // First add key files (in specified order) for (const path of epubStructure) { if (path.endsWith('/')) { // This is a directory zip.folder(path); } else { const file = new File(`${this.tempPath}${path}`); if (file.exists) { const content = file.text(); zip.file(path, content); } } } // Read all files and add to zip await this.addFolderToZip(zip, this.tempPath, ''); // Generate epub file const content = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE', compressionOptions: { level: 9 }, streamFiles: false }); // Write generated content to filesystem const outputFile = new File(outputFilePath); if (outputFile.exists) { outputFile.delete(); } outputFile.create(); outputFile.write(content); // Clean up temporary files await this.discardChanges(); this.dProgress = 100; EpubBuilder.onProgress?.(this.dProgress, epubFileName, 'Finished'); return outputFilePath; } else { throw new Error('Temporary path not set, EPUB creation failed'); } } catch (error) { console.error("Error saving EPUB:", error); await this.discardChanges(); throw error; } } /** * Recursively adds folder contents to zip file * @param zip - JSZip instance * @param basePath - Base path of temporary directory * @param relativePath - Relative path within the base path */ private async addFolderToZip(zip: JSZip, basePath: string, relativePath: string): Promise<void> { const dir = new Directory(`${basePath}${relativePath}`); const items = dir.list(); for (const item of items) { // Check if item is directory or file const isDirectory = item instanceof Directory; // Get item name for path building const itemName = item.name; const itemPath = relativePath ? `${relativePath}/${itemName}` : itemName; if (itemPath === 'mimetype') continue; // Skip mimetype (already added) if (itemPath === 'script.js') continue; // Skip any script.js files if (isDirectory) { // Process subdirectory recursively await this.addFolderToZip(zip, basePath, itemPath); } else { // Add file to zip const file = item as File; if (file.exists) { const content = file.text(); // Check if file already exists in zip (avoid duplicates) if (!zip.file(itemPath)) { zip.file(itemPath, content); } } } } } /** * Sanitizes chapter content by removing unwanted tags */ private sanitizeChapterContent(): void { if (this.epub.epubSettings.chapters) { this.epub.epubSettings.chapters = this.epub.epubSettings.chapters.map(chapter => { if (chapter.htmlBody) { // Remove script tags along with image tags const sanitizedHtml = chapter.htmlBody .replace(/<img[^>]*>/gi, '') .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, ''); return { ...chapter, htmlBody: sanitizedHtml }; } return chapter; }); } } /** * Fixes EPUB structure to ensure it's valid and follows specifications */ private async fixEpubStructure(): Promise<void> { if (!this.tempPath) return; try { // Detect chapter files and their actual paths const epubPath = `${this.tempPath}EPUB/`; const contentDir = new Directory(`${epubPath}content/`); let chapterFiles: string[] = []; // Collect all chapter files if (contentDir.exists) { // If content directory exists, collect all files within it const items = contentDir.list(); for (const item of items) { if (item instanceof File && (item.name.endsWith('.html') || item.name.endsWith('.xhtml'))) { chapterFiles.push(`content/${item.name}`); } } } else { // Otherwise look for HTML/XHTML files in EPUB root const epubDir = new Directory(epubPath); const items = epubDir.list(); for (const item of items) { if (item instanceof File && (item.name.endsWith('.html') || item.name.endsWith('.xhtml'))) { chapterFiles.push(item.name); } } } console.log(`Found ${chapterFiles.length} chapter files:`, chapterFiles); // Find OPF file const opfFilePattern = /\.opf$/; const epubDir = new Directory(epubPath); const items = epubDir.list(); let opfPath: string | null = null; for (const item of items) { if (item instanceof File && opfFilePattern.test(item.name)) { opfPath = `${epubPath}${item.name}`; break; } } if (!opfPath) { console.error('OPF file not found'); return; } const opfFile = new File(opfPath); let content = opfFile.text(); // Create valid IDs for each chapter const idMap = new Map(); for (let i = 0; i < chapterFiles.length; i++) { // Generate safe ID (no spaces or special characters) const chapterId = `chapter${i}`; idMap.set(chapterFiles[i], chapterId); } // Fix manifest section let manifestContent = ''; const manifestRegex = /<manifest>([\s\S]*?)<\/manifest>/; const manifestMatch = manifestRegex.exec(content); if (manifestMatch) { manifestContent = manifestMatch[1]; // Create new manifest content let newManifestContent = ''; // Preserve CSS and NCX items const cssItemRegex = /<item[^>]*media-type="text\/css"[^>]*>/g; const cssItems = manifestContent.match(cssItemRegex) || []; cssItems.forEach(item => { newManifestContent += item + '\n'; }); const ncxItemRegex = /<item[^>]*media-type="application\/x-dtbncx\+xml"[^>]*>/g; const ncxItems = manifestContent.match(ncxItemRegex) || []; ncxItems.forEach(item => { newManifestContent += item + '\n'; }); // Add chapter items for (const [file, id] of idMap.entries()) { newManifestContent += `<item id="${id}" href="${file}" media-type="application/xhtml+xml"/>\n`; } // Add navigation document const navFile = 'toc.xhtml'; const navFilePath = `${epubPath}${navFile}`; let navFileExists = new File(navFilePath).exists; if (!navFileExists) { // Try alternative nav file name const altNavFile = 'toc.html'; const altNavFilePath = `${epubPath}${altNavFile}`; if (new File(altNavFilePath).exists) { navFileExists = true; } } if (navFileExists) { newManifestContent += `<item id="nav" href="${navFile}" media-type="application/xhtml+xml" properties="nav"/>\n`; } else { // Create navigation file const navContent = this.createNavigationDocument(chapterFiles, idMap); const newNavFile = new File(navFilePath); newNavFile.create(); newNavFile.write(navContent); newManifestContent += `<item id="nav" href="${navFile}" media-type="application/xhtml+xml" properties="nav"/>\n`; } // Update manifest section content = content.replace(manifestRegex, `<manifest>${newManifestContent}</manifest>`); } // Fix spine section let spineContent = '<spine toc="ncx">\n'; for (const id of idMap.values()) { spineContent += ` <itemref idref="${id}"/>\n`; } spineContent += '</spine>'; const spineRegex = /<spine[^>]*>[\s\S]*?<\/spine>/; content = content.replace(spineRegex, spineContent); // Save modified OPF file opfFile.write(content); // Fix NCX file const ncxPath = `${epubPath}toc.ncx`; if (new File(ncxPath).exists) { const ncxFile = new File(ncxPath); let ncxContent = ncxFile.text(); // Create new navigation points let navMap = '<navMap>\n'; let index = 1; for (const [file, id] of idMap.entries()) { const title = `Chapter ${index}`; navMap += ` <navPoint id="${id}" playOrder="${index}">\n`; navMap += ` <navLabel><text>${title}</text></navLabel>\n`; navMap += ` <content src="${file}"/>\n`; navMap += ` </navPoint>\n`; index++; } navMap += '</navMap>'; // Replace navMap section ncxContent = ncxContent.replace(/<navMap>[\s\S]*?<\/navMap>/, navMap); ncxFile.write(ncxContent); } } catch (error) { console.error('Error fixing EPUB structure:', error); throw error; } } /** * Creates a navigation document for the EPUB * @param chapterFiles - Array of chapter file paths * @param _idMap - Map of file paths to IDs * @returns Navigation document content as string */ private createNavigationDocument(chapterFiles: string[], _idMap: Map<string, string>): string { let navContent = `<?xml version="1.0" encoding="utf-8"?> <!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops"> <head> <title>Table of Contents</title> <meta charset="utf-8"/> </head> <body> <nav epub:type="toc" id="toc"> <h1>Table of Contents</h1> <ol>`; let index = 1; for (const file of chapterFiles) { const title = `Chapter ${index}`; navContent += `\n <li><a href="${file}">${title}</a></li>`; index++; } navContent += ` </ol> </nav> </body> </html>`; return navContent; } }