@himojuku/react-native-epub-creator
Version:
This react-native library create epub file for android and IOS
583 lines (511 loc) • 18.8 kB
text/typescript
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;
}
}