#pragma once // ============================================================================= // EpubProcessor.h - Convert EPUB files to plain text for TextReaderScreen // // Pipeline: EPUB (ZIP) → container.xml → OPF spine → extract chapters → // strip XHTML tags → concatenated plain text → cached .txt on SD // // The resulting .txt file is placed in /books/ and picked up automatically // by TextReaderScreen's existing pagination, indexing, and bookmarking. // // Dependencies: EpubZipReader.h (for ZIP extraction) // ============================================================================= #include #include #include "EpubZipReader.h" #include "Utf8CP437.h" // Maximum chapters in spine (most novels have 20-80) #define EPUB_MAX_CHAPTERS 200 // Maximum manifest items we track #define EPUB_MAX_MANIFEST 256 // Buffer size for reading OPF/container XML // (These are small files, typically 1-20KB) #define EPUB_XML_BUF_SIZE 64 class EpubProcessor { public: // ---------------------------------------------------------- // Process an EPUB file: extract text and write to SD cache. // // epubPath: source, e.g. "/books/The Iliad.epub" // txtPath: output, e.g. "/books/The Iliad by Homer.txt" // // Returns true if the .txt file was written successfully. // If txtPath already exists, returns true immediately (cached). // ---------------------------------------------------------- static bool processToText(const char* epubPath, const char* txtPath) { // Check if already cached if (SD.exists(txtPath)) { Serial.printf("EpubProc: '%s' already cached\n", txtPath); return true; } Serial.printf("EpubProc: Processing '%s'\n", epubPath); unsigned long t0 = millis(); // Open the EPUB (ZIP archive) File epubFile = SD.open(epubPath, FILE_READ); if (!epubFile) { Serial.println("EpubProc: Cannot open EPUB file"); return false; } // Heap-allocate zip reader (entries table is ~19KB) EpubZipReader* zip = new EpubZipReader(); if (!zip) { epubFile.close(); Serial.println("EpubProc: Cannot allocate ZipReader"); return false; } if (!zip->open(epubFile)) { delete zip; epubFile.close(); Serial.println("EpubProc: Cannot parse ZIP structure"); return false; } // Step 1: Find OPF path from container.xml char opfPath[EPUB_XML_BUF_SIZE]; opfPath[0] = '\0'; if (!_findOpfPath(zip, opfPath, sizeof(opfPath))) { delete zip; epubFile.close(); Serial.println("EpubProc: Cannot find OPF path"); return false; } Serial.printf("EpubProc: OPF at '%s'\n", opfPath); // Determine the content base directory (e.g., "OEBPS/") char baseDir[EPUB_XML_BUF_SIZE]; _getDirectory(opfPath, baseDir, sizeof(baseDir)); // Step 2: Parse OPF to get title and spine chapter order char title[128]; title[0] = '\0'; // Chapter paths in spine order char** chapterPaths = nullptr; int chapterCount = 0; if (!_parseOpf(zip, opfPath, baseDir, title, sizeof(title), &chapterPaths, &chapterCount)) { delete zip; epubFile.close(); Serial.println("EpubProc: Cannot parse OPF"); return false; } Serial.printf("EpubProc: Title='%s', %d chapters\n", title, chapterCount); // Step 3: Extract each chapter, strip XHTML, write to output .txt File outFile = SD.open(txtPath, FILE_WRITE); if (!outFile) { _freeChapterPaths(chapterPaths, chapterCount); delete zip; epubFile.close(); Serial.printf("EpubProc: Cannot create '%s'\n", txtPath); return false; } // Write title as first line if (title[0]) { outFile.println(title); outFile.println(); } int chaptersWritten = 0; uint32_t totalBytes = 0; for (int i = 0; i < chapterCount; i++) { int entryIdx = zip->findEntry(chapterPaths[i]); if (entryIdx < 0) { Serial.printf("EpubProc: Chapter not found: '%s'\n", chapterPaths[i]); continue; } uint32_t rawSize = 0; uint8_t* rawData = zip->extractEntry(entryIdx, &rawSize); if (!rawData || rawSize == 0) { Serial.printf("EpubProc: Failed to extract chapter %d\n", i); if (rawData) free(rawData); continue; } // Strip XHTML tags and write plain text uint32_t textLen = 0; uint8_t* plainText = _stripXhtml(rawData, rawSize, &textLen); free(rawData); if (plainText && textLen > 0) { outFile.write(plainText, textLen); // Add chapter separator outFile.print("\n\n"); totalBytes += textLen + 2; chaptersWritten++; } if (plainText) free(plainText); } outFile.flush(); outFile.close(); // Release SD CS for other SPI users digitalWrite(SDCARD_CS, HIGH); _freeChapterPaths(chapterPaths, chapterCount); delete zip; epubFile.close(); unsigned long elapsed = millis() - t0; Serial.printf("EpubProc: Done! %d chapters, %u bytes in %lu ms -> '%s'\n", chaptersWritten, totalBytes, elapsed, txtPath); return chaptersWritten > 0; } // ---------------------------------------------------------- // Extract just the title from an EPUB (for display in file list). // Returns false if it can't be determined. // ---------------------------------------------------------- static bool getTitle(const char* epubPath, char* titleBuf, int titleBufSize) { File epubFile = SD.open(epubPath, FILE_READ); if (!epubFile) return false; EpubZipReader* zip = new EpubZipReader(); if (!zip) { epubFile.close(); return false; } if (!zip->open(epubFile)) { delete zip; epubFile.close(); return false; } char opfPath[EPUB_XML_BUF_SIZE]; if (!_findOpfPath(zip, opfPath, sizeof(opfPath))) { delete zip; epubFile.close(); return false; } // Extract OPF and find int opfIdx = zip->findEntry(opfPath); if (opfIdx < 0) { delete zip; epubFile.close(); return false; } uint32_t opfSize = 0; uint8_t* opfData = zip->extractEntry(opfIdx, &opfSize); delete zip; epubFile.close(); if (!opfData) return false; bool found = _extractTagContent((const char*)opfData, opfSize, "dc:title", titleBuf, titleBufSize); free(opfData); return found; } // ---------------------------------------------------------- // Build a cache .txt path from an .epub path. // e.g., "/books/mybook.epub" -> "/books/.epub_cache/mybook.txt" // ---------------------------------------------------------- static void buildCachePath(const char* epubPath, char* cachePath, int cachePathSize) { // Extract filename without extension const char* lastSlash = strrchr(epubPath, '/'); const char* filename = lastSlash ? lastSlash + 1 : epubPath; // Find the directory part char dir[128]; if (lastSlash) { int dirLen = lastSlash - epubPath; if (dirLen >= (int)sizeof(dir)) dirLen = sizeof(dir) - 1; strncpy(dir, epubPath, dirLen); dir[dirLen] = '\0'; } else { strcpy(dir, "/books"); } // Create cache directory if needed char cacheDir[160]; snprintf(cacheDir, sizeof(cacheDir), "%s/.epub_cache", dir); if (!SD.exists(cacheDir)) { SD.mkdir(cacheDir); } // Strip .epub extension char baseName[128]; strncpy(baseName, filename, sizeof(baseName) - 1); baseName[sizeof(baseName) - 1] = '\0'; char* dot = strrchr(baseName, '.'); if (dot) *dot = '\0'; snprintf(cachePath, cachePathSize, "%s/%s.txt", cacheDir, baseName); } private: // ---------------------------------------------------------- // Parse container.xml to find the OPF file path. // Returns true if found. // ---------------------------------------------------------- static bool _findOpfPath(EpubZipReader* zip, char* opfPath, int opfPathSize) { int idx = zip->findEntry("META-INF/container.xml"); if (idx < 0) { // Fallback: find any .opf file directly idx = zip->findEntryBySuffix(".opf"); if (idx >= 0) { const ZipEntry* e = zip->getEntry(idx); strncpy(opfPath, e->filename, opfPathSize - 1); opfPath[opfPathSize - 1] = '\0'; return true; } return false; } uint32_t size = 0; uint8_t* data = zip->extractEntry(idx, &size); if (!data) return false; // Find: full-path="OEBPS/content.opf" bool found = _extractAttribute((const char*)data, size, "full-path", opfPath, opfPathSize); free(data); return found; } // ---------------------------------------------------------- // Parse OPF to extract title, build manifest, and resolve spine. // // Populates chapterPaths (heap-allocated array of strings) with // full ZIP paths for each chapter in spine order. // Caller must free with _freeChapterPaths(). // ---------------------------------------------------------- static bool _parseOpf(EpubZipReader* zip, const char* opfPath, const char* baseDir, char* title, int titleSize, char*** outChapterPaths, int* outChapterCount) { int opfIdx = zip->findEntry(opfPath); if (opfIdx < 0) return false; uint32_t opfSize = 0; uint8_t* opfData = zip->extractEntry(opfIdx, &opfSize); if (!opfData) return false; const char* xml = (const char*)opfData; // Extract title _extractTagContent(xml, opfSize, "dc:title", title, titleSize); // Build manifest: map id -> href // We use two parallel arrays to avoid complex data structures struct ManifestItem { char id[64]; char href[128]; bool isContent; // has media-type containing "html" or "xml" }; // Heap-allocate manifest (could be large) ManifestItem* manifest = (ManifestItem*)ps_malloc( EPUB_MAX_MANIFEST * sizeof(ManifestItem)); if (!manifest) { manifest = (ManifestItem*)malloc(EPUB_MAX_MANIFEST * sizeof(ManifestItem)); } if (!manifest) { free(opfData); return false; } int manifestCount = 0; // Parse elements from const char* manifestStart = _findTag(xml, opfSize, "= manifestEnd) break; // Find the closing > of this const char* tagEnd = (const char*)memchr(pos, '>', manifestEnd - pos); if (!tagEnd) break; tagEnd++; ManifestItem& item = manifest[manifestCount]; item.id[0] = '\0'; item.href[0] = '\0'; item.isContent = false; _extractAttributeFromTag(pos, tagEnd - pos, "id", item.id, sizeof(item.id)); _extractAttributeFromTag(pos, tagEnd - pos, "href", item.href, sizeof(item.href)); // Check media-type for content files char mediaType[64]; mediaType[0] = '\0'; _extractAttributeFromTag(pos, tagEnd - pos, "media-type", mediaType, sizeof(mediaType)); item.isContent = (strstr(mediaType, "html") != nullptr || strstr(mediaType, "xml") != nullptr); if (item.id[0] && item.href[0]) { manifestCount++; } pos = tagEnd; } } Serial.printf("EpubProc: Manifest has %d items\n", manifestCount); // Parse to get reading order // Spine contains elements const char* spineStart = _findTag(xml, opfSize, "= spineEnd) break; const char* tagEnd = (const char*)memchr(pos, '>', spineEnd - pos); if (!tagEnd) break; tagEnd++; char idref[64]; idref[0] = '\0'; _extractAttributeFromTag(pos, tagEnd - pos, "idref", idref, sizeof(idref)); if (idref[0]) { // Look up in manifest for (int m = 0; m < manifestCount; m++) { if (strcmp(manifest[m].id, idref) == 0 && manifest[m].isContent) { // Build full path: baseDir + href int pathLen = strlen(baseDir) + strlen(manifest[m].href) + 1; char* fullPath = (char*)malloc(pathLen); if (fullPath) { snprintf(fullPath, pathLen, "%s%s", baseDir, manifest[m].href); chapterPaths[chapterCount++] = fullPath; } break; } } } pos = tagEnd; } } free(manifest); free(opfData); *outChapterPaths = chapterPaths; *outChapterCount = chapterCount; return chapterCount > 0; } // ---------------------------------------------------------- // Strip XHTML/HTML tags from raw content, producing plain text. // // Handles: // - Tag removal (everything between < and >) // -

,
,

,

-

→ newlines // - HTML entity decoding (& < > " ' &#NNN; &#xHH;) // - Collapse multiple whitespace/newlines // - Skip ,