2025-12-03 22:00:29 +11:00
|
|
|
#include "Epub.h"
|
|
|
|
|
|
2025-12-23 14:14:10 +11:00
|
|
|
#include <FsHelpers.h>
|
2026-03-07 21:22:19 -05:00
|
|
|
#include <HalDisplay.h>
|
2026-02-08 21:29:14 +01:00
|
|
|
#include <HalStorage.h>
|
2025-12-21 18:42:06 +11:00
|
|
|
#include <JpegToBmpConverter.h>
|
2026-02-13 12:16:39 +01:00
|
|
|
#include <Logging.h>
|
2026-02-16 06:56:13 -05:00
|
|
|
#include <PngToBmpConverter.h>
|
2025-12-03 22:00:29 +11:00
|
|
|
#include <ZipFile.h>
|
|
|
|
|
|
2025-12-13 19:36:01 +11:00
|
|
|
#include "Epub/parsers/ContainerParser.h"
|
|
|
|
|
#include "Epub/parsers/ContentOpfParser.h"
|
2026-01-03 16:10:35 +08:00
|
|
|
#include "Epub/parsers/TocNavParser.h"
|
2025-12-13 19:36:01 +11:00
|
|
|
#include "Epub/parsers/TocNcxParser.h"
|
2025-12-12 22:13:34 +11:00
|
|
|
|
2025-12-13 19:36:01 +11:00
|
|
|
bool Epub::findContentOpfFile(std::string* contentOpfFile) const {
|
|
|
|
|
const auto containerPath = "META-INF/container.xml";
|
|
|
|
|
size_t containerSize;
|
|
|
|
|
|
|
|
|
|
// Get file size without loading it all into heap
|
|
|
|
|
if (!getItemSize(containerPath, &containerSize)) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "Could not find or size META-INF/container.xml");
|
2025-12-03 22:00:29 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-13 19:36:01 +11:00
|
|
|
ContainerParser containerParser(containerSize);
|
2025-12-03 22:00:29 +11:00
|
|
|
|
2025-12-13 19:36:01 +11:00
|
|
|
if (!containerParser.setup()) {
|
2025-12-03 22:00:29 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-13 19:36:01 +11:00
|
|
|
// Stream read (reusing your existing stream logic)
|
|
|
|
|
if (!readItemContentsToStream(containerPath, containerParser, 512)) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "Could not read META-INF/container.xml");
|
2025-12-03 22:00:29 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-13 19:36:01 +11:00
|
|
|
// Extract the result
|
|
|
|
|
if (containerParser.fullPath.empty()) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "Could not find valid rootfile in container.xml");
|
2025-12-03 22:00:29 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-13 19:36:01 +11:00
|
|
|
*contentOpfFile = std::move(containerParser.fullPath);
|
|
|
|
|
return true;
|
2025-12-03 22:00:29 +11:00
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
|
|
|
|
|
std::string contentOpfFilePath;
|
|
|
|
|
if (!findContentOpfFile(&contentOpfFilePath)) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "Could not find content.opf in zip");
|
2025-12-24 22:36:13 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
contentBasePath = contentOpfFilePath.substr(0, contentOpfFilePath.find_last_of('/') + 1);
|
|
|
|
|
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "Parsing content.opf: %s", contentOpfFilePath.c_str());
|
2025-12-21 15:43:53 +11:00
|
|
|
|
2025-12-13 19:36:01 +11:00
|
|
|
size_t contentOpfSize;
|
|
|
|
|
if (!getItemSize(contentOpfFilePath, &contentOpfSize)) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "Could not get size of content.opf");
|
2025-12-03 22:00:29 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
ContentOpfParser opfParser(getCachePath(), getBasePath(), contentOpfSize, bookMetadataCache.get());
|
2025-12-13 19:36:01 +11:00
|
|
|
if (!opfParser.setup()) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "Could not setup content.opf parser");
|
2025-12-03 22:00:29 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-13 19:36:01 +11:00
|
|
|
if (!readItemContentsToStream(contentOpfFilePath, opfParser, 1024)) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "Could not read content.opf");
|
2025-12-03 22:00:29 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-13 19:36:01 +11:00
|
|
|
// Grab data from opfParser into epub
|
2025-12-24 22:36:13 +11:00
|
|
|
bookMetadata.title = opfParser.title;
|
2025-12-30 21:15:44 +10:00
|
|
|
bookMetadata.author = opfParser.author;
|
2026-01-19 17:56:26 +05:00
|
|
|
bookMetadata.language = opfParser.language;
|
2025-12-24 22:36:13 +11:00
|
|
|
bookMetadata.coverItemHref = opfParser.coverItemHref;
|
port: upstream PR #1342 - Book Info screen, richer metadata, safer controls
Ports upstream PR #1342 (feat: Add Book Info screen, richer metadata,
and safer file-browser controls) with mod-specific adaptations:
- Parse and cache series, seriesIndex, description from EPUB OPF
- Bump book.bin cache version to 6 for new metadata fields
- Add BookInfoActivity (new screen) accessible via Right button in FileBrowser
- Add ManageBook menu via Left button in FileBrowser (replaces upstream hidden delete)
- Guard all delete/archive actions with ConfirmationActivity (10 call sites)
- Add inputArmed gating to ConfirmationActivity to prevent accidental confirmation
- Safe deserialization: readString now returns bool with MAX_STRING_LENGTH guard
- Add series field to RecentBooksStore with JSON and binary serialization
- Add i18n keys: STR_BOOK_INFO, STR_AUTHOR, STR_SERIES, STR_FILE_SIZE, etc.
Made-with: Cursor
2026-03-09 00:39:32 -04:00
|
|
|
bookMetadata.series = opfParser.series;
|
|
|
|
|
bookMetadata.seriesIndex = opfParser.seriesIndex;
|
|
|
|
|
bookMetadata.description = opfParser.description;
|
2026-02-16 07:24:30 -05:00
|
|
|
|
|
|
|
|
// Guide-based cover fallback: if no cover found via metadata/properties,
|
|
|
|
|
// try extracting the image reference from the guide's cover page XHTML
|
|
|
|
|
if (bookMetadata.coverItemHref.empty() && !opfParser.guideCoverPageHref.empty()) {
|
|
|
|
|
LOG_DBG("EBP", "No cover from metadata, trying guide cover page: %s", opfParser.guideCoverPageHref.c_str());
|
|
|
|
|
size_t coverPageSize;
|
|
|
|
|
uint8_t* coverPageData = readItemContentsToBytes(opfParser.guideCoverPageHref, &coverPageSize, true);
|
|
|
|
|
if (coverPageData) {
|
|
|
|
|
const std::string coverPageHtml(reinterpret_cast<char*>(coverPageData), coverPageSize);
|
|
|
|
|
free(coverPageData);
|
|
|
|
|
|
|
|
|
|
// Determine base path of the cover page for resolving relative image references
|
|
|
|
|
std::string coverPageBase;
|
|
|
|
|
const auto lastSlash = opfParser.guideCoverPageHref.rfind('/');
|
|
|
|
|
if (lastSlash != std::string::npos) {
|
|
|
|
|
coverPageBase = opfParser.guideCoverPageHref.substr(0, lastSlash + 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Search for image references: xlink:href="..." (SVG) and src="..." (img)
|
|
|
|
|
std::string imageRef;
|
|
|
|
|
for (const char* pattern : {"xlink:href=\"", "src=\""}) {
|
|
|
|
|
auto pos = coverPageHtml.find(pattern);
|
|
|
|
|
while (pos != std::string::npos) {
|
|
|
|
|
pos += strlen(pattern);
|
|
|
|
|
const auto endPos = coverPageHtml.find('"', pos);
|
|
|
|
|
if (endPos != std::string::npos) {
|
2026-03-05 10:12:22 -06:00
|
|
|
const auto ref = std::string_view{coverPageHtml}.substr(pos, endPos - pos);
|
2026-02-16 07:24:30 -05:00
|
|
|
// Check if it's an image file
|
2026-03-05 10:12:22 -06:00
|
|
|
if (FsHelpers::hasPngExtension(ref) || FsHelpers::hasJpgExtension(ref) || FsHelpers::hasGifExtension(ref)) {
|
|
|
|
|
imageRef = ref;
|
|
|
|
|
break;
|
2026-02-16 07:24:30 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
pos = coverPageHtml.find(pattern, pos);
|
|
|
|
|
}
|
|
|
|
|
if (!imageRef.empty()) break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!imageRef.empty()) {
|
|
|
|
|
bookMetadata.coverItemHref = FsHelpers::normalisePath(coverPageBase + imageRef);
|
|
|
|
|
LOG_DBG("EBP", "Found cover image from guide: %s", bookMetadata.coverItemHref.c_str());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 13:02:46 +01:00
|
|
|
bookMetadata.textReferenceHref = opfParser.textReferenceHref;
|
2025-12-03 22:00:29 +11:00
|
|
|
|
2025-12-17 10:49:45 +03:00
|
|
|
if (!opfParser.tocNcxPath.empty()) {
|
|
|
|
|
tocNcxItem = opfParser.tocNcxPath;
|
2025-12-03 22:00:29 +11:00
|
|
|
}
|
|
|
|
|
|
2026-01-03 16:10:35 +08:00
|
|
|
if (!opfParser.tocNavPath.empty()) {
|
|
|
|
|
tocNavItem = opfParser.tocNavPath;
|
|
|
|
|
}
|
|
|
|
|
|
feat: Add CSS parsing and CSS support in EPUBs (#411)
## Summary
* **What is the goal of this PR?**
- Adds basic CSS parsing to EPUBs and determine the CSS rules when
rendering to the screen so that text is styled correctly. Currently
supports bold, underline, italics, margin, padding, and text alignment
## Additional Context
- My main reason for wanting this is that the book I'm currently
reading, Carl's Doomsday Scenario (2nd in the Dungeon Crawler Carl
series), relies _a lot_ on styled text for telling parts of the story.
When text is bolded, it's supposed to be a message that's rendered
"on-screen" in the story. When characters are "chatting" with each
other, the text is bolded and their names are underlined. Plus, normal
emphasis is provided with italicizing words here and there. So, this
greatly improves my experience reading this book on the Xteink, and I
figured it was useful enough for others too.
- For transparency: I'm a software engineer, but I'm mostly frontend and
TypeScript/JavaScript. It's been _years_ since I did any C/C++, so I
would not be surprised if I'm doing something dumb along the way in this
code. Please don't hesitate to ask for changes if something looks off. I
heavily relied on Claude Code for help, and I had a lot of inspiration
from how [microreader](https://github.com/CidVonHighwind/microreader)
achieves their CSS parsing and styling. I did give this as good of a
code review as I could and went through everything, and _it works on my
machine_ 😄
### Before


### After


---
### AI Usage
Did you use AI tools to help write this code? **YES**, Claude Code
2026-02-05 05:28:10 -05:00
|
|
|
if (!opfParser.cssFiles.empty()) {
|
|
|
|
|
cssFiles = opfParser.cssFiles;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "Successfully parsed content.opf");
|
2025-12-03 22:00:29 +11:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
bool Epub::parseTocNcxFile() const {
|
2025-12-03 22:00:29 +11:00
|
|
|
// the ncx file should have been specified in the content.opf file
|
|
|
|
|
if (tocNcxItem.empty()) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "No ncx file specified");
|
2025-12-03 22:00:29 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "Parsing toc ncx file: %s", tocNcxItem.c_str());
|
2025-12-21 15:43:53 +11:00
|
|
|
|
2025-12-21 17:08:34 +11:00
|
|
|
const auto tmpNcxPath = getCachePath() + "/toc.ncx";
|
2025-12-30 15:09:30 +10:00
|
|
|
FsFile tempNcxFile;
|
2026-02-08 21:29:14 +01:00
|
|
|
if (!Storage.openFileForWrite("EBP", tmpNcxPath, tempNcxFile)) {
|
2025-12-23 14:14:10 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
2025-12-21 17:08:34 +11:00
|
|
|
readItemContentsToStream(tocNcxItem, tempNcxFile, 1024);
|
|
|
|
|
tempNcxFile.close();
|
2026-02-08 21:29:14 +01:00
|
|
|
if (!Storage.openFileForRead("EBP", tmpNcxPath, tempNcxFile)) {
|
2025-12-23 14:14:10 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
2025-12-21 17:08:34 +11:00
|
|
|
const auto ncxSize = tempNcxFile.size();
|
2025-12-03 22:00:29 +11:00
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
TocNcxParser ncxParser(contentBasePath, ncxSize, bookMetadataCache.get());
|
2025-12-03 22:00:29 +11:00
|
|
|
|
2025-12-13 19:36:01 +11:00
|
|
|
if (!ncxParser.setup()) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "Could not setup toc ncx parser");
|
2025-12-30 23:18:51 +11:00
|
|
|
tempNcxFile.close();
|
2025-12-03 22:00:29 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-21 17:08:34 +11:00
|
|
|
const auto ncxBuffer = static_cast<uint8_t*>(malloc(1024));
|
|
|
|
|
if (!ncxBuffer) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "Could not allocate memory for toc ncx parser");
|
2025-12-30 23:18:51 +11:00
|
|
|
tempNcxFile.close();
|
2025-12-03 22:00:29 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-21 17:08:34 +11:00
|
|
|
while (tempNcxFile.available()) {
|
|
|
|
|
const auto readSize = tempNcxFile.read(ncxBuffer, 1024);
|
2025-12-30 23:18:51 +11:00
|
|
|
if (readSize == 0) break;
|
2025-12-21 17:08:34 +11:00
|
|
|
const auto processedSize = ncxParser.write(ncxBuffer, readSize);
|
|
|
|
|
|
|
|
|
|
if (processedSize != readSize) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "Could not process all toc ncx data");
|
2025-12-21 17:08:34 +11:00
|
|
|
free(ncxBuffer);
|
|
|
|
|
tempNcxFile.close();
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
free(ncxBuffer);
|
|
|
|
|
tempNcxFile.close();
|
2026-02-08 21:29:14 +01:00
|
|
|
Storage.remove(tmpNcxPath.c_str());
|
2025-12-21 17:08:34 +11:00
|
|
|
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "Parsed TOC items");
|
2025-12-13 19:36:01 +11:00
|
|
|
return true;
|
2025-12-03 22:00:29 +11:00
|
|
|
}
|
|
|
|
|
|
2026-01-03 16:10:35 +08:00
|
|
|
bool Epub::parseTocNavFile() const {
|
|
|
|
|
// the nav file should have been specified in the content.opf file (EPUB 3)
|
|
|
|
|
if (tocNavItem.empty()) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "No nav file specified");
|
2026-01-03 16:10:35 +08:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "Parsing toc nav file: %s", tocNavItem.c_str());
|
2026-01-03 16:10:35 +08:00
|
|
|
|
|
|
|
|
const auto tmpNavPath = getCachePath() + "/toc.nav";
|
|
|
|
|
FsFile tempNavFile;
|
2026-02-08 21:29:14 +01:00
|
|
|
if (!Storage.openFileForWrite("EBP", tmpNavPath, tempNavFile)) {
|
2026-01-03 16:10:35 +08:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
readItemContentsToStream(tocNavItem, tempNavFile, 1024);
|
|
|
|
|
tempNavFile.close();
|
2026-02-08 21:29:14 +01:00
|
|
|
if (!Storage.openFileForRead("EBP", tmpNavPath, tempNavFile)) {
|
2026-01-03 16:10:35 +08:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const auto navSize = tempNavFile.size();
|
|
|
|
|
|
2026-01-12 23:57:34 +10:00
|
|
|
// Note: We can't use `contentBasePath` here as the nav file may be in a different folder to the content.opf
|
|
|
|
|
// and the HTMLX nav file will have hrefs relative to itself
|
|
|
|
|
const std::string navContentBasePath = tocNavItem.substr(0, tocNavItem.find_last_of('/') + 1);
|
|
|
|
|
TocNavParser navParser(navContentBasePath, navSize, bookMetadataCache.get());
|
2026-01-03 16:10:35 +08:00
|
|
|
|
|
|
|
|
if (!navParser.setup()) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "Could not setup toc nav parser");
|
2026-01-03 16:10:35 +08:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const auto navBuffer = static_cast<uint8_t*>(malloc(1024));
|
|
|
|
|
if (!navBuffer) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "Could not allocate memory for toc nav parser");
|
2026-01-03 16:10:35 +08:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
while (tempNavFile.available()) {
|
|
|
|
|
const auto readSize = tempNavFile.read(navBuffer, 1024);
|
|
|
|
|
const auto processedSize = navParser.write(navBuffer, readSize);
|
|
|
|
|
|
|
|
|
|
if (processedSize != readSize) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "Could not process all toc nav data");
|
2026-01-03 16:10:35 +08:00
|
|
|
free(navBuffer);
|
|
|
|
|
tempNavFile.close();
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
free(navBuffer);
|
|
|
|
|
tempNavFile.close();
|
2026-02-08 21:29:14 +01:00
|
|
|
Storage.remove(tmpNavPath.c_str());
|
2026-01-03 16:10:35 +08:00
|
|
|
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "Parsed TOC nav items");
|
2026-01-03 16:10:35 +08:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
feat: Add CSS parsing and CSS support in EPUBs (#411)
## Summary
* **What is the goal of this PR?**
- Adds basic CSS parsing to EPUBs and determine the CSS rules when
rendering to the screen so that text is styled correctly. Currently
supports bold, underline, italics, margin, padding, and text alignment
## Additional Context
- My main reason for wanting this is that the book I'm currently
reading, Carl's Doomsday Scenario (2nd in the Dungeon Crawler Carl
series), relies _a lot_ on styled text for telling parts of the story.
When text is bolded, it's supposed to be a message that's rendered
"on-screen" in the story. When characters are "chatting" with each
other, the text is bolded and their names are underlined. Plus, normal
emphasis is provided with italicizing words here and there. So, this
greatly improves my experience reading this book on the Xteink, and I
figured it was useful enough for others too.
- For transparency: I'm a software engineer, but I'm mostly frontend and
TypeScript/JavaScript. It's been _years_ since I did any C/C++, so I
would not be surprised if I'm doing something dumb along the way in this
code. Please don't hesitate to ask for changes if something looks off. I
heavily relied on Claude Code for help, and I had a lot of inspiration
from how [microreader](https://github.com/CidVonHighwind/microreader)
achieves their CSS parsing and styling. I did give this as good of a
code review as I could and went through everything, and _it works on my
machine_ 😄
### Before


### After


---
### AI Usage
Did you use AI tools to help write this code? **YES**, Claude Code
2026-02-05 05:28:10 -05:00
|
|
|
void Epub::parseCssFiles() const {
|
2026-02-19 10:56:20 +00:00
|
|
|
// Maximum CSS file size we'll attempt to parse (uncompressed)
|
|
|
|
|
// Larger files risk memory exhaustion on ESP32
|
|
|
|
|
constexpr size_t MAX_CSS_FILE_SIZE = 128 * 1024; // 128KB
|
|
|
|
|
// Minimum heap required before attempting CSS parsing
|
|
|
|
|
constexpr size_t MIN_HEAP_FOR_CSS_PARSING = 64 * 1024; // 64KB
|
|
|
|
|
|
feat: Add CSS parsing and CSS support in EPUBs (#411)
## Summary
* **What is the goal of this PR?**
- Adds basic CSS parsing to EPUBs and determine the CSS rules when
rendering to the screen so that text is styled correctly. Currently
supports bold, underline, italics, margin, padding, and text alignment
## Additional Context
- My main reason for wanting this is that the book I'm currently
reading, Carl's Doomsday Scenario (2nd in the Dungeon Crawler Carl
series), relies _a lot_ on styled text for telling parts of the story.
When text is bolded, it's supposed to be a message that's rendered
"on-screen" in the story. When characters are "chatting" with each
other, the text is bolded and their names are underlined. Plus, normal
emphasis is provided with italicizing words here and there. So, this
greatly improves my experience reading this book on the Xteink, and I
figured it was useful enough for others too.
- For transparency: I'm a software engineer, but I'm mostly frontend and
TypeScript/JavaScript. It's been _years_ since I did any C/C++, so I
would not be surprised if I'm doing something dumb along the way in this
code. Please don't hesitate to ask for changes if something looks off. I
heavily relied on Claude Code for help, and I had a lot of inspiration
from how [microreader](https://github.com/CidVonHighwind/microreader)
achieves their CSS parsing and styling. I did give this as good of a
code review as I could and went through everything, and _it works on my
machine_ 😄
### Before


### After


---
### AI Usage
Did you use AI tools to help write this code? **YES**, Claude Code
2026-02-05 05:28:10 -05:00
|
|
|
if (cssFiles.empty()) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "No CSS files to parse, but CssParser created for inline styles");
|
feat: Add CSS parsing and CSS support in EPUBs (#411)
## Summary
* **What is the goal of this PR?**
- Adds basic CSS parsing to EPUBs and determine the CSS rules when
rendering to the screen so that text is styled correctly. Currently
supports bold, underline, italics, margin, padding, and text alignment
## Additional Context
- My main reason for wanting this is that the book I'm currently
reading, Carl's Doomsday Scenario (2nd in the Dungeon Crawler Carl
series), relies _a lot_ on styled text for telling parts of the story.
When text is bolded, it's supposed to be a message that's rendered
"on-screen" in the story. When characters are "chatting" with each
other, the text is bolded and their names are underlined. Plus, normal
emphasis is provided with italicizing words here and there. So, this
greatly improves my experience reading this book on the Xteink, and I
figured it was useful enough for others too.
- For transparency: I'm a software engineer, but I'm mostly frontend and
TypeScript/JavaScript. It's been _years_ since I did any C/C++, so I
would not be surprised if I'm doing something dumb along the way in this
code. Please don't hesitate to ask for changes if something looks off. I
heavily relied on Claude Code for help, and I had a lot of inspiration
from how [microreader](https://github.com/CidVonHighwind/microreader)
achieves their CSS parsing and styling. I did give this as good of a
code review as I could and went through everything, and _it works on my
machine_ 😄
### Before


### After


---
### AI Usage
Did you use AI tools to help write this code? **YES**, Claude Code
2026-02-05 05:28:10 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-20 17:04:50 +11:00
|
|
|
LOG_DBG("EBP", "CSS files to parse: %zu", cssFiles.size());
|
|
|
|
|
|
2026-02-15 12:22:42 -05:00
|
|
|
// See if we have a cached version of the CSS rules
|
2026-02-20 17:04:50 +11:00
|
|
|
if (cssParser->hasCache()) {
|
|
|
|
|
LOG_DBG("EBP", "CSS cache exists, skipping parseCssFiles");
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-19 10:56:20 +00:00
|
|
|
|
2026-02-20 17:04:50 +11:00
|
|
|
// No cache yet - parse CSS files
|
|
|
|
|
for (const auto& cssPath : cssFiles) {
|
|
|
|
|
LOG_DBG("EBP", "Parsing CSS file: %s", cssPath.c_str());
|
2026-02-19 10:56:20 +00:00
|
|
|
|
2026-02-20 17:04:50 +11:00
|
|
|
// Check heap before parsing - CSS parsing allocates heavily
|
|
|
|
|
const uint32_t freeHeap = ESP.getFreeHeap();
|
|
|
|
|
if (freeHeap < MIN_HEAP_FOR_CSS_PARSING) {
|
|
|
|
|
LOG_ERR("EBP", "Insufficient heap for CSS parsing (%u bytes free, need %zu), skipping: %s", freeHeap,
|
|
|
|
|
MIN_HEAP_FOR_CSS_PARSING, cssPath.c_str());
|
|
|
|
|
continue;
|
|
|
|
|
}
|
feat: Add CSS parsing and CSS support in EPUBs (#411)
## Summary
* **What is the goal of this PR?**
- Adds basic CSS parsing to EPUBs and determine the CSS rules when
rendering to the screen so that text is styled correctly. Currently
supports bold, underline, italics, margin, padding, and text alignment
## Additional Context
- My main reason for wanting this is that the book I'm currently
reading, Carl's Doomsday Scenario (2nd in the Dungeon Crawler Carl
series), relies _a lot_ on styled text for telling parts of the story.
When text is bolded, it's supposed to be a message that's rendered
"on-screen" in the story. When characters are "chatting" with each
other, the text is bolded and their names are underlined. Plus, normal
emphasis is provided with italicizing words here and there. So, this
greatly improves my experience reading this book on the Xteink, and I
figured it was useful enough for others too.
- For transparency: I'm a software engineer, but I'm mostly frontend and
TypeScript/JavaScript. It's been _years_ since I did any C/C++, so I
would not be surprised if I'm doing something dumb along the way in this
code. Please don't hesitate to ask for changes if something looks off. I
heavily relied on Claude Code for help, and I had a lot of inspiration
from how [microreader](https://github.com/CidVonHighwind/microreader)
achieves their CSS parsing and styling. I did give this as good of a
code review as I could and went through everything, and _it works on my
machine_ 😄
### Before


### After


---
### AI Usage
Did you use AI tools to help write this code? **YES**, Claude Code
2026-02-05 05:28:10 -05:00
|
|
|
|
2026-02-20 17:04:50 +11:00
|
|
|
// Check CSS file size before decompressing - skip files that are too large
|
|
|
|
|
size_t cssFileSize = 0;
|
|
|
|
|
if (getItemSize(cssPath, &cssFileSize)) {
|
|
|
|
|
if (cssFileSize > MAX_CSS_FILE_SIZE) {
|
|
|
|
|
LOG_ERR("EBP", "CSS file too large (%zu bytes > %zu max), skipping: %s", cssFileSize, MAX_CSS_FILE_SIZE,
|
|
|
|
|
cssPath.c_str());
|
feat: Add CSS parsing and CSS support in EPUBs (#411)
## Summary
* **What is the goal of this PR?**
- Adds basic CSS parsing to EPUBs and determine the CSS rules when
rendering to the screen so that text is styled correctly. Currently
supports bold, underline, italics, margin, padding, and text alignment
## Additional Context
- My main reason for wanting this is that the book I'm currently
reading, Carl's Doomsday Scenario (2nd in the Dungeon Crawler Carl
series), relies _a lot_ on styled text for telling parts of the story.
When text is bolded, it's supposed to be a message that's rendered
"on-screen" in the story. When characters are "chatting" with each
other, the text is bolded and their names are underlined. Plus, normal
emphasis is provided with italicizing words here and there. So, this
greatly improves my experience reading this book on the Xteink, and I
figured it was useful enough for others too.
- For transparency: I'm a software engineer, but I'm mostly frontend and
TypeScript/JavaScript. It's been _years_ since I did any C/C++, so I
would not be surprised if I'm doing something dumb along the way in this
code. Please don't hesitate to ask for changes if something looks off. I
heavily relied on Claude Code for help, and I had a lot of inspiration
from how [microreader](https://github.com/CidVonHighwind/microreader)
achieves their CSS parsing and styling. I did give this as good of a
code review as I could and went through everything, and _it works on my
machine_ 😄
### Before


### After


---
### AI Usage
Did you use AI tools to help write this code? **YES**, Claude Code
2026-02-05 05:28:10 -05:00
|
|
|
continue;
|
|
|
|
|
}
|
2026-02-20 17:04:50 +11:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Extract CSS file to temp location
|
|
|
|
|
const auto tmpCssPath = getCachePath() + "/.tmp.css";
|
|
|
|
|
FsFile tempCssFile;
|
|
|
|
|
if (!Storage.openFileForWrite("EBP", tmpCssPath, tempCssFile)) {
|
|
|
|
|
LOG_ERR("EBP", "Could not create temp CSS file");
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (!readItemContentsToStream(cssPath, tempCssFile, 1024)) {
|
|
|
|
|
LOG_ERR("EBP", "Could not read CSS file: %s", cssPath.c_str());
|
feat: Add CSS parsing and CSS support in EPUBs (#411)
## Summary
* **What is the goal of this PR?**
- Adds basic CSS parsing to EPUBs and determine the CSS rules when
rendering to the screen so that text is styled correctly. Currently
supports bold, underline, italics, margin, padding, and text alignment
## Additional Context
- My main reason for wanting this is that the book I'm currently
reading, Carl's Doomsday Scenario (2nd in the Dungeon Crawler Carl
series), relies _a lot_ on styled text for telling parts of the story.
When text is bolded, it's supposed to be a message that's rendered
"on-screen" in the story. When characters are "chatting" with each
other, the text is bolded and their names are underlined. Plus, normal
emphasis is provided with italicizing words here and there. So, this
greatly improves my experience reading this book on the Xteink, and I
figured it was useful enough for others too.
- For transparency: I'm a software engineer, but I'm mostly frontend and
TypeScript/JavaScript. It's been _years_ since I did any C/C++, so I
would not be surprised if I'm doing something dumb along the way in this
code. Please don't hesitate to ask for changes if something looks off. I
heavily relied on Claude Code for help, and I had a lot of inspiration
from how [microreader](https://github.com/CidVonHighwind/microreader)
achieves their CSS parsing and styling. I did give this as good of a
code review as I could and went through everything, and _it works on my
machine_ 😄
### Before


### After


---
### AI Usage
Did you use AI tools to help write this code? **YES**, Claude Code
2026-02-05 05:28:10 -05:00
|
|
|
tempCssFile.close();
|
2026-02-08 21:29:14 +01:00
|
|
|
Storage.remove(tmpCssPath.c_str());
|
2026-02-20 17:04:50 +11:00
|
|
|
continue;
|
feat: Add CSS parsing and CSS support in EPUBs (#411)
## Summary
* **What is the goal of this PR?**
- Adds basic CSS parsing to EPUBs and determine the CSS rules when
rendering to the screen so that text is styled correctly. Currently
supports bold, underline, italics, margin, padding, and text alignment
## Additional Context
- My main reason for wanting this is that the book I'm currently
reading, Carl's Doomsday Scenario (2nd in the Dungeon Crawler Carl
series), relies _a lot_ on styled text for telling parts of the story.
When text is bolded, it's supposed to be a message that's rendered
"on-screen" in the story. When characters are "chatting" with each
other, the text is bolded and their names are underlined. Plus, normal
emphasis is provided with italicizing words here and there. So, this
greatly improves my experience reading this book on the Xteink, and I
figured it was useful enough for others too.
- For transparency: I'm a software engineer, but I'm mostly frontend and
TypeScript/JavaScript. It's been _years_ since I did any C/C++, so I
would not be surprised if I'm doing something dumb along the way in this
code. Please don't hesitate to ask for changes if something looks off. I
heavily relied on Claude Code for help, and I had a lot of inspiration
from how [microreader](https://github.com/CidVonHighwind/microreader)
achieves their CSS parsing and styling. I did give this as good of a
code review as I could and went through everything, and _it works on my
machine_ 😄
### Before


### After


---
### AI Usage
Did you use AI tools to help write this code? **YES**, Claude Code
2026-02-05 05:28:10 -05:00
|
|
|
}
|
2026-02-20 17:04:50 +11:00
|
|
|
tempCssFile.close();
|
feat: Add CSS parsing and CSS support in EPUBs (#411)
## Summary
* **What is the goal of this PR?**
- Adds basic CSS parsing to EPUBs and determine the CSS rules when
rendering to the screen so that text is styled correctly. Currently
supports bold, underline, italics, margin, padding, and text alignment
## Additional Context
- My main reason for wanting this is that the book I'm currently
reading, Carl's Doomsday Scenario (2nd in the Dungeon Crawler Carl
series), relies _a lot_ on styled text for telling parts of the story.
When text is bolded, it's supposed to be a message that's rendered
"on-screen" in the story. When characters are "chatting" with each
other, the text is bolded and their names are underlined. Plus, normal
emphasis is provided with italicizing words here and there. So, this
greatly improves my experience reading this book on the Xteink, and I
figured it was useful enough for others too.
- For transparency: I'm a software engineer, but I'm mostly frontend and
TypeScript/JavaScript. It's been _years_ since I did any C/C++, so I
would not be surprised if I'm doing something dumb along the way in this
code. Please don't hesitate to ask for changes if something looks off. I
heavily relied on Claude Code for help, and I had a lot of inspiration
from how [microreader](https://github.com/CidVonHighwind/microreader)
achieves their CSS parsing and styling. I did give this as good of a
code review as I could and went through everything, and _it works on my
machine_ 😄
### Before


### After


---
### AI Usage
Did you use AI tools to help write this code? **YES**, Claude Code
2026-02-05 05:28:10 -05:00
|
|
|
|
2026-02-20 17:04:50 +11:00
|
|
|
// Parse the CSS file
|
|
|
|
|
if (!Storage.openFileForRead("EBP", tmpCssPath, tempCssFile)) {
|
|
|
|
|
LOG_ERR("EBP", "Could not open temp CSS file for reading");
|
|
|
|
|
Storage.remove(tmpCssPath.c_str());
|
|
|
|
|
continue;
|
feat: Add CSS parsing and CSS support in EPUBs (#411)
## Summary
* **What is the goal of this PR?**
- Adds basic CSS parsing to EPUBs and determine the CSS rules when
rendering to the screen so that text is styled correctly. Currently
supports bold, underline, italics, margin, padding, and text alignment
## Additional Context
- My main reason for wanting this is that the book I'm currently
reading, Carl's Doomsday Scenario (2nd in the Dungeon Crawler Carl
series), relies _a lot_ on styled text for telling parts of the story.
When text is bolded, it's supposed to be a message that's rendered
"on-screen" in the story. When characters are "chatting" with each
other, the text is bolded and their names are underlined. Plus, normal
emphasis is provided with italicizing words here and there. So, this
greatly improves my experience reading this book on the Xteink, and I
figured it was useful enough for others too.
- For transparency: I'm a software engineer, but I'm mostly frontend and
TypeScript/JavaScript. It's been _years_ since I did any C/C++, so I
would not be surprised if I'm doing something dumb along the way in this
code. Please don't hesitate to ask for changes if something looks off. I
heavily relied on Claude Code for help, and I had a lot of inspiration
from how [microreader](https://github.com/CidVonHighwind/microreader)
achieves their CSS parsing and styling. I did give this as good of a
code review as I could and went through everything, and _it works on my
machine_ 😄
### Before


### After


---
### AI Usage
Did you use AI tools to help write this code? **YES**, Claude Code
2026-02-05 05:28:10 -05:00
|
|
|
}
|
2026-02-20 17:04:50 +11:00
|
|
|
cssParser->loadFromStream(tempCssFile);
|
|
|
|
|
tempCssFile.close();
|
|
|
|
|
Storage.remove(tmpCssPath.c_str());
|
|
|
|
|
}
|
feat: Add CSS parsing and CSS support in EPUBs (#411)
## Summary
* **What is the goal of this PR?**
- Adds basic CSS parsing to EPUBs and determine the CSS rules when
rendering to the screen so that text is styled correctly. Currently
supports bold, underline, italics, margin, padding, and text alignment
## Additional Context
- My main reason for wanting this is that the book I'm currently
reading, Carl's Doomsday Scenario (2nd in the Dungeon Crawler Carl
series), relies _a lot_ on styled text for telling parts of the story.
When text is bolded, it's supposed to be a message that's rendered
"on-screen" in the story. When characters are "chatting" with each
other, the text is bolded and their names are underlined. Plus, normal
emphasis is provided with italicizing words here and there. So, this
greatly improves my experience reading this book on the Xteink, and I
figured it was useful enough for others too.
- For transparency: I'm a software engineer, but I'm mostly frontend and
TypeScript/JavaScript. It's been _years_ since I did any C/C++, so I
would not be surprised if I'm doing something dumb along the way in this
code. Please don't hesitate to ask for changes if something looks off. I
heavily relied on Claude Code for help, and I had a lot of inspiration
from how [microreader](https://github.com/CidVonHighwind/microreader)
achieves their CSS parsing and styling. I did give this as good of a
code review as I could and went through everything, and _it works on my
machine_ 😄
### Before


### After


---
### AI Usage
Did you use AI tools to help write this code? **YES**, Claude Code
2026-02-05 05:28:10 -05:00
|
|
|
|
2026-02-20 17:04:50 +11:00
|
|
|
// Save to cache for next time
|
|
|
|
|
if (!cssParser->saveToCache()) {
|
|
|
|
|
LOG_ERR("EBP", "Failed to save CSS rules to cache");
|
feat: Add CSS parsing and CSS support in EPUBs (#411)
## Summary
* **What is the goal of this PR?**
- Adds basic CSS parsing to EPUBs and determine the CSS rules when
rendering to the screen so that text is styled correctly. Currently
supports bold, underline, italics, margin, padding, and text alignment
## Additional Context
- My main reason for wanting this is that the book I'm currently
reading, Carl's Doomsday Scenario (2nd in the Dungeon Crawler Carl
series), relies _a lot_ on styled text for telling parts of the story.
When text is bolded, it's supposed to be a message that's rendered
"on-screen" in the story. When characters are "chatting" with each
other, the text is bolded and their names are underlined. Plus, normal
emphasis is provided with italicizing words here and there. So, this
greatly improves my experience reading this book on the Xteink, and I
figured it was useful enough for others too.
- For transparency: I'm a software engineer, but I'm mostly frontend and
TypeScript/JavaScript. It's been _years_ since I did any C/C++, so I
would not be surprised if I'm doing something dumb along the way in this
code. Please don't hesitate to ask for changes if something looks off. I
heavily relied on Claude Code for help, and I had a lot of inspiration
from how [microreader](https://github.com/CidVonHighwind/microreader)
achieves their CSS parsing and styling. I did give this as good of a
code review as I could and went through everything, and _it works on my
machine_ 😄
### Before


### After


---
### AI Usage
Did you use AI tools to help write this code? **YES**, Claude Code
2026-02-05 05:28:10 -05:00
|
|
|
}
|
2026-02-20 17:04:50 +11:00
|
|
|
cssParser->clear();
|
|
|
|
|
|
|
|
|
|
LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size());
|
feat: Add CSS parsing and CSS support in EPUBs (#411)
## Summary
* **What is the goal of this PR?**
- Adds basic CSS parsing to EPUBs and determine the CSS rules when
rendering to the screen so that text is styled correctly. Currently
supports bold, underline, italics, margin, padding, and text alignment
## Additional Context
- My main reason for wanting this is that the book I'm currently
reading, Carl's Doomsday Scenario (2nd in the Dungeon Crawler Carl
series), relies _a lot_ on styled text for telling parts of the story.
When text is bolded, it's supposed to be a message that's rendered
"on-screen" in the story. When characters are "chatting" with each
other, the text is bolded and their names are underlined. Plus, normal
emphasis is provided with italicizing words here and there. So, this
greatly improves my experience reading this book on the Xteink, and I
figured it was useful enough for others too.
- For transparency: I'm a software engineer, but I'm mostly frontend and
TypeScript/JavaScript. It's been _years_ since I did any C/C++, so I
would not be surprised if I'm doing something dumb along the way in this
code. Please don't hesitate to ask for changes if something looks off. I
heavily relied on Claude Code for help, and I had a lot of inspiration
from how [microreader](https://github.com/CidVonHighwind/microreader)
achieves their CSS parsing and styling. I did give this as good of a
code review as I could and went through everything, and _it works on my
machine_ 😄
### Before


### After


---
### AI Usage
Did you use AI tools to help write this code? **YES**, Claude Code
2026-02-05 05:28:10 -05:00
|
|
|
}
|
|
|
|
|
|
2025-12-03 22:00:29 +11:00
|
|
|
// load in the meta data for the epub file
|
feat: Add CSS parsing and CSS support in EPUBs (#411)
## Summary
* **What is the goal of this PR?**
- Adds basic CSS parsing to EPUBs and determine the CSS rules when
rendering to the screen so that text is styled correctly. Currently
supports bold, underline, italics, margin, padding, and text alignment
## Additional Context
- My main reason for wanting this is that the book I'm currently
reading, Carl's Doomsday Scenario (2nd in the Dungeon Crawler Carl
series), relies _a lot_ on styled text for telling parts of the story.
When text is bolded, it's supposed to be a message that's rendered
"on-screen" in the story. When characters are "chatting" with each
other, the text is bolded and their names are underlined. Plus, normal
emphasis is provided with italicizing words here and there. So, this
greatly improves my experience reading this book on the Xteink, and I
figured it was useful enough for others too.
- For transparency: I'm a software engineer, but I'm mostly frontend and
TypeScript/JavaScript. It's been _years_ since I did any C/C++, so I
would not be surprised if I'm doing something dumb along the way in this
code. Please don't hesitate to ask for changes if something looks off. I
heavily relied on Claude Code for help, and I had a lot of inspiration
from how [microreader](https://github.com/CidVonHighwind/microreader)
achieves their CSS parsing and styling. I did give this as good of a
code review as I could and went through everything, and _it works on my
machine_ 😄
### Before


### After


---
### AI Usage
Did you use AI tools to help write this code? **YES**, Claude Code
2026-02-05 05:28:10 -05:00
|
|
|
bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "Loading ePub: %s", filepath.c_str());
|
2025-12-03 22:00:29 +11:00
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
// Initialize spine/TOC cache
|
|
|
|
|
bookMetadataCache.reset(new BookMetadataCache(cachePath));
|
feat: Add CSS parsing and CSS support in EPUBs (#411)
## Summary
* **What is the goal of this PR?**
- Adds basic CSS parsing to EPUBs and determine the CSS rules when
rendering to the screen so that text is styled correctly. Currently
supports bold, underline, italics, margin, padding, and text alignment
## Additional Context
- My main reason for wanting this is that the book I'm currently
reading, Carl's Doomsday Scenario (2nd in the Dungeon Crawler Carl
series), relies _a lot_ on styled text for telling parts of the story.
When text is bolded, it's supposed to be a message that's rendered
"on-screen" in the story. When characters are "chatting" with each
other, the text is bolded and their names are underlined. Plus, normal
emphasis is provided with italicizing words here and there. So, this
greatly improves my experience reading this book on the Xteink, and I
figured it was useful enough for others too.
- For transparency: I'm a software engineer, but I'm mostly frontend and
TypeScript/JavaScript. It's been _years_ since I did any C/C++, so I
would not be surprised if I'm doing something dumb along the way in this
code. Please don't hesitate to ask for changes if something looks off. I
heavily relied on Claude Code for help, and I had a lot of inspiration
from how [microreader](https://github.com/CidVonHighwind/microreader)
achieves their CSS parsing and styling. I did give this as good of a
code review as I could and went through everything, and _it works on my
machine_ 😄
### Before


### After


---
### AI Usage
Did you use AI tools to help write this code? **YES**, Claude Code
2026-02-05 05:28:10 -05:00
|
|
|
// Always create CssParser - needed for inline style parsing even without CSS files
|
2026-02-15 12:22:42 -05:00
|
|
|
cssParser.reset(new CssParser(cachePath));
|
2025-12-24 22:36:13 +11:00
|
|
|
|
|
|
|
|
// Try to load existing cache first
|
|
|
|
|
if (bookMetadataCache->load()) {
|
2026-02-19 21:34:28 -08:00
|
|
|
if (!skipLoadingCss) {
|
|
|
|
|
// Rebuild CSS cache when missing or when cache version changed (loadFromCache removes stale file)
|
2026-02-20 17:04:50 +11:00
|
|
|
if (!cssParser->hasCache() || !cssParser->loadFromCache()) {
|
2026-02-19 21:34:28 -08:00
|
|
|
LOG_DBG("EBP", "CSS rules cache missing or stale, attempting to parse CSS files");
|
2026-02-20 17:04:50 +11:00
|
|
|
cssParser->deleteCache();
|
|
|
|
|
|
2026-02-19 21:34:28 -08:00
|
|
|
if (!parseContentOpf(bookMetadataCache->coreMetadata)) {
|
|
|
|
|
LOG_ERR("EBP", "Could not parse content.opf from cached bookMetadata for CSS files");
|
|
|
|
|
// continue anyway - book will work without CSS and we'll still load any inline style CSS
|
|
|
|
|
}
|
|
|
|
|
parseCssFiles();
|
|
|
|
|
// Invalidate section caches so they are rebuilt with the new CSS
|
|
|
|
|
Storage.removeDir((cachePath + "/sections").c_str());
|
feat: Add CSS parsing and CSS support in EPUBs (#411)
## Summary
* **What is the goal of this PR?**
- Adds basic CSS parsing to EPUBs and determine the CSS rules when
rendering to the screen so that text is styled correctly. Currently
supports bold, underline, italics, margin, padding, and text alignment
## Additional Context
- My main reason for wanting this is that the book I'm currently
reading, Carl's Doomsday Scenario (2nd in the Dungeon Crawler Carl
series), relies _a lot_ on styled text for telling parts of the story.
When text is bolded, it's supposed to be a message that's rendered
"on-screen" in the story. When characters are "chatting" with each
other, the text is bolded and their names are underlined. Plus, normal
emphasis is provided with italicizing words here and there. So, this
greatly improves my experience reading this book on the Xteink, and I
figured it was useful enough for others too.
- For transparency: I'm a software engineer, but I'm mostly frontend and
TypeScript/JavaScript. It's been _years_ since I did any C/C++, so I
would not be surprised if I'm doing something dumb along the way in this
code. Please don't hesitate to ask for changes if something looks off. I
heavily relied on Claude Code for help, and I had a lot of inspiration
from how [microreader](https://github.com/CidVonHighwind/microreader)
achieves their CSS parsing and styling. I did give this as good of a
code review as I could and went through everything, and _it works on my
machine_ 😄
### Before


### After


---
### AI Usage
Did you use AI tools to help write this code? **YES**, Claude Code
2026-02-05 05:28:10 -05:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str());
|
2025-12-24 22:36:13 +11:00
|
|
|
return true;
|
2025-12-03 22:00:29 +11:00
|
|
|
}
|
|
|
|
|
|
2025-12-30 22:18:10 +10:00
|
|
|
// If we didn't load from cache above and we aren't allowed to build, fail now
|
|
|
|
|
if (!buildIfMissing) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
// Cache doesn't exist or is invalid, build it
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "Cache not found, building spine/TOC cache");
|
2025-12-24 22:36:13 +11:00
|
|
|
setupCacheDir();
|
2025-12-13 19:36:01 +11:00
|
|
|
|
perf: optimize large EPUB indexing from O(n^2) to O(n) (#458)
## Summary
Optimizes EPUB metadata indexing for large books (2000+ chapters) from
~30 minutes to ~50 seconds by replacing O(n²) algorithms with O(n log n)
hash-indexed lookups.
Fixes #134
## Problem
Three phases had O(n²) complexity due to nested loops:
| Phase | Operation | Before (2768 chapters) |
|-------|-----------|------------------------|
| OPF Pass | For each spine ref, scan all manifest items | ~25 min |
| TOC Pass | For each TOC entry, scan all spine items | ~5 min |
| buildBookBin | For each spine item, scan ZIP central directory | ~8.4
min |
Total: **~30+ minutes** for first-time indexing of large EPUBs.
## Solution
Replace linear scans with sorted hash indexes + binary search:
- **OPF Pass**: Build `{hash(id), len, offset}` index from manifest,
binary search for each spine ref
- **TOC Pass**: Build `{hash(href), len, spineIndex}` index from spine,
binary search for each TOC entry
- **buildBookBin**: New `ZipFile::fillUncompressedSizes()` API - single
ZIP central directory scan with batch hash matching
All indexes use FNV-1a hashing with length as secondary key to minimize
collisions. Indexes are freed immediately after each phase.
## Results
**Shadow Slave EPUB (2768 chapters):**
| Phase | Before | After | Speedup |
|-------|--------|-------|---------|
| OPF pass | ~25 min | 10.8 sec | ~140x |
| TOC pass | ~5 min | 4.7 sec | ~60x |
| buildBookBin | 506 sec | 34.6 sec | ~15x |
| **Total** | **~30+ min** | **~50 sec** | **~36x** |
**Normal EPUB (87 chapters):** 1.7 sec - no regression.
## Memory
Peak temporary memory during indexing:
- OPF index: ~33KB (2770 items × 12 bytes)
- TOC index: ~33KB (2768 items × 12 bytes)
- ZIP batch: ~44KB (targets + sizes arrays)
All indexes cleared immediately after each phase. No OOM risk on
ESP32-C3.
## Note on Threshold
All optimizations are gated by `LARGE_SPINE_THRESHOLD = 400` to preserve
existing behavior for small books. However, the algorithms work
correctly for any book size and are faster even for small books:
| Book Size | Old O(n²) | New O(n log n) | Improvement |
|-----------|-----------|----------------|-------------|
| 10 ch | 100 ops | 50 ops | 2x |
| 100 ch | 10K ops | 800 ops | 12x |
| 400 ch | 160K ops | 4K ops | 40x |
If preferred, the threshold could be removed to use the optimized path
universally.
## Testing
- [x] Shadow Slave (2768 chapters): 50s first-time indexing, loads and
navigates correctly
- [x] Normal book (87 chapters): 1.7s indexing, no regression
- [x] Build passes
- [x] clang-format passes
## Files Changed
- `lib/Epub/Epub/parsers/ContentOpfParser.h/.cpp` - OPF manifest index
- `lib/Epub/Epub/BookMetadataCache.h/.cpp` - TOC index + batch size
lookup
- `lib/ZipFile/ZipFile.h/.cpp` - New `fillUncompressedSizes()` API
- `lib/Epub/Epub.cpp` - Timing logs
<details>
<summary><b>Algorithm Details</b> (click to expand)</summary>
### Phase 1: OPF Pass - Manifest to Spine Lookup
**Problem**: Each `<itemref idref="ch001">` in spine must find matching
`<item id="ch001" href="...">` in manifest.
```
OLD: For each of 2768 spine refs, scan all 2770 manifest items
= 7.6M string comparisons
NEW: While parsing manifest, build index:
{ hash("ch001"), len=5, file_offset=120 }
Sort index, then binary search for each spine ref:
2768 × log₂(2770) ≈ 2768 × 11 = 30K comparisons
```
### Phase 2: TOC Pass - TOC Entry to Spine Index Lookup
**Problem**: Each TOC entry with `href="chapter0001.xhtml"` must find
its spine index.
```
OLD: For each of 2768 TOC entries, scan all 2768 spine entries
= 7.6M string comparisons
NEW: At beginTocPass(), read spine once and build index:
{ hash("OEBPS/chapter0001.xhtml"), len=25, spineIndex=0 }
Sort index, binary search for each TOC entry:
2768 × log₂(2768) ≈ 30K comparisons
Clear index at endTocPass() to free memory.
```
### Phase 3: buildBookBin - ZIP Size Lookup
**Problem**: Need uncompressed file size for each spine item (for
reading progress). Sizes are in ZIP central directory.
```
OLD: For each of 2768 spine items, scan ZIP central directory (2773 entries)
= 7.6M filename reads + string comparisons
Time: 506 seconds
NEW:
Step 1: Build targets from spine
{ hash("OEBPS/chapter0001.xhtml"), len=25, index=0 }
Sort by (hash, len)
Step 2: Single pass through ZIP central directory
For each entry:
- Compute hash ON THE FLY (no string allocation)
- Binary search targets
- If match: sizes[target.index] = uncompressedSize
Step 3: Use sizes array directly (O(1) per spine item)
Total: 2773 entries × log₂(2768) ≈ 33K comparisons
Time: 35 seconds
```
### Why Hash + Length?
Using 64-bit FNV-1a hash + string length as a composite key:
- Collision probability: ~1 in 2⁶⁴ × typical_path_lengths
- No string storage needed in index (just 12-16 bytes per entry)
- Integer comparisons are faster than string comparisons
- Verification on match handles the rare collision case
</details>
---
_AI-assisted development. All changes tested on hardware._
2026-01-27 06:29:15 -08:00
|
|
|
const uint32_t indexingStart = millis();
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
// Begin building cache - stream entries to disk immediately
|
|
|
|
|
if (!bookMetadataCache->beginWrite()) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "Could not begin writing cache");
|
2025-12-24 22:36:13 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
2025-12-03 22:00:29 +11:00
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
// OPF Pass
|
perf: optimize large EPUB indexing from O(n^2) to O(n) (#458)
## Summary
Optimizes EPUB metadata indexing for large books (2000+ chapters) from
~30 minutes to ~50 seconds by replacing O(n²) algorithms with O(n log n)
hash-indexed lookups.
Fixes #134
## Problem
Three phases had O(n²) complexity due to nested loops:
| Phase | Operation | Before (2768 chapters) |
|-------|-----------|------------------------|
| OPF Pass | For each spine ref, scan all manifest items | ~25 min |
| TOC Pass | For each TOC entry, scan all spine items | ~5 min |
| buildBookBin | For each spine item, scan ZIP central directory | ~8.4
min |
Total: **~30+ minutes** for first-time indexing of large EPUBs.
## Solution
Replace linear scans with sorted hash indexes + binary search:
- **OPF Pass**: Build `{hash(id), len, offset}` index from manifest,
binary search for each spine ref
- **TOC Pass**: Build `{hash(href), len, spineIndex}` index from spine,
binary search for each TOC entry
- **buildBookBin**: New `ZipFile::fillUncompressedSizes()` API - single
ZIP central directory scan with batch hash matching
All indexes use FNV-1a hashing with length as secondary key to minimize
collisions. Indexes are freed immediately after each phase.
## Results
**Shadow Slave EPUB (2768 chapters):**
| Phase | Before | After | Speedup |
|-------|--------|-------|---------|
| OPF pass | ~25 min | 10.8 sec | ~140x |
| TOC pass | ~5 min | 4.7 sec | ~60x |
| buildBookBin | 506 sec | 34.6 sec | ~15x |
| **Total** | **~30+ min** | **~50 sec** | **~36x** |
**Normal EPUB (87 chapters):** 1.7 sec - no regression.
## Memory
Peak temporary memory during indexing:
- OPF index: ~33KB (2770 items × 12 bytes)
- TOC index: ~33KB (2768 items × 12 bytes)
- ZIP batch: ~44KB (targets + sizes arrays)
All indexes cleared immediately after each phase. No OOM risk on
ESP32-C3.
## Note on Threshold
All optimizations are gated by `LARGE_SPINE_THRESHOLD = 400` to preserve
existing behavior for small books. However, the algorithms work
correctly for any book size and are faster even for small books:
| Book Size | Old O(n²) | New O(n log n) | Improvement |
|-----------|-----------|----------------|-------------|
| 10 ch | 100 ops | 50 ops | 2x |
| 100 ch | 10K ops | 800 ops | 12x |
| 400 ch | 160K ops | 4K ops | 40x |
If preferred, the threshold could be removed to use the optimized path
universally.
## Testing
- [x] Shadow Slave (2768 chapters): 50s first-time indexing, loads and
navigates correctly
- [x] Normal book (87 chapters): 1.7s indexing, no regression
- [x] Build passes
- [x] clang-format passes
## Files Changed
- `lib/Epub/Epub/parsers/ContentOpfParser.h/.cpp` - OPF manifest index
- `lib/Epub/Epub/BookMetadataCache.h/.cpp` - TOC index + batch size
lookup
- `lib/ZipFile/ZipFile.h/.cpp` - New `fillUncompressedSizes()` API
- `lib/Epub/Epub.cpp` - Timing logs
<details>
<summary><b>Algorithm Details</b> (click to expand)</summary>
### Phase 1: OPF Pass - Manifest to Spine Lookup
**Problem**: Each `<itemref idref="ch001">` in spine must find matching
`<item id="ch001" href="...">` in manifest.
```
OLD: For each of 2768 spine refs, scan all 2770 manifest items
= 7.6M string comparisons
NEW: While parsing manifest, build index:
{ hash("ch001"), len=5, file_offset=120 }
Sort index, then binary search for each spine ref:
2768 × log₂(2770) ≈ 2768 × 11 = 30K comparisons
```
### Phase 2: TOC Pass - TOC Entry to Spine Index Lookup
**Problem**: Each TOC entry with `href="chapter0001.xhtml"` must find
its spine index.
```
OLD: For each of 2768 TOC entries, scan all 2768 spine entries
= 7.6M string comparisons
NEW: At beginTocPass(), read spine once and build index:
{ hash("OEBPS/chapter0001.xhtml"), len=25, spineIndex=0 }
Sort index, binary search for each TOC entry:
2768 × log₂(2768) ≈ 30K comparisons
Clear index at endTocPass() to free memory.
```
### Phase 3: buildBookBin - ZIP Size Lookup
**Problem**: Need uncompressed file size for each spine item (for
reading progress). Sizes are in ZIP central directory.
```
OLD: For each of 2768 spine items, scan ZIP central directory (2773 entries)
= 7.6M filename reads + string comparisons
Time: 506 seconds
NEW:
Step 1: Build targets from spine
{ hash("OEBPS/chapter0001.xhtml"), len=25, index=0 }
Sort by (hash, len)
Step 2: Single pass through ZIP central directory
For each entry:
- Compute hash ON THE FLY (no string allocation)
- Binary search targets
- If match: sizes[target.index] = uncompressedSize
Step 3: Use sizes array directly (O(1) per spine item)
Total: 2773 entries × log₂(2768) ≈ 33K comparisons
Time: 35 seconds
```
### Why Hash + Length?
Using 64-bit FNV-1a hash + string length as a composite key:
- Collision probability: ~1 in 2⁶⁴ × typical_path_lengths
- No string storage needed in index (just 12-16 bytes per entry)
- Integer comparisons are faster than string comparisons
- Verification on match handles the rare collision case
</details>
---
_AI-assisted development. All changes tested on hardware._
2026-01-27 06:29:15 -08:00
|
|
|
const uint32_t opfStart = millis();
|
2025-12-24 22:36:13 +11:00
|
|
|
BookMetadataCache::BookMetadata bookMetadata;
|
|
|
|
|
if (!bookMetadataCache->beginContentOpfPass()) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "Could not begin writing content.opf pass");
|
2025-12-24 22:36:13 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (!parseContentOpf(bookMetadata)) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "Could not parse content.opf");
|
2025-12-03 22:00:29 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
2025-12-24 22:36:13 +11:00
|
|
|
if (!bookMetadataCache->endContentOpfPass()) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "Could not end writing content.opf pass");
|
2025-12-24 22:36:13 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "OPF pass completed in %lu ms", millis() - opfStart);
|
2025-12-03 22:00:29 +11:00
|
|
|
|
2026-01-03 16:10:35 +08:00
|
|
|
// TOC Pass - try EPUB 3 nav first, fall back to NCX
|
perf: optimize large EPUB indexing from O(n^2) to O(n) (#458)
## Summary
Optimizes EPUB metadata indexing for large books (2000+ chapters) from
~30 minutes to ~50 seconds by replacing O(n²) algorithms with O(n log n)
hash-indexed lookups.
Fixes #134
## Problem
Three phases had O(n²) complexity due to nested loops:
| Phase | Operation | Before (2768 chapters) |
|-------|-----------|------------------------|
| OPF Pass | For each spine ref, scan all manifest items | ~25 min |
| TOC Pass | For each TOC entry, scan all spine items | ~5 min |
| buildBookBin | For each spine item, scan ZIP central directory | ~8.4
min |
Total: **~30+ minutes** for first-time indexing of large EPUBs.
## Solution
Replace linear scans with sorted hash indexes + binary search:
- **OPF Pass**: Build `{hash(id), len, offset}` index from manifest,
binary search for each spine ref
- **TOC Pass**: Build `{hash(href), len, spineIndex}` index from spine,
binary search for each TOC entry
- **buildBookBin**: New `ZipFile::fillUncompressedSizes()` API - single
ZIP central directory scan with batch hash matching
All indexes use FNV-1a hashing with length as secondary key to minimize
collisions. Indexes are freed immediately after each phase.
## Results
**Shadow Slave EPUB (2768 chapters):**
| Phase | Before | After | Speedup |
|-------|--------|-------|---------|
| OPF pass | ~25 min | 10.8 sec | ~140x |
| TOC pass | ~5 min | 4.7 sec | ~60x |
| buildBookBin | 506 sec | 34.6 sec | ~15x |
| **Total** | **~30+ min** | **~50 sec** | **~36x** |
**Normal EPUB (87 chapters):** 1.7 sec - no regression.
## Memory
Peak temporary memory during indexing:
- OPF index: ~33KB (2770 items × 12 bytes)
- TOC index: ~33KB (2768 items × 12 bytes)
- ZIP batch: ~44KB (targets + sizes arrays)
All indexes cleared immediately after each phase. No OOM risk on
ESP32-C3.
## Note on Threshold
All optimizations are gated by `LARGE_SPINE_THRESHOLD = 400` to preserve
existing behavior for small books. However, the algorithms work
correctly for any book size and are faster even for small books:
| Book Size | Old O(n²) | New O(n log n) | Improvement |
|-----------|-----------|----------------|-------------|
| 10 ch | 100 ops | 50 ops | 2x |
| 100 ch | 10K ops | 800 ops | 12x |
| 400 ch | 160K ops | 4K ops | 40x |
If preferred, the threshold could be removed to use the optimized path
universally.
## Testing
- [x] Shadow Slave (2768 chapters): 50s first-time indexing, loads and
navigates correctly
- [x] Normal book (87 chapters): 1.7s indexing, no regression
- [x] Build passes
- [x] clang-format passes
## Files Changed
- `lib/Epub/Epub/parsers/ContentOpfParser.h/.cpp` - OPF manifest index
- `lib/Epub/Epub/BookMetadataCache.h/.cpp` - TOC index + batch size
lookup
- `lib/ZipFile/ZipFile.h/.cpp` - New `fillUncompressedSizes()` API
- `lib/Epub/Epub.cpp` - Timing logs
<details>
<summary><b>Algorithm Details</b> (click to expand)</summary>
### Phase 1: OPF Pass - Manifest to Spine Lookup
**Problem**: Each `<itemref idref="ch001">` in spine must find matching
`<item id="ch001" href="...">` in manifest.
```
OLD: For each of 2768 spine refs, scan all 2770 manifest items
= 7.6M string comparisons
NEW: While parsing manifest, build index:
{ hash("ch001"), len=5, file_offset=120 }
Sort index, then binary search for each spine ref:
2768 × log₂(2770) ≈ 2768 × 11 = 30K comparisons
```
### Phase 2: TOC Pass - TOC Entry to Spine Index Lookup
**Problem**: Each TOC entry with `href="chapter0001.xhtml"` must find
its spine index.
```
OLD: For each of 2768 TOC entries, scan all 2768 spine entries
= 7.6M string comparisons
NEW: At beginTocPass(), read spine once and build index:
{ hash("OEBPS/chapter0001.xhtml"), len=25, spineIndex=0 }
Sort index, binary search for each TOC entry:
2768 × log₂(2768) ≈ 30K comparisons
Clear index at endTocPass() to free memory.
```
### Phase 3: buildBookBin - ZIP Size Lookup
**Problem**: Need uncompressed file size for each spine item (for
reading progress). Sizes are in ZIP central directory.
```
OLD: For each of 2768 spine items, scan ZIP central directory (2773 entries)
= 7.6M filename reads + string comparisons
Time: 506 seconds
NEW:
Step 1: Build targets from spine
{ hash("OEBPS/chapter0001.xhtml"), len=25, index=0 }
Sort by (hash, len)
Step 2: Single pass through ZIP central directory
For each entry:
- Compute hash ON THE FLY (no string allocation)
- Binary search targets
- If match: sizes[target.index] = uncompressedSize
Step 3: Use sizes array directly (O(1) per spine item)
Total: 2773 entries × log₂(2768) ≈ 33K comparisons
Time: 35 seconds
```
### Why Hash + Length?
Using 64-bit FNV-1a hash + string length as a composite key:
- Collision probability: ~1 in 2⁶⁴ × typical_path_lengths
- No string storage needed in index (just 12-16 bytes per entry)
- Integer comparisons are faster than string comparisons
- Verification on match handles the rare collision case
</details>
---
_AI-assisted development. All changes tested on hardware._
2026-01-27 06:29:15 -08:00
|
|
|
const uint32_t tocStart = millis();
|
2025-12-24 22:36:13 +11:00
|
|
|
if (!bookMetadataCache->beginTocPass()) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "Could not begin writing toc pass");
|
2025-12-24 22:36:13 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
2026-01-03 16:10:35 +08:00
|
|
|
|
|
|
|
|
bool tocParsed = false;
|
|
|
|
|
|
|
|
|
|
// Try EPUB 3 nav document first (preferred)
|
|
|
|
|
if (!tocNavItem.empty()) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "Attempting to parse EPUB 3 nav document");
|
2026-01-03 16:10:35 +08:00
|
|
|
tocParsed = parseTocNavFile();
|
2025-12-03 22:00:29 +11:00
|
|
|
}
|
2026-01-03 16:10:35 +08:00
|
|
|
|
|
|
|
|
// Fall back to NCX if nav parsing failed or wasn't available
|
|
|
|
|
if (!tocParsed && !tocNcxItem.empty()) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "Falling back to NCX TOC");
|
2026-01-03 16:10:35 +08:00
|
|
|
tocParsed = parseTocNcxFile();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!tocParsed) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "Warning: Could not parse any TOC format");
|
2026-01-03 16:10:35 +08:00
|
|
|
// Continue anyway - book will work without TOC
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
if (!bookMetadataCache->endTocPass()) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "Could not end writing toc pass");
|
2025-12-24 22:36:13 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "TOC pass completed in %lu ms", millis() - tocStart);
|
2025-12-03 22:00:29 +11:00
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
// Close the cache files
|
|
|
|
|
if (!bookMetadataCache->endWrite()) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "Could not end writing cache");
|
2025-12-24 22:36:13 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
2025-12-03 22:00:29 +11:00
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
// Build final book.bin
|
perf: optimize large EPUB indexing from O(n^2) to O(n) (#458)
## Summary
Optimizes EPUB metadata indexing for large books (2000+ chapters) from
~30 minutes to ~50 seconds by replacing O(n²) algorithms with O(n log n)
hash-indexed lookups.
Fixes #134
## Problem
Three phases had O(n²) complexity due to nested loops:
| Phase | Operation | Before (2768 chapters) |
|-------|-----------|------------------------|
| OPF Pass | For each spine ref, scan all manifest items | ~25 min |
| TOC Pass | For each TOC entry, scan all spine items | ~5 min |
| buildBookBin | For each spine item, scan ZIP central directory | ~8.4
min |
Total: **~30+ minutes** for first-time indexing of large EPUBs.
## Solution
Replace linear scans with sorted hash indexes + binary search:
- **OPF Pass**: Build `{hash(id), len, offset}` index from manifest,
binary search for each spine ref
- **TOC Pass**: Build `{hash(href), len, spineIndex}` index from spine,
binary search for each TOC entry
- **buildBookBin**: New `ZipFile::fillUncompressedSizes()` API - single
ZIP central directory scan with batch hash matching
All indexes use FNV-1a hashing with length as secondary key to minimize
collisions. Indexes are freed immediately after each phase.
## Results
**Shadow Slave EPUB (2768 chapters):**
| Phase | Before | After | Speedup |
|-------|--------|-------|---------|
| OPF pass | ~25 min | 10.8 sec | ~140x |
| TOC pass | ~5 min | 4.7 sec | ~60x |
| buildBookBin | 506 sec | 34.6 sec | ~15x |
| **Total** | **~30+ min** | **~50 sec** | **~36x** |
**Normal EPUB (87 chapters):** 1.7 sec - no regression.
## Memory
Peak temporary memory during indexing:
- OPF index: ~33KB (2770 items × 12 bytes)
- TOC index: ~33KB (2768 items × 12 bytes)
- ZIP batch: ~44KB (targets + sizes arrays)
All indexes cleared immediately after each phase. No OOM risk on
ESP32-C3.
## Note on Threshold
All optimizations are gated by `LARGE_SPINE_THRESHOLD = 400` to preserve
existing behavior for small books. However, the algorithms work
correctly for any book size and are faster even for small books:
| Book Size | Old O(n²) | New O(n log n) | Improvement |
|-----------|-----------|----------------|-------------|
| 10 ch | 100 ops | 50 ops | 2x |
| 100 ch | 10K ops | 800 ops | 12x |
| 400 ch | 160K ops | 4K ops | 40x |
If preferred, the threshold could be removed to use the optimized path
universally.
## Testing
- [x] Shadow Slave (2768 chapters): 50s first-time indexing, loads and
navigates correctly
- [x] Normal book (87 chapters): 1.7s indexing, no regression
- [x] Build passes
- [x] clang-format passes
## Files Changed
- `lib/Epub/Epub/parsers/ContentOpfParser.h/.cpp` - OPF manifest index
- `lib/Epub/Epub/BookMetadataCache.h/.cpp` - TOC index + batch size
lookup
- `lib/ZipFile/ZipFile.h/.cpp` - New `fillUncompressedSizes()` API
- `lib/Epub/Epub.cpp` - Timing logs
<details>
<summary><b>Algorithm Details</b> (click to expand)</summary>
### Phase 1: OPF Pass - Manifest to Spine Lookup
**Problem**: Each `<itemref idref="ch001">` in spine must find matching
`<item id="ch001" href="...">` in manifest.
```
OLD: For each of 2768 spine refs, scan all 2770 manifest items
= 7.6M string comparisons
NEW: While parsing manifest, build index:
{ hash("ch001"), len=5, file_offset=120 }
Sort index, then binary search for each spine ref:
2768 × log₂(2770) ≈ 2768 × 11 = 30K comparisons
```
### Phase 2: TOC Pass - TOC Entry to Spine Index Lookup
**Problem**: Each TOC entry with `href="chapter0001.xhtml"` must find
its spine index.
```
OLD: For each of 2768 TOC entries, scan all 2768 spine entries
= 7.6M string comparisons
NEW: At beginTocPass(), read spine once and build index:
{ hash("OEBPS/chapter0001.xhtml"), len=25, spineIndex=0 }
Sort index, binary search for each TOC entry:
2768 × log₂(2768) ≈ 30K comparisons
Clear index at endTocPass() to free memory.
```
### Phase 3: buildBookBin - ZIP Size Lookup
**Problem**: Need uncompressed file size for each spine item (for
reading progress). Sizes are in ZIP central directory.
```
OLD: For each of 2768 spine items, scan ZIP central directory (2773 entries)
= 7.6M filename reads + string comparisons
Time: 506 seconds
NEW:
Step 1: Build targets from spine
{ hash("OEBPS/chapter0001.xhtml"), len=25, index=0 }
Sort by (hash, len)
Step 2: Single pass through ZIP central directory
For each entry:
- Compute hash ON THE FLY (no string allocation)
- Binary search targets
- If match: sizes[target.index] = uncompressedSize
Step 3: Use sizes array directly (O(1) per spine item)
Total: 2773 entries × log₂(2768) ≈ 33K comparisons
Time: 35 seconds
```
### Why Hash + Length?
Using 64-bit FNV-1a hash + string length as a composite key:
- Collision probability: ~1 in 2⁶⁴ × typical_path_lengths
- No string storage needed in index (just 12-16 bytes per entry)
- Integer comparisons are faster than string comparisons
- Verification on match handles the rare collision case
</details>
---
_AI-assisted development. All changes tested on hardware._
2026-01-27 06:29:15 -08:00
|
|
|
const uint32_t buildStart = millis();
|
2025-12-24 22:36:13 +11:00
|
|
|
if (!bookMetadataCache->buildBookBin(filepath, bookMetadata)) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "Could not update mappings and sizes");
|
2025-12-24 22:36:13 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "buildBookBin completed in %lu ms", millis() - buildStart);
|
|
|
|
|
LOG_DBG("EBP", "Total indexing completed in %lu ms", millis() - indexingStart);
|
2025-12-18 12:49:14 +01:00
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
if (!bookMetadataCache->cleanupTmpFiles()) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "Could not cleanup tmp files - ignoring");
|
2025-12-24 22:36:13 +11:00
|
|
|
}
|
2025-12-18 12:49:14 +01:00
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
// Reload the cache from disk so it's in the correct state
|
|
|
|
|
bookMetadataCache.reset(new BookMetadataCache(cachePath));
|
|
|
|
|
if (!bookMetadataCache->load()) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "Failed to reload cache after writing");
|
2025-12-24 22:36:13 +11:00
|
|
|
return false;
|
2025-12-18 12:49:14 +01:00
|
|
|
}
|
2025-12-21 04:38:51 +01:00
|
|
|
|
feat: Add CSS parsing and CSS support in EPUBs (#411)
## Summary
* **What is the goal of this PR?**
- Adds basic CSS parsing to EPUBs and determine the CSS rules when
rendering to the screen so that text is styled correctly. Currently
supports bold, underline, italics, margin, padding, and text alignment
## Additional Context
- My main reason for wanting this is that the book I'm currently
reading, Carl's Doomsday Scenario (2nd in the Dungeon Crawler Carl
series), relies _a lot_ on styled text for telling parts of the story.
When text is bolded, it's supposed to be a message that's rendered
"on-screen" in the story. When characters are "chatting" with each
other, the text is bolded and their names are underlined. Plus, normal
emphasis is provided with italicizing words here and there. So, this
greatly improves my experience reading this book on the Xteink, and I
figured it was useful enough for others too.
- For transparency: I'm a software engineer, but I'm mostly frontend and
TypeScript/JavaScript. It's been _years_ since I did any C/C++, so I
would not be surprised if I'm doing something dumb along the way in this
code. Please don't hesitate to ask for changes if something looks off. I
heavily relied on Claude Code for help, and I had a lot of inspiration
from how [microreader](https://github.com/CidVonHighwind/microreader)
achieves their CSS parsing and styling. I did give this as good of a
code review as I could and went through everything, and _it works on my
machine_ 😄
### Before


### After


---
### AI Usage
Did you use AI tools to help write this code? **YES**, Claude Code
2026-02-05 05:28:10 -05:00
|
|
|
if (!skipLoadingCss) {
|
|
|
|
|
// Parse CSS files after cache reload
|
|
|
|
|
parseCssFiles();
|
2026-02-19 21:34:28 -08:00
|
|
|
Storage.removeDir((cachePath + "/sections").c_str());
|
feat: Add CSS parsing and CSS support in EPUBs (#411)
## Summary
* **What is the goal of this PR?**
- Adds basic CSS parsing to EPUBs and determine the CSS rules when
rendering to the screen so that text is styled correctly. Currently
supports bold, underline, italics, margin, padding, and text alignment
## Additional Context
- My main reason for wanting this is that the book I'm currently
reading, Carl's Doomsday Scenario (2nd in the Dungeon Crawler Carl
series), relies _a lot_ on styled text for telling parts of the story.
When text is bolded, it's supposed to be a message that's rendered
"on-screen" in the story. When characters are "chatting" with each
other, the text is bolded and their names are underlined. Plus, normal
emphasis is provided with italicizing words here and there. So, this
greatly improves my experience reading this book on the Xteink, and I
figured it was useful enough for others too.
- For transparency: I'm a software engineer, but I'm mostly frontend and
TypeScript/JavaScript. It's been _years_ since I did any C/C++, so I
would not be surprised if I'm doing something dumb along the way in this
code. Please don't hesitate to ask for changes if something looks off. I
heavily relied on Claude Code for help, and I had a lot of inspiration
from how [microreader](https://github.com/CidVonHighwind/microreader)
achieves their CSS parsing and styling. I did give this as good of a
code review as I could and went through everything, and _it works on my
machine_ 😄
### Before


### After


---
### AI Usage
Did you use AI tools to help write this code? **YES**, Claude Code
2026-02-05 05:28:10 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str());
|
2025-12-24 22:36:13 +11:00
|
|
|
return true;
|
2025-12-18 12:49:14 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-12 22:13:34 +11:00
|
|
|
bool Epub::clearCache() const {
|
2026-02-08 21:29:14 +01:00
|
|
|
if (!Storage.exists(cachePath.c_str())) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EPB", "Cache does not exist, no action needed");
|
2025-12-12 22:13:34 +11:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 21:29:14 +01:00
|
|
|
if (!Storage.removeDir(cachePath.c_str())) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EPB", "Failed to clear cache");
|
2025-12-12 22:13:34 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EPB", "Cache cleared successfully");
|
2025-12-12 22:13:34 +11:00
|
|
|
return true;
|
|
|
|
|
}
|
2025-12-03 22:00:29 +11:00
|
|
|
|
|
|
|
|
void Epub::setupCacheDir() const {
|
2026-02-08 21:29:14 +01:00
|
|
|
if (Storage.exists(cachePath.c_str())) {
|
2025-12-03 22:00:29 +11:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 21:29:14 +01:00
|
|
|
Storage.mkdir(cachePath.c_str());
|
2025-12-03 22:00:29 +11:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const std::string& Epub::getCachePath() const { return cachePath; }
|
|
|
|
|
|
|
|
|
|
const std::string& Epub::getPath() const { return filepath; }
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
const std::string& Epub::getTitle() const {
|
|
|
|
|
static std::string blank;
|
|
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
|
|
|
|
return blank;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return bookMetadataCache->coreMetadata.title;
|
|
|
|
|
}
|
2025-12-03 22:00:29 +11:00
|
|
|
|
2025-12-30 21:15:44 +10:00
|
|
|
const std::string& Epub::getAuthor() const {
|
|
|
|
|
static std::string blank;
|
|
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
|
|
|
|
return blank;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return bookMetadataCache->coreMetadata.author;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-19 17:56:26 +05:00
|
|
|
const std::string& Epub::getLanguage() const {
|
|
|
|
|
static std::string blank;
|
|
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
|
|
|
|
return blank;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return bookMetadataCache->coreMetadata.language;
|
|
|
|
|
}
|
|
|
|
|
|
port: upstream PR #1342 - Book Info screen, richer metadata, safer controls
Ports upstream PR #1342 (feat: Add Book Info screen, richer metadata,
and safer file-browser controls) with mod-specific adaptations:
- Parse and cache series, seriesIndex, description from EPUB OPF
- Bump book.bin cache version to 6 for new metadata fields
- Add BookInfoActivity (new screen) accessible via Right button in FileBrowser
- Add ManageBook menu via Left button in FileBrowser (replaces upstream hidden delete)
- Guard all delete/archive actions with ConfirmationActivity (10 call sites)
- Add inputArmed gating to ConfirmationActivity to prevent accidental confirmation
- Safe deserialization: readString now returns bool with MAX_STRING_LENGTH guard
- Add series field to RecentBooksStore with JSON and binary serialization
- Add i18n keys: STR_BOOK_INFO, STR_AUTHOR, STR_SERIES, STR_FILE_SIZE, etc.
Made-with: Cursor
2026-03-09 00:39:32 -04:00
|
|
|
const std::string& Epub::getSeries() const {
|
|
|
|
|
static std::string blank;
|
|
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
|
|
|
|
return blank;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return bookMetadataCache->coreMetadata.series;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const std::string& Epub::getSeriesIndex() const {
|
|
|
|
|
static std::string blank;
|
|
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
|
|
|
|
return blank;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return bookMetadataCache->coreMetadata.seriesIndex;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const std::string& Epub::getDescription() const {
|
|
|
|
|
static std::string blank;
|
|
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
|
|
|
|
return blank;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return bookMetadataCache->coreMetadata.description;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-12 10:55:47 +01:00
|
|
|
std::string Epub::getCoverBmpPath(bool cropped) const {
|
2026-01-27 10:21:15 +01:00
|
|
|
const auto coverFileName = std::string("cover") + (cropped ? "_crop" : "");
|
2026-01-12 10:55:47 +01:00
|
|
|
return cachePath + "/" + coverFileName + ".bmp";
|
|
|
|
|
}
|
2025-12-21 18:42:06 +11:00
|
|
|
|
2026-01-12 10:55:47 +01:00
|
|
|
bool Epub::generateCoverBmp(bool cropped) const {
|
2025-12-21 18:42:06 +11:00
|
|
|
// Already generated, return true
|
2026-02-08 21:29:14 +01:00
|
|
|
if (Storage.exists(getCoverBmpPath(cropped).c_str())) {
|
2025-12-21 18:42:06 +11:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "Cannot generate cover BMP, cache not loaded");
|
2025-12-24 22:36:13 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
|
|
|
|
|
if (coverImageHref.empty()) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "No known cover image");
|
2025-12-21 18:42:06 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 10:12:22 -06:00
|
|
|
if (FsHelpers::hasJpgExtension(coverImageHref)) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "Generating BMP from JPG cover image (%s mode)", cropped ? "cropped" : "fit");
|
2025-12-23 14:14:10 +11:00
|
|
|
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
|
|
|
|
|
|
2025-12-30 15:09:30 +10:00
|
|
|
FsFile coverJpg;
|
2026-02-08 21:29:14 +01:00
|
|
|
if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
|
2025-12-23 14:14:10 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
2025-12-24 22:36:13 +11:00
|
|
|
readItemContentsToStream(coverImageHref, coverJpg, 1024);
|
2025-12-21 18:42:06 +11:00
|
|
|
coverJpg.close();
|
|
|
|
|
|
2026-02-08 21:29:14 +01:00
|
|
|
if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
|
2025-12-23 14:14:10 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 15:09:30 +10:00
|
|
|
FsFile coverBmp;
|
2026-02-08 21:29:14 +01:00
|
|
|
if (!Storage.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
|
2025-12-23 14:14:10 +11:00
|
|
|
coverJpg.close();
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-01-27 10:21:15 +01:00
|
|
|
const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp, cropped);
|
2025-12-21 18:42:06 +11:00
|
|
|
coverJpg.close();
|
|
|
|
|
coverBmp.close();
|
2026-02-08 21:29:14 +01:00
|
|
|
Storage.remove(coverJpgTempPath.c_str());
|
2025-12-21 18:42:06 +11:00
|
|
|
|
|
|
|
|
if (!success) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "Failed to generate BMP from cover image");
|
2026-02-08 21:29:14 +01:00
|
|
|
Storage.remove(getCoverBmpPath(cropped).c_str());
|
2025-12-21 18:42:06 +11:00
|
|
|
}
|
2026-02-16 06:56:13 -05:00
|
|
|
LOG_DBG("EBP", "Generated BMP from JPG cover image, success: %s", success ? "yes" : "no");
|
|
|
|
|
return success;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 10:12:22 -06:00
|
|
|
if (FsHelpers::hasPngExtension(coverImageHref)) {
|
2026-02-16 06:56:13 -05:00
|
|
|
LOG_DBG("EBP", "Generating BMP from PNG cover image (%s mode)", cropped ? "cropped" : "fit");
|
|
|
|
|
const auto coverPngTempPath = getCachePath() + "/.cover.png";
|
|
|
|
|
|
|
|
|
|
FsFile coverPng;
|
|
|
|
|
if (!Storage.openFileForWrite("EBP", coverPngTempPath, coverPng)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
readItemContentsToStream(coverImageHref, coverPng, 1024);
|
|
|
|
|
coverPng.close();
|
|
|
|
|
|
|
|
|
|
if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
FsFile coverBmp;
|
|
|
|
|
if (!Storage.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
|
|
|
|
|
coverPng.close();
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const bool success = PngToBmpConverter::pngFileToBmpStream(coverPng, coverBmp, cropped);
|
|
|
|
|
coverPng.close();
|
|
|
|
|
coverBmp.close();
|
|
|
|
|
Storage.remove(coverPngTempPath.c_str());
|
|
|
|
|
|
|
|
|
|
if (!success) {
|
|
|
|
|
LOG_ERR("EBP", "Failed to generate BMP from PNG cover image");
|
|
|
|
|
Storage.remove(getCoverBmpPath(cropped).c_str());
|
|
|
|
|
}
|
|
|
|
|
LOG_DBG("EBP", "Generated BMP from PNG cover image, success: %s", success ? "yes" : "no");
|
2025-12-21 18:42:06 +11:00
|
|
|
return success;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 06:56:13 -05:00
|
|
|
LOG_ERR("EBP", "Cover image is not a supported format, skipping");
|
2025-12-21 18:42:06 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
2025-12-03 22:00:29 +11:00
|
|
|
|
feat: UI themes, Lyra (#528)
## Summary
### What is the goal of this PR?
- Visual UI overhaul
- UI theme selection
### What changes are included?
- Added a setting "UI Theme": Classic, Lyra
- The classic theme is the current Crosspoint theme
- The Lyra theme implements these mockups:
https://www.figma.com/design/UhxoV4DgUnfrDQgMPPTXog/Lyra-Theme?node-id=2003-7596&t=4CSOZqf0n9uQMxDt-0
by Discord users yagofarias, ruby and gan_shu
- New functions in GFXRenderer to render rounded rectangles, greyscale
fills (using dithering) and thick lines
- Basic UI components are factored into BaseTheme methods which can be
overridden by each additional theme. Methods that are not overridden
will fallback to BaseTheme behavior. This means any new
features/components in CrossPoint only need to be developed for the
"Classic" BaseTheme.
- Additional themes can easily be developed by the community using this
foundation



## Additional Context
- Only the Home, Library and main Settings screens have been implemented
so far, this will be extended to the transfer screens and chapter
selection screen later on, but we need to get the ball rolling somehow
:)
- Loading extra covers on the home screen in the Lyra theme takes a
little more time (about 2 seconds), I added a loading bar popup (reusing
the Indexing progress bar from the reader view, factored into a neat UI
component) but the popup adds ~400ms to the loading time.
- ~~Home screen thumbnails will need to be generated separately for each
theme, because they are displayed in different sizes. Because we're
using dithering, displaying a thumb with the wrong size causes the
picture to look janky or dark as it does on the screenshots above. No
worries this will be fixed in a future PR.~~ Thumbs are now generated
with a size parameter
- UI Icons will need to be implemented in a future PR.
---
### AI Usage
While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.
Did you use AI tools to help write this code? _**PARTIALLY**_
This is not a vibe coded PR. Copilot was used for autocompletion to save
time but I reviewed, understood and edited all generated code.
---------
Co-authored-by: Dave Allie <dave@daveallie.com>
2026-02-05 17:50:11 +07:00
|
|
|
std::string Epub::getThumbBmpPath() const { return cachePath + "/thumb_[HEIGHT].bmp"; }
|
|
|
|
|
std::string Epub::getThumbBmpPath(int height) const { return cachePath + "/thumb_" + std::to_string(height) + ".bmp"; }
|
Add cover image display in *Continue Reading* card with framebuffer caching (#200)
## Summary
* **What is the goal of this PR?** (e.g., Fixes a bug in the user
authentication module,
Display the book cover image in the **"Continue Reading"** card on the
home screen, with fast navigation using framebuffer caching.
* **What changes are included?**
- Display book cover image in the "Continue Reading" card on home screen
- Load cover from cached BMP (same as sleep screen cover)
- Add framebuffer store/restore functions (`copyStoredBwBuffer`,
`freeStoredBwBuffer`) for fast navigation after initial render
- Fix `drawBitmap` scaling bug: apply scale to offset only, not to base
coordinates
- Add white text boxes behind title/author/continue reading label for
readability on cover
- Support both EPUB and XTC file cover images
- Increase HomeActivity task stack size from 2048 to 4096 for cover
image rendering
## Additional Context
* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks, specific areas to
focus on).
- Performance: First render loads cover from SD card (~800ms),
subsequent navigation uses cached framebuffer (~instant)
- Memory: Framebuffer cache uses ~48KB (6 chunks × 8KB) while on home
screen, freed on exit
- Fallback: If cover image is not available, falls back to standard
text-only display
- The `drawBitmap` fix corrects a bug where screenY = (y + offset) scale
was incorrectly scaling the base coordinates. Now correctly uses screenY
= y + (offset scale)
2026-01-14 19:24:02 +09:00
|
|
|
|
feat: UI themes, Lyra (#528)
## Summary
### What is the goal of this PR?
- Visual UI overhaul
- UI theme selection
### What changes are included?
- Added a setting "UI Theme": Classic, Lyra
- The classic theme is the current Crosspoint theme
- The Lyra theme implements these mockups:
https://www.figma.com/design/UhxoV4DgUnfrDQgMPPTXog/Lyra-Theme?node-id=2003-7596&t=4CSOZqf0n9uQMxDt-0
by Discord users yagofarias, ruby and gan_shu
- New functions in GFXRenderer to render rounded rectangles, greyscale
fills (using dithering) and thick lines
- Basic UI components are factored into BaseTheme methods which can be
overridden by each additional theme. Methods that are not overridden
will fallback to BaseTheme behavior. This means any new
features/components in CrossPoint only need to be developed for the
"Classic" BaseTheme.
- Additional themes can easily be developed by the community using this
foundation



## Additional Context
- Only the Home, Library and main Settings screens have been implemented
so far, this will be extended to the transfer screens and chapter
selection screen later on, but we need to get the ball rolling somehow
:)
- Loading extra covers on the home screen in the Lyra theme takes a
little more time (about 2 seconds), I added a loading bar popup (reusing
the Indexing progress bar from the reader view, factored into a neat UI
component) but the popup adds ~400ms to the loading time.
- ~~Home screen thumbnails will need to be generated separately for each
theme, because they are displayed in different sizes. Because we're
using dithering, displaying a thumb with the wrong size causes the
picture to look janky or dark as it does on the screenshots above. No
worries this will be fixed in a future PR.~~ Thumbs are now generated
with a size parameter
- UI Icons will need to be implemented in a future PR.
---
### AI Usage
While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.
Did you use AI tools to help write this code? _**PARTIALLY**_
This is not a vibe coded PR. Copilot was used for autocompletion to save
time but I reviewed, understood and edited all generated code.
---------
Co-authored-by: Dave Allie <dave@daveallie.com>
2026-02-05 17:50:11 +07:00
|
|
|
bool Epub::generateThumbBmp(int height) const {
|
Add cover image display in *Continue Reading* card with framebuffer caching (#200)
## Summary
* **What is the goal of this PR?** (e.g., Fixes a bug in the user
authentication module,
Display the book cover image in the **"Continue Reading"** card on the
home screen, with fast navigation using framebuffer caching.
* **What changes are included?**
- Display book cover image in the "Continue Reading" card on home screen
- Load cover from cached BMP (same as sleep screen cover)
- Add framebuffer store/restore functions (`copyStoredBwBuffer`,
`freeStoredBwBuffer`) for fast navigation after initial render
- Fix `drawBitmap` scaling bug: apply scale to offset only, not to base
coordinates
- Add white text boxes behind title/author/continue reading label for
readability on cover
- Support both EPUB and XTC file cover images
- Increase HomeActivity task stack size from 2048 to 4096 for cover
image rendering
## Additional Context
* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks, specific areas to
focus on).
- Performance: First render loads cover from SD card (~800ms),
subsequent navigation uses cached framebuffer (~instant)
- Memory: Framebuffer cache uses ~48KB (6 chunks × 8KB) while on home
screen, freed on exit
- Fallback: If cover image is not available, falls back to standard
text-only display
- The `drawBitmap` fix corrects a bug where screenY = (y + offset) scale
was incorrectly scaling the base coordinates. Now correctly uses screenY
= y + (offset scale)
2026-01-14 19:24:02 +09:00
|
|
|
// Already generated, return true
|
2026-02-08 21:29:14 +01:00
|
|
|
if (Storage.exists(getThumbBmpPath(height).c_str())) {
|
Add cover image display in *Continue Reading* card with framebuffer caching (#200)
## Summary
* **What is the goal of this PR?** (e.g., Fixes a bug in the user
authentication module,
Display the book cover image in the **"Continue Reading"** card on the
home screen, with fast navigation using framebuffer caching.
* **What changes are included?**
- Display book cover image in the "Continue Reading" card on home screen
- Load cover from cached BMP (same as sleep screen cover)
- Add framebuffer store/restore functions (`copyStoredBwBuffer`,
`freeStoredBwBuffer`) for fast navigation after initial render
- Fix `drawBitmap` scaling bug: apply scale to offset only, not to base
coordinates
- Add white text boxes behind title/author/continue reading label for
readability on cover
- Support both EPUB and XTC file cover images
- Increase HomeActivity task stack size from 2048 to 4096 for cover
image rendering
## Additional Context
* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks, specific areas to
focus on).
- Performance: First render loads cover from SD card (~800ms),
subsequent navigation uses cached framebuffer (~instant)
- Memory: Framebuffer cache uses ~48KB (6 chunks × 8KB) while on home
screen, freed on exit
- Fallback: If cover image is not available, falls back to standard
text-only display
- The `drawBitmap` fix corrects a bug where screenY = (y + offset) scale
was incorrectly scaling the base coordinates. Now correctly uses screenY
= y + (offset scale)
2026-01-14 19:24:02 +09:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "Cannot generate thumb BMP, cache not loaded");
|
Add cover image display in *Continue Reading* card with framebuffer caching (#200)
## Summary
* **What is the goal of this PR?** (e.g., Fixes a bug in the user
authentication module,
Display the book cover image in the **"Continue Reading"** card on the
home screen, with fast navigation using framebuffer caching.
* **What changes are included?**
- Display book cover image in the "Continue Reading" card on home screen
- Load cover from cached BMP (same as sleep screen cover)
- Add framebuffer store/restore functions (`copyStoredBwBuffer`,
`freeStoredBwBuffer`) for fast navigation after initial render
- Fix `drawBitmap` scaling bug: apply scale to offset only, not to base
coordinates
- Add white text boxes behind title/author/continue reading label for
readability on cover
- Support both EPUB and XTC file cover images
- Increase HomeActivity task stack size from 2048 to 4096 for cover
image rendering
## Additional Context
* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks, specific areas to
focus on).
- Performance: First render loads cover from SD card (~800ms),
subsequent navigation uses cached framebuffer (~instant)
- Memory: Framebuffer cache uses ~48KB (6 chunks × 8KB) while on home
screen, freed on exit
- Fallback: If cover image is not available, falls back to standard
text-only display
- The `drawBitmap` fix corrects a bug where screenY = (y + offset) scale
was incorrectly scaling the base coordinates. Now correctly uses screenY
= y + (offset scale)
2026-01-14 19:24:02 +09:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
|
|
|
|
|
if (coverImageHref.empty()) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "No known cover image for thumbnail");
|
2026-03-05 10:12:22 -06:00
|
|
|
} else if (FsHelpers::hasJpgExtension(coverImageHref)) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "Generating thumb BMP from JPG cover image");
|
Add cover image display in *Continue Reading* card with framebuffer caching (#200)
## Summary
* **What is the goal of this PR?** (e.g., Fixes a bug in the user
authentication module,
Display the book cover image in the **"Continue Reading"** card on the
home screen, with fast navigation using framebuffer caching.
* **What changes are included?**
- Display book cover image in the "Continue Reading" card on home screen
- Load cover from cached BMP (same as sleep screen cover)
- Add framebuffer store/restore functions (`copyStoredBwBuffer`,
`freeStoredBwBuffer`) for fast navigation after initial render
- Fix `drawBitmap` scaling bug: apply scale to offset only, not to base
coordinates
- Add white text boxes behind title/author/continue reading label for
readability on cover
- Support both EPUB and XTC file cover images
- Increase HomeActivity task stack size from 2048 to 4096 for cover
image rendering
## Additional Context
* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks, specific areas to
focus on).
- Performance: First render loads cover from SD card (~800ms),
subsequent navigation uses cached framebuffer (~instant)
- Memory: Framebuffer cache uses ~48KB (6 chunks × 8KB) while on home
screen, freed on exit
- Fallback: If cover image is not available, falls back to standard
text-only display
- The `drawBitmap` fix corrects a bug where screenY = (y + offset) scale
was incorrectly scaling the base coordinates. Now correctly uses screenY
= y + (offset scale)
2026-01-14 19:24:02 +09:00
|
|
|
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
|
|
|
|
|
|
|
|
|
|
FsFile coverJpg;
|
2026-02-08 21:29:14 +01:00
|
|
|
if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
|
Add cover image display in *Continue Reading* card with framebuffer caching (#200)
## Summary
* **What is the goal of this PR?** (e.g., Fixes a bug in the user
authentication module,
Display the book cover image in the **"Continue Reading"** card on the
home screen, with fast navigation using framebuffer caching.
* **What changes are included?**
- Display book cover image in the "Continue Reading" card on home screen
- Load cover from cached BMP (same as sleep screen cover)
- Add framebuffer store/restore functions (`copyStoredBwBuffer`,
`freeStoredBwBuffer`) for fast navigation after initial render
- Fix `drawBitmap` scaling bug: apply scale to offset only, not to base
coordinates
- Add white text boxes behind title/author/continue reading label for
readability on cover
- Support both EPUB and XTC file cover images
- Increase HomeActivity task stack size from 2048 to 4096 for cover
image rendering
## Additional Context
* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks, specific areas to
focus on).
- Performance: First render loads cover from SD card (~800ms),
subsequent navigation uses cached framebuffer (~instant)
- Memory: Framebuffer cache uses ~48KB (6 chunks × 8KB) while on home
screen, freed on exit
- Fallback: If cover image is not available, falls back to standard
text-only display
- The `drawBitmap` fix corrects a bug where screenY = (y + offset) scale
was incorrectly scaling the base coordinates. Now correctly uses screenY
= y + (offset scale)
2026-01-14 19:24:02 +09:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
readItemContentsToStream(coverImageHref, coverJpg, 1024);
|
|
|
|
|
coverJpg.close();
|
|
|
|
|
|
2026-02-08 21:29:14 +01:00
|
|
|
if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
|
Add cover image display in *Continue Reading* card with framebuffer caching (#200)
## Summary
* **What is the goal of this PR?** (e.g., Fixes a bug in the user
authentication module,
Display the book cover image in the **"Continue Reading"** card on the
home screen, with fast navigation using framebuffer caching.
* **What changes are included?**
- Display book cover image in the "Continue Reading" card on home screen
- Load cover from cached BMP (same as sleep screen cover)
- Add framebuffer store/restore functions (`copyStoredBwBuffer`,
`freeStoredBwBuffer`) for fast navigation after initial render
- Fix `drawBitmap` scaling bug: apply scale to offset only, not to base
coordinates
- Add white text boxes behind title/author/continue reading label for
readability on cover
- Support both EPUB and XTC file cover images
- Increase HomeActivity task stack size from 2048 to 4096 for cover
image rendering
## Additional Context
* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks, specific areas to
focus on).
- Performance: First render loads cover from SD card (~800ms),
subsequent navigation uses cached framebuffer (~instant)
- Memory: Framebuffer cache uses ~48KB (6 chunks × 8KB) while on home
screen, freed on exit
- Fallback: If cover image is not available, falls back to standard
text-only display
- The `drawBitmap` fix corrects a bug where screenY = (y + offset) scale
was incorrectly scaling the base coordinates. Now correctly uses screenY
= y + (offset scale)
2026-01-14 19:24:02 +09:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
FsFile thumbBmp;
|
2026-02-08 21:29:14 +01:00
|
|
|
if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
|
Add cover image display in *Continue Reading* card with framebuffer caching (#200)
## Summary
* **What is the goal of this PR?** (e.g., Fixes a bug in the user
authentication module,
Display the book cover image in the **"Continue Reading"** card on the
home screen, with fast navigation using framebuffer caching.
* **What changes are included?**
- Display book cover image in the "Continue Reading" card on home screen
- Load cover from cached BMP (same as sleep screen cover)
- Add framebuffer store/restore functions (`copyStoredBwBuffer`,
`freeStoredBwBuffer`) for fast navigation after initial render
- Fix `drawBitmap` scaling bug: apply scale to offset only, not to base
coordinates
- Add white text boxes behind title/author/continue reading label for
readability on cover
- Support both EPUB and XTC file cover images
- Increase HomeActivity task stack size from 2048 to 4096 for cover
image rendering
## Additional Context
* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks, specific areas to
focus on).
- Performance: First render loads cover from SD card (~800ms),
subsequent navigation uses cached framebuffer (~instant)
- Memory: Framebuffer cache uses ~48KB (6 chunks × 8KB) while on home
screen, freed on exit
- Fallback: If cover image is not available, falls back to standard
text-only display
- The `drawBitmap` fix corrects a bug where screenY = (y + offset) scale
was incorrectly scaling the base coordinates. Now correctly uses screenY
= y + (offset scale)
2026-01-14 19:24:02 +09:00
|
|
|
coverJpg.close();
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
// Use smaller target size for Continue Reading card (half of screen: 240x400)
|
|
|
|
|
// Generate 1-bit BMP for fast home screen rendering (no gray passes needed)
|
feat: UI themes, Lyra (#528)
## Summary
### What is the goal of this PR?
- Visual UI overhaul
- UI theme selection
### What changes are included?
- Added a setting "UI Theme": Classic, Lyra
- The classic theme is the current Crosspoint theme
- The Lyra theme implements these mockups:
https://www.figma.com/design/UhxoV4DgUnfrDQgMPPTXog/Lyra-Theme?node-id=2003-7596&t=4CSOZqf0n9uQMxDt-0
by Discord users yagofarias, ruby and gan_shu
- New functions in GFXRenderer to render rounded rectangles, greyscale
fills (using dithering) and thick lines
- Basic UI components are factored into BaseTheme methods which can be
overridden by each additional theme. Methods that are not overridden
will fallback to BaseTheme behavior. This means any new
features/components in CrossPoint only need to be developed for the
"Classic" BaseTheme.
- Additional themes can easily be developed by the community using this
foundation



## Additional Context
- Only the Home, Library and main Settings screens have been implemented
so far, this will be extended to the transfer screens and chapter
selection screen later on, but we need to get the ball rolling somehow
:)
- Loading extra covers on the home screen in the Lyra theme takes a
little more time (about 2 seconds), I added a loading bar popup (reusing
the Indexing progress bar from the reader view, factored into a neat UI
component) but the popup adds ~400ms to the loading time.
- ~~Home screen thumbnails will need to be generated separately for each
theme, because they are displayed in different sizes. Because we're
using dithering, displaying a thumb with the wrong size causes the
picture to look janky or dark as it does on the screenshots above. No
worries this will be fixed in a future PR.~~ Thumbs are now generated
with a size parameter
- UI Icons will need to be implemented in a future PR.
---
### AI Usage
While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.
Did you use AI tools to help write this code? _**PARTIALLY**_
This is not a vibe coded PR. Copilot was used for autocompletion to save
time but I reviewed, understood and edited all generated code.
---------
Co-authored-by: Dave Allie <dave@daveallie.com>
2026-02-05 17:50:11 +07:00
|
|
|
int THUMB_TARGET_WIDTH = height * 0.6;
|
|
|
|
|
int THUMB_TARGET_HEIGHT = height;
|
Add cover image display in *Continue Reading* card with framebuffer caching (#200)
## Summary
* **What is the goal of this PR?** (e.g., Fixes a bug in the user
authentication module,
Display the book cover image in the **"Continue Reading"** card on the
home screen, with fast navigation using framebuffer caching.
* **What changes are included?**
- Display book cover image in the "Continue Reading" card on home screen
- Load cover from cached BMP (same as sleep screen cover)
- Add framebuffer store/restore functions (`copyStoredBwBuffer`,
`freeStoredBwBuffer`) for fast navigation after initial render
- Fix `drawBitmap` scaling bug: apply scale to offset only, not to base
coordinates
- Add white text boxes behind title/author/continue reading label for
readability on cover
- Support both EPUB and XTC file cover images
- Increase HomeActivity task stack size from 2048 to 4096 for cover
image rendering
## Additional Context
* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks, specific areas to
focus on).
- Performance: First render loads cover from SD card (~800ms),
subsequent navigation uses cached framebuffer (~instant)
- Memory: Framebuffer cache uses ~48KB (6 chunks × 8KB) while on home
screen, freed on exit
- Fallback: If cover image is not available, falls back to standard
text-only display
- The `drawBitmap` fix corrects a bug where screenY = (y + offset) scale
was incorrectly scaling the base coordinates. Now correctly uses screenY
= y + (offset scale)
2026-01-14 19:24:02 +09:00
|
|
|
const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH,
|
|
|
|
|
THUMB_TARGET_HEIGHT);
|
|
|
|
|
coverJpg.close();
|
|
|
|
|
thumbBmp.close();
|
2026-02-08 21:29:14 +01:00
|
|
|
Storage.remove(coverJpgTempPath.c_str());
|
Add cover image display in *Continue Reading* card with framebuffer caching (#200)
## Summary
* **What is the goal of this PR?** (e.g., Fixes a bug in the user
authentication module,
Display the book cover image in the **"Continue Reading"** card on the
home screen, with fast navigation using framebuffer caching.
* **What changes are included?**
- Display book cover image in the "Continue Reading" card on home screen
- Load cover from cached BMP (same as sleep screen cover)
- Add framebuffer store/restore functions (`copyStoredBwBuffer`,
`freeStoredBwBuffer`) for fast navigation after initial render
- Fix `drawBitmap` scaling bug: apply scale to offset only, not to base
coordinates
- Add white text boxes behind title/author/continue reading label for
readability on cover
- Support both EPUB and XTC file cover images
- Increase HomeActivity task stack size from 2048 to 4096 for cover
image rendering
## Additional Context
* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks, specific areas to
focus on).
- Performance: First render loads cover from SD card (~800ms),
subsequent navigation uses cached framebuffer (~instant)
- Memory: Framebuffer cache uses ~48KB (6 chunks × 8KB) while on home
screen, freed on exit
- Fallback: If cover image is not available, falls back to standard
text-only display
- The `drawBitmap` fix corrects a bug where screenY = (y + offset) scale
was incorrectly scaling the base coordinates. Now correctly uses screenY
= y + (offset scale)
2026-01-14 19:24:02 +09:00
|
|
|
|
|
|
|
|
if (!success) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "Failed to generate thumb BMP from JPG cover image");
|
2026-02-08 21:29:14 +01:00
|
|
|
Storage.remove(getThumbBmpPath(height).c_str());
|
Add cover image display in *Continue Reading* card with framebuffer caching (#200)
## Summary
* **What is the goal of this PR?** (e.g., Fixes a bug in the user
authentication module,
Display the book cover image in the **"Continue Reading"** card on the
home screen, with fast navigation using framebuffer caching.
* **What changes are included?**
- Display book cover image in the "Continue Reading" card on home screen
- Load cover from cached BMP (same as sleep screen cover)
- Add framebuffer store/restore functions (`copyStoredBwBuffer`,
`freeStoredBwBuffer`) for fast navigation after initial render
- Fix `drawBitmap` scaling bug: apply scale to offset only, not to base
coordinates
- Add white text boxes behind title/author/continue reading label for
readability on cover
- Support both EPUB and XTC file cover images
- Increase HomeActivity task stack size from 2048 to 4096 for cover
image rendering
## Additional Context
* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks, specific areas to
focus on).
- Performance: First render loads cover from SD card (~800ms),
subsequent navigation uses cached framebuffer (~instant)
- Memory: Framebuffer cache uses ~48KB (6 chunks × 8KB) while on home
screen, freed on exit
- Fallback: If cover image is not available, falls back to standard
text-only display
- The `drawBitmap` fix corrects a bug where screenY = (y + offset) scale
was incorrectly scaling the base coordinates. Now correctly uses screenY
= y + (offset scale)
2026-01-14 19:24:02 +09:00
|
|
|
}
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "Generated thumb BMP from JPG cover image, success: %s", success ? "yes" : "no");
|
Add cover image display in *Continue Reading* card with framebuffer caching (#200)
## Summary
* **What is the goal of this PR?** (e.g., Fixes a bug in the user
authentication module,
Display the book cover image in the **"Continue Reading"** card on the
home screen, with fast navigation using framebuffer caching.
* **What changes are included?**
- Display book cover image in the "Continue Reading" card on home screen
- Load cover from cached BMP (same as sleep screen cover)
- Add framebuffer store/restore functions (`copyStoredBwBuffer`,
`freeStoredBwBuffer`) for fast navigation after initial render
- Fix `drawBitmap` scaling bug: apply scale to offset only, not to base
coordinates
- Add white text boxes behind title/author/continue reading label for
readability on cover
- Support both EPUB and XTC file cover images
- Increase HomeActivity task stack size from 2048 to 4096 for cover
image rendering
## Additional Context
* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks, specific areas to
focus on).
- Performance: First render loads cover from SD card (~800ms),
subsequent navigation uses cached framebuffer (~instant)
- Memory: Framebuffer cache uses ~48KB (6 chunks × 8KB) while on home
screen, freed on exit
- Fallback: If cover image is not available, falls back to standard
text-only display
- The `drawBitmap` fix corrects a bug where screenY = (y + offset) scale
was incorrectly scaling the base coordinates. Now correctly uses screenY
= y + (offset scale)
2026-01-14 19:24:02 +09:00
|
|
|
return success;
|
2026-03-05 10:12:22 -06:00
|
|
|
} else if (FsHelpers::hasPngExtension(coverImageHref)) {
|
2026-02-16 06:56:13 -05:00
|
|
|
LOG_DBG("EBP", "Generating thumb BMP from PNG cover image");
|
|
|
|
|
const auto coverPngTempPath = getCachePath() + "/.cover.png";
|
|
|
|
|
|
|
|
|
|
FsFile coverPng;
|
|
|
|
|
if (!Storage.openFileForWrite("EBP", coverPngTempPath, coverPng)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
readItemContentsToStream(coverImageHref, coverPng, 1024);
|
|
|
|
|
coverPng.close();
|
|
|
|
|
|
|
|
|
|
if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
FsFile thumbBmp;
|
|
|
|
|
if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
|
|
|
|
|
coverPng.close();
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
int THUMB_TARGET_WIDTH = height * 0.6;
|
|
|
|
|
int THUMB_TARGET_HEIGHT = height;
|
|
|
|
|
const bool success =
|
|
|
|
|
PngToBmpConverter::pngFileTo1BitBmpStreamWithSize(coverPng, thumbBmp, THUMB_TARGET_WIDTH, THUMB_TARGET_HEIGHT);
|
|
|
|
|
coverPng.close();
|
|
|
|
|
thumbBmp.close();
|
|
|
|
|
Storage.remove(coverPngTempPath.c_str());
|
|
|
|
|
|
|
|
|
|
if (!success) {
|
|
|
|
|
LOG_ERR("EBP", "Failed to generate thumb BMP from PNG cover image");
|
|
|
|
|
Storage.remove(getThumbBmpPath(height).c_str());
|
|
|
|
|
}
|
|
|
|
|
LOG_DBG("EBP", "Generated thumb BMP from PNG cover image, success: %s", success ? "yes" : "no");
|
|
|
|
|
return success;
|
Add cover image display in *Continue Reading* card with framebuffer caching (#200)
## Summary
* **What is the goal of this PR?** (e.g., Fixes a bug in the user
authentication module,
Display the book cover image in the **"Continue Reading"** card on the
home screen, with fast navigation using framebuffer caching.
* **What changes are included?**
- Display book cover image in the "Continue Reading" card on home screen
- Load cover from cached BMP (same as sleep screen cover)
- Add framebuffer store/restore functions (`copyStoredBwBuffer`,
`freeStoredBwBuffer`) for fast navigation after initial render
- Fix `drawBitmap` scaling bug: apply scale to offset only, not to base
coordinates
- Add white text boxes behind title/author/continue reading label for
readability on cover
- Support both EPUB and XTC file cover images
- Increase HomeActivity task stack size from 2048 to 4096 for cover
image rendering
## Additional Context
* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks, specific areas to
focus on).
- Performance: First render loads cover from SD card (~800ms),
subsequent navigation uses cached framebuffer (~instant)
- Memory: Framebuffer cache uses ~48KB (6 chunks × 8KB) while on home
screen, freed on exit
- Fallback: If cover image is not available, falls back to standard
text-only display
- The `drawBitmap` fix corrects a bug where screenY = (y + offset) scale
was incorrectly scaling the base coordinates. Now correctly uses screenY
= y + (offset scale)
2026-01-14 19:24:02 +09:00
|
|
|
} else {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "Cover image is not a supported format, skipping thumbnail");
|
Add cover image display in *Continue Reading* card with framebuffer caching (#200)
## Summary
* **What is the goal of this PR?** (e.g., Fixes a bug in the user
authentication module,
Display the book cover image in the **"Continue Reading"** card on the
home screen, with fast navigation using framebuffer caching.
* **What changes are included?**
- Display book cover image in the "Continue Reading" card on home screen
- Load cover from cached BMP (same as sleep screen cover)
- Add framebuffer store/restore functions (`copyStoredBwBuffer`,
`freeStoredBwBuffer`) for fast navigation after initial render
- Fix `drawBitmap` scaling bug: apply scale to offset only, not to base
coordinates
- Add white text boxes behind title/author/continue reading label for
readability on cover
- Support both EPUB and XTC file cover images
- Increase HomeActivity task stack size from 2048 to 4096 for cover
image rendering
## Additional Context
* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks, specific areas to
focus on).
- Performance: First render loads cover from SD card (~800ms),
subsequent navigation uses cached framebuffer (~instant)
- Memory: Framebuffer cache uses ~48KB (6 chunks × 8KB) while on home
screen, freed on exit
- Fallback: If cover image is not available, falls back to standard
text-only display
- The `drawBitmap` fix corrects a bug where screenY = (y + offset) scale
was incorrectly scaling the base coordinates. Now correctly uses screenY
= y + (offset scale)
2026-01-14 19:24:02 +09:00
|
|
|
}
|
|
|
|
|
|
feat: UI themes, Lyra (#528)
## Summary
### What is the goal of this PR?
- Visual UI overhaul
- UI theme selection
### What changes are included?
- Added a setting "UI Theme": Classic, Lyra
- The classic theme is the current Crosspoint theme
- The Lyra theme implements these mockups:
https://www.figma.com/design/UhxoV4DgUnfrDQgMPPTXog/Lyra-Theme?node-id=2003-7596&t=4CSOZqf0n9uQMxDt-0
by Discord users yagofarias, ruby and gan_shu
- New functions in GFXRenderer to render rounded rectangles, greyscale
fills (using dithering) and thick lines
- Basic UI components are factored into BaseTheme methods which can be
overridden by each additional theme. Methods that are not overridden
will fallback to BaseTheme behavior. This means any new
features/components in CrossPoint only need to be developed for the
"Classic" BaseTheme.
- Additional themes can easily be developed by the community using this
foundation



## Additional Context
- Only the Home, Library and main Settings screens have been implemented
so far, this will be extended to the transfer screens and chapter
selection screen later on, but we need to get the ball rolling somehow
:)
- Loading extra covers on the home screen in the Lyra theme takes a
little more time (about 2 seconds), I added a loading bar popup (reusing
the Indexing progress bar from the reader view, factored into a neat UI
component) but the popup adds ~400ms to the loading time.
- ~~Home screen thumbnails will need to be generated separately for each
theme, because they are displayed in different sizes. Because we're
using dithering, displaying a thumb with the wrong size causes the
picture to look janky or dark as it does on the screenshots above. No
worries this will be fixed in a future PR.~~ Thumbs are now generated
with a size parameter
- UI Icons will need to be implemented in a future PR.
---
### AI Usage
While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.
Did you use AI tools to help write this code? _**PARTIALLY**_
This is not a vibe coded PR. Copilot was used for autocompletion to save
time but I reviewed, understood and edited all generated code.
---------
Co-authored-by: Dave Allie <dave@daveallie.com>
2026-02-05 17:50:11 +07:00
|
|
|
// Write an empty bmp file to avoid generation attempts in the future
|
|
|
|
|
FsFile thumbBmp;
|
2026-02-08 21:29:14 +01:00
|
|
|
Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp);
|
feat: UI themes, Lyra (#528)
## Summary
### What is the goal of this PR?
- Visual UI overhaul
- UI theme selection
### What changes are included?
- Added a setting "UI Theme": Classic, Lyra
- The classic theme is the current Crosspoint theme
- The Lyra theme implements these mockups:
https://www.figma.com/design/UhxoV4DgUnfrDQgMPPTXog/Lyra-Theme?node-id=2003-7596&t=4CSOZqf0n9uQMxDt-0
by Discord users yagofarias, ruby and gan_shu
- New functions in GFXRenderer to render rounded rectangles, greyscale
fills (using dithering) and thick lines
- Basic UI components are factored into BaseTheme methods which can be
overridden by each additional theme. Methods that are not overridden
will fallback to BaseTheme behavior. This means any new
features/components in CrossPoint only need to be developed for the
"Classic" BaseTheme.
- Additional themes can easily be developed by the community using this
foundation



## Additional Context
- Only the Home, Library and main Settings screens have been implemented
so far, this will be extended to the transfer screens and chapter
selection screen later on, but we need to get the ball rolling somehow
:)
- Loading extra covers on the home screen in the Lyra theme takes a
little more time (about 2 seconds), I added a loading bar popup (reusing
the Indexing progress bar from the reader view, factored into a neat UI
component) but the popup adds ~400ms to the loading time.
- ~~Home screen thumbnails will need to be generated separately for each
theme, because they are displayed in different sizes. Because we're
using dithering, displaying a thumb with the wrong size causes the
picture to look janky or dark as it does on the screenshots above. No
worries this will be fixed in a future PR.~~ Thumbs are now generated
with a size parameter
- UI Icons will need to be implemented in a future PR.
---
### AI Usage
While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.
Did you use AI tools to help write this code? _**PARTIALLY**_
This is not a vibe coded PR. Copilot was used for autocompletion to save
time but I reviewed, understood and edited all generated code.
---------
Co-authored-by: Dave Allie <dave@daveallie.com>
2026-02-05 17:50:11 +07:00
|
|
|
thumbBmp.close();
|
Add cover image display in *Continue Reading* card with framebuffer caching (#200)
## Summary
* **What is the goal of this PR?** (e.g., Fixes a bug in the user
authentication module,
Display the book cover image in the **"Continue Reading"** card on the
home screen, with fast navigation using framebuffer caching.
* **What changes are included?**
- Display book cover image in the "Continue Reading" card on home screen
- Load cover from cached BMP (same as sleep screen cover)
- Add framebuffer store/restore functions (`copyStoredBwBuffer`,
`freeStoredBwBuffer`) for fast navigation after initial render
- Fix `drawBitmap` scaling bug: apply scale to offset only, not to base
coordinates
- Add white text boxes behind title/author/continue reading label for
readability on cover
- Support both EPUB and XTC file cover images
- Increase HomeActivity task stack size from 2048 to 4096 for cover
image rendering
## Additional Context
* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks, specific areas to
focus on).
- Performance: First render loads cover from SD card (~800ms),
subsequent navigation uses cached framebuffer (~instant)
- Memory: Framebuffer cache uses ~48KB (6 chunks × 8KB) while on home
screen, freed on exit
- Fallback: If cover image is not available, falls back to standard
text-only display
- The `drawBitmap` fix corrects a bug where screenY = (y + offset) scale
was incorrectly scaling the base coordinates. Now correctly uses screenY
= y + (offset scale)
2026-01-14 19:24:02 +09:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 21:22:19 -05:00
|
|
|
bool Epub::isValidThumbnailBmp(const std::string& bmpPath) {
|
|
|
|
|
if (!Storage.exists(bmpPath.c_str())) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
FsFile file = Storage.open(bmpPath.c_str());
|
|
|
|
|
if (!file) {
|
|
|
|
|
LOG_ERR("EBP", "Failed to open thumbnail BMP at path: %s", bmpPath.c_str());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
size_t fileSize = file.size();
|
|
|
|
|
if (fileSize == 0) {
|
|
|
|
|
LOG_DBG("EBP", "Thumbnail BMP is empty (no cover marker) at path: %s", bmpPath.c_str());
|
|
|
|
|
file.close();
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
uint8_t header[2];
|
|
|
|
|
size_t bytesRead = file.read(header, 2);
|
|
|
|
|
if (bytesRead != 2) {
|
|
|
|
|
LOG_ERR("EBP", "Failed to read thumbnail BMP header at path: %s", bmpPath.c_str());
|
|
|
|
|
file.close();
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
file.close();
|
|
|
|
|
return header[0] == 'B' && header[1] == 'M';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool Epub::generateInvalidFormatThumbBmp(int height) const {
|
|
|
|
|
const int width = height * 0.6;
|
|
|
|
|
const int rowBytes = ((width + 31) / 32) * 4;
|
|
|
|
|
const int imageSize = rowBytes * height;
|
|
|
|
|
const int fileSize = 14 + 40 + 8 + imageSize;
|
|
|
|
|
const int dataOffset = 14 + 40 + 8;
|
|
|
|
|
|
|
|
|
|
FsFile thumbBmp;
|
|
|
|
|
if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
thumbBmp.write('B');
|
|
|
|
|
thumbBmp.write('M');
|
|
|
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&fileSize), 4);
|
|
|
|
|
uint32_t reserved = 0;
|
|
|
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&reserved), 4);
|
|
|
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&dataOffset), 4);
|
|
|
|
|
|
|
|
|
|
uint32_t dibHeaderSize = 40;
|
|
|
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&dibHeaderSize), 4);
|
|
|
|
|
int32_t bmpWidth = width;
|
|
|
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&bmpWidth), 4);
|
|
|
|
|
int32_t bmpHeight = -height;
|
|
|
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&bmpHeight), 4);
|
|
|
|
|
uint16_t planes = 1;
|
|
|
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&planes), 2);
|
|
|
|
|
uint16_t bitsPerPixel = 1;
|
|
|
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&bitsPerPixel), 2);
|
|
|
|
|
uint32_t compression = 0;
|
|
|
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&compression), 4);
|
|
|
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&imageSize), 4);
|
|
|
|
|
int32_t ppmX = 2835;
|
|
|
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&ppmX), 4);
|
|
|
|
|
int32_t ppmY = 2835;
|
|
|
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&ppmY), 4);
|
|
|
|
|
uint32_t colorsUsed = 2;
|
|
|
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&colorsUsed), 4);
|
|
|
|
|
uint32_t colorsImportant = 2;
|
|
|
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&colorsImportant), 4);
|
|
|
|
|
|
|
|
|
|
uint8_t black[4] = {0x00, 0x00, 0x00, 0x00};
|
|
|
|
|
thumbBmp.write(black, 4);
|
|
|
|
|
uint8_t white[4] = {0xFF, 0xFF, 0xFF, 0x00};
|
|
|
|
|
thumbBmp.write(white, 4);
|
|
|
|
|
|
|
|
|
|
for (int y = 0; y < height; y++) {
|
|
|
|
|
std::vector<uint8_t> rowData(rowBytes, 0xFF);
|
|
|
|
|
const int scaledY = (y * width) / height;
|
|
|
|
|
const int thickness = 2;
|
|
|
|
|
for (int x = 0; x < width; x++) {
|
|
|
|
|
bool drawPixel = false;
|
|
|
|
|
if (std::abs(x - scaledY) <= thickness) drawPixel = true;
|
|
|
|
|
if (std::abs(x - (width - 1 - scaledY)) <= thickness) drawPixel = true;
|
|
|
|
|
if (drawPixel) {
|
|
|
|
|
const int byteIndex = x / 8;
|
|
|
|
|
const int bitIndex = 7 - (x % 8);
|
|
|
|
|
rowData[byteIndex] &= static_cast<uint8_t>(~(1 << bitIndex));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
thumbBmp.write(rowData.data(), rowBytes);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
thumbBmp.close();
|
|
|
|
|
LOG_DBG("EBP", "Generated invalid format thumbnail BMP");
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool Epub::generateInvalidFormatCoverBmp(bool cropped) const {
|
|
|
|
|
const int hwW = static_cast<int>(HalDisplay::DISPLAY_WIDTH);
|
|
|
|
|
const int hwH = static_cast<int>(HalDisplay::DISPLAY_HEIGHT);
|
|
|
|
|
const int width = std::min(hwW, hwH);
|
|
|
|
|
const int height = std::max(hwW, hwH);
|
|
|
|
|
const int rowBytes = ((width + 31) / 32) * 4;
|
|
|
|
|
const int imageSize = rowBytes * height;
|
|
|
|
|
const int fileSize = 14 + 40 + 8 + imageSize;
|
|
|
|
|
const int dataOffset = 14 + 40 + 8;
|
|
|
|
|
|
|
|
|
|
FsFile coverBmp;
|
|
|
|
|
if (!Storage.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
coverBmp.write('B');
|
|
|
|
|
coverBmp.write('M');
|
|
|
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&fileSize), 4);
|
|
|
|
|
uint32_t reserved = 0;
|
|
|
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&reserved), 4);
|
|
|
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&dataOffset), 4);
|
|
|
|
|
|
|
|
|
|
uint32_t dibHeaderSize = 40;
|
|
|
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&dibHeaderSize), 4);
|
|
|
|
|
int32_t bmpWidth = width;
|
|
|
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&bmpWidth), 4);
|
|
|
|
|
int32_t bmpHeight = -height;
|
|
|
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&bmpHeight), 4);
|
|
|
|
|
uint16_t planes = 1;
|
|
|
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&planes), 2);
|
|
|
|
|
uint16_t bitsPerPixel = 1;
|
|
|
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&bitsPerPixel), 2);
|
|
|
|
|
uint32_t compression = 0;
|
|
|
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&compression), 4);
|
|
|
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&imageSize), 4);
|
|
|
|
|
int32_t ppmX = 2835;
|
|
|
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&ppmX), 4);
|
|
|
|
|
int32_t ppmY = 2835;
|
|
|
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&ppmY), 4);
|
|
|
|
|
uint32_t colorsUsed = 2;
|
|
|
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&colorsUsed), 4);
|
|
|
|
|
uint32_t colorsImportant = 2;
|
|
|
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&colorsImportant), 4);
|
|
|
|
|
|
|
|
|
|
uint8_t black[4] = {0x00, 0x00, 0x00, 0x00};
|
|
|
|
|
coverBmp.write(black, 4);
|
|
|
|
|
uint8_t white[4] = {0xFF, 0xFF, 0xFF, 0x00};
|
|
|
|
|
coverBmp.write(white, 4);
|
|
|
|
|
|
|
|
|
|
for (int y = 0; y < height; y++) {
|
|
|
|
|
std::vector<uint8_t> rowData(rowBytes, 0xFF);
|
|
|
|
|
const int scaledY = (y * width) / height;
|
|
|
|
|
const int thickness = 6;
|
|
|
|
|
for (int x = 0; x < width; x++) {
|
|
|
|
|
bool drawPixel = false;
|
|
|
|
|
if (std::abs(x - scaledY) <= thickness) drawPixel = true;
|
|
|
|
|
if (std::abs(x - (width - 1 - scaledY)) <= thickness) drawPixel = true;
|
|
|
|
|
if (drawPixel) {
|
|
|
|
|
const int byteIndex = x / 8;
|
|
|
|
|
const int bitIndex = 7 - (x % 8);
|
|
|
|
|
rowData[byteIndex] &= static_cast<uint8_t>(~(1 << bitIndex));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
coverBmp.write(rowData.data(), rowBytes);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
coverBmp.close();
|
|
|
|
|
LOG_DBG("EBP", "Generated invalid format cover BMP");
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, const bool trailingNullByte) const {
|
2025-12-30 15:09:30 +10:00
|
|
|
if (itemHref.empty()) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "Failed to read item, empty href");
|
2025-12-30 15:09:30 +10:00
|
|
|
return nullptr;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-23 14:14:10 +11:00
|
|
|
const std::string path = FsHelpers::normalisePath(itemHref);
|
2025-12-03 22:00:29 +11:00
|
|
|
|
2025-12-29 20:17:29 +10:00
|
|
|
const auto content = ZipFile(filepath).readFileToMemory(path.c_str(), size, trailingNullByte);
|
2025-12-03 22:00:29 +11:00
|
|
|
if (!content) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "Failed to read item %s", path.c_str());
|
2025-12-03 22:00:29 +11:00
|
|
|
return nullptr;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return content;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-08 00:39:17 +11:00
|
|
|
bool Epub::readItemContentsToStream(const std::string& itemHref, Print& out, const size_t chunkSize) const {
|
2025-12-30 15:09:30 +10:00
|
|
|
if (itemHref.empty()) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "Failed to read item, empty href");
|
2025-12-30 15:09:30 +10:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-23 14:14:10 +11:00
|
|
|
const std::string path = FsHelpers::normalisePath(itemHref);
|
2025-12-29 20:17:29 +10:00
|
|
|
return ZipFile(filepath).readFileToStream(path.c_str(), out, chunkSize);
|
2025-12-03 22:00:29 +11:00
|
|
|
}
|
|
|
|
|
|
2025-12-13 19:36:01 +11:00
|
|
|
bool Epub::getItemSize(const std::string& itemHref, size_t* size) const {
|
2025-12-23 14:14:10 +11:00
|
|
|
const std::string path = FsHelpers::normalisePath(itemHref);
|
2025-12-29 20:17:29 +10:00
|
|
|
return ZipFile(filepath).getInflatedFileSize(path.c_str(), size);
|
2025-12-13 19:36:01 +11:00
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
int Epub::getSpineItemsCount() const {
|
|
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
2025-12-21 03:36:30 +01:00
|
|
|
return 0;
|
|
|
|
|
}
|
2025-12-24 22:36:13 +11:00
|
|
|
return bookMetadataCache->getSpineCount();
|
2025-12-21 03:36:30 +01:00
|
|
|
}
|
2025-12-17 13:05:24 +01:00
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const { return getSpineItem(spineIndex).cumulativeSize; }
|
|
|
|
|
|
|
|
|
|
BookMetadataCache::SpineEntry Epub::getSpineItem(const int spineIndex) const {
|
|
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "getSpineItem called but cache not loaded");
|
2025-12-24 22:36:13 +11:00
|
|
|
return {};
|
2025-12-21 03:36:30 +01:00
|
|
|
}
|
2025-12-24 22:36:13 +11:00
|
|
|
|
|
|
|
|
if (spineIndex < 0 || spineIndex >= bookMetadataCache->getSpineCount()) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "getSpineItem index:%d is out of range", spineIndex);
|
2025-12-24 22:36:13 +11:00
|
|
|
return bookMetadataCache->getSpineEntry(0);
|
2025-12-03 22:00:29 +11:00
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
return bookMetadataCache->getSpineEntry(spineIndex);
|
2025-12-03 22:00:29 +11:00
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
BookMetadataCache::TocEntry Epub::getTocItem(const int tocIndex) const {
|
|
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "getTocItem called but cache not loaded");
|
2025-12-24 22:36:13 +11:00
|
|
|
return {};
|
2025-12-21 03:36:30 +01:00
|
|
|
}
|
2025-12-24 22:36:13 +11:00
|
|
|
|
|
|
|
|
if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "getTocItem index:%d is out of range", tocIndex);
|
2025-12-24 22:36:13 +11:00
|
|
|
return {};
|
2025-12-03 22:00:29 +11:00
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
return bookMetadataCache->getTocEntry(tocIndex);
|
2025-12-03 22:00:29 +11:00
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
int Epub::getTocItemsCount() const {
|
|
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return bookMetadataCache->getTocCount();
|
|
|
|
|
}
|
2025-12-03 22:00:29 +11:00
|
|
|
|
|
|
|
|
// work out the section index for a toc index
|
|
|
|
|
int Epub::getSpineIndexForTocIndex(const int tocIndex) const {
|
2025-12-24 22:36:13 +11:00
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "getSpineIndexForTocIndex called but cache not loaded");
|
2025-12-19 13:23:23 +01:00
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "getSpineIndexForTocIndex: tocIndex %d out of range", tocIndex);
|
2025-12-24 22:36:13 +11:00
|
|
|
return 0;
|
2025-12-19 13:23:23 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
const int spineIndex = bookMetadataCache->getTocEntry(tocIndex).spineIndex;
|
|
|
|
|
if (spineIndex < 0) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "Section not found for TOC index %d", tocIndex);
|
2025-12-24 22:36:13 +11:00
|
|
|
return 0;
|
2025-12-03 22:00:29 +11:00
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
return spineIndex;
|
2025-12-03 22:00:29 +11:00
|
|
|
}
|
2025-12-17 13:05:24 +01:00
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
int Epub::getTocIndexForSpineIndex(const int spineIndex) const { return getSpineItem(spineIndex).tocIndex; }
|
|
|
|
|
|
2025-12-19 13:28:36 +01:00
|
|
|
size_t Epub::getBookSize() const {
|
2025-12-24 22:36:13 +11:00
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded() || bookMetadataCache->getSpineCount() == 0) {
|
2025-12-19 13:28:36 +01:00
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
return getCumulativeSpineItemSize(getSpineItemsCount() - 1);
|
|
|
|
|
}
|
2025-12-17 13:05:24 +01:00
|
|
|
|
2025-12-30 13:02:46 +01:00
|
|
|
int Epub::getSpineIndexForTextReference() const {
|
|
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_ERR("EBP", "getSpineIndexForTextReference called but cache not loaded");
|
2025-12-30 13:02:46 +01:00
|
|
|
return 0;
|
|
|
|
|
}
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "Core Metadata: cover(%d)=%s, textReference(%d)=%s",
|
|
|
|
|
bookMetadataCache->coreMetadata.coverItemHref.size(), bookMetadataCache->coreMetadata.coverItemHref.c_str(),
|
|
|
|
|
bookMetadataCache->coreMetadata.textReferenceHref.size(),
|
|
|
|
|
bookMetadataCache->coreMetadata.textReferenceHref.c_str());
|
2025-12-30 13:02:46 +01:00
|
|
|
|
|
|
|
|
if (bookMetadataCache->coreMetadata.textReferenceHref.empty()) {
|
|
|
|
|
// there was no textReference in epub, so we return 0 (the first chapter)
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// loop through spine items to get the correct index matching the text href
|
|
|
|
|
for (size_t i = 0; i < getSpineItemsCount(); i++) {
|
|
|
|
|
if (getSpineItem(i).href == bookMetadataCache->coreMetadata.textReferenceHref) {
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "Text reference %s found at index %d", bookMetadataCache->coreMetadata.textReferenceHref.c_str(),
|
|
|
|
|
i);
|
2025-12-30 13:02:46 +01:00
|
|
|
return i;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// This should not happen, as we checked for empty textReferenceHref earlier
|
2026-02-13 12:16:39 +01:00
|
|
|
LOG_DBG("EBP", "Section not found for text reference");
|
2025-12-30 13:02:46 +01:00
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-19 06:55:35 -05:00
|
|
|
// Calculate progress in book (returns 0.0-1.0)
|
|
|
|
|
float Epub::calculateProgress(const int currentSpineIndex, const float currentSpineRead) const {
|
2025-12-24 22:36:13 +11:00
|
|
|
const size_t bookSize = getBookSize();
|
2025-12-19 13:28:36 +01:00
|
|
|
if (bookSize == 0) {
|
2026-01-19 06:55:35 -05:00
|
|
|
return 0.0f;
|
2025-12-19 13:28:36 +01:00
|
|
|
}
|
2025-12-24 22:36:13 +11:00
|
|
|
const size_t prevChapterSize = (currentSpineIndex >= 1) ? getCumulativeSpineItemSize(currentSpineIndex - 1) : 0;
|
|
|
|
|
const size_t curChapterSize = getCumulativeSpineItemSize(currentSpineIndex) - prevChapterSize;
|
2026-01-19 06:55:35 -05:00
|
|
|
const float sectionProgSize = currentSpineRead * static_cast<float>(curChapterSize);
|
|
|
|
|
const float totalProgress = static_cast<float>(prevChapterSize) + sectionProgSize;
|
|
|
|
|
return totalProgress / static_cast<float>(bookSize);
|
2025-12-17 13:05:24 +01:00
|
|
|
}
|
feat: slim footnotes support (#1031)
## Summary
**What is the goal of this PR?** Implement support for footnotes in epub
files.
It is based on #553, but simplified — removed the parts which
complicated the code and burden the CPU/RAM. This version supports basic
footnotes and lets the user jump from location to location inside the
epub.
**What changes are included?**
- `FootnoteEntry` struct — A small POD struct (number[24], href[64])
shared between parser, page storage, and UI.
- Parser: `<a href>` detection (`ChapterHtmlSlimParser`) — During a
single parsing pass, internal epub links are detected and collected as
footnotes. The link text is underlined to hint navigability.
Bracket/whitespace normalization is applied to the display label (e.g.
[1] → 1).
- Footnote-to-page assignment (`ChapterHtmlSlimParser`, `Page`) —
Footnotes are attached to the exact page where their anchor word
appears, tracked via a cumulative word counter during layout, surviving
paragraph splits and the 750-word mid-paragraph safety flush.
- Page serialization (`Page`, `Section`) — Footnotes are
serialized/deserialized per page (max 16 per page). Section cache
version bumped to 14 to force a clean rebuild.
- Href → spine resolution (`Epub`) — `resolveHrefToSpineIndex()` maps an
href (e.g. `chapter2.xhtml#note1`) to its spine index by filename
matching.
- Footnotes menu + activity (`EpubReaderMenuActivity`,
`EpubReaderFootnotesActivity`) — A new "Footnotes" entry in the reader
menu lists all footnote links found on the current page. The user
scrolls and selects to navigate.
- Navigate & restore (`EpubReaderActivity`) — `navigateToHref()` saves
the current spine index and page number, then jumps to the target. The
Back button restores the saved position when the user is done reading
the footnote.
**Additional Context**
**What was removed vs #553:** virtual spine items
(`addVirtualSpineItem`, `isVirtualSpineItem`), two-pass parsing,
`<aside>` content extraction to temp HTML files, `<p class="note">`
paragraph note extraction, `replaceHtmlEntities` (master already has
`lookupHtmlEntity`), `footnotePages` / `buildFilteredChapterList`,
`noterefCallback` / `Noteref` struct, and the stack size increase from 8
KB to 24 KB (not needed without two-pass parsing and virtual file I/O on
the render task).
**Performance:** Single-pass parsing. No new heap allocations in the hot
path — footnote text is collected into fixed stack buffers (char[24],
char[64]). Active runtime memory is ~2.8 KB worst-case (one page × 16
footnotes × 88 bytes, mirrored in `currentPageFootnotes`). Flash usage
is unchanged at 97.4%; RAM stays at 31%.
**Known limitations:** When clicking a footnote, it jumps to the start
of the HTML file instead of the specific anchor. This could be
problematic for books that don't have separate files for each footnote.
(no element-id-to-page mapping yet - will be another PR soon).
---
### AI Usage
Did you use AI tools to help write this code? _**< PARTIALLY>**_
Claude Opus 4.6 was used to do most of the migration, I checked manually
its work, and fixed some stuff, but I haven't review all the changes
yet, so feedback is welcomed.
---------
Co-authored-by: Arthur Tazhitdinov <lisnake@gmail.com>
2026-02-26 16:47:34 +02:00
|
|
|
|
|
|
|
|
int Epub::resolveHrefToSpineIndex(const std::string& href) const {
|
|
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) return -1;
|
|
|
|
|
|
|
|
|
|
// Extract filename (remove #anchor)
|
|
|
|
|
std::string target = href;
|
|
|
|
|
size_t hashPos = target.find('#');
|
|
|
|
|
if (hashPos != std::string::npos) target = target.substr(0, hashPos);
|
|
|
|
|
|
|
|
|
|
// Same-file reference (anchor-only)
|
|
|
|
|
if (target.empty()) return -1;
|
|
|
|
|
|
|
|
|
|
// Extract just the filename for comparison
|
|
|
|
|
size_t targetSlash = target.find_last_of('/');
|
|
|
|
|
std::string targetFilename = (targetSlash != std::string::npos) ? target.substr(targetSlash + 1) : target;
|
|
|
|
|
|
|
|
|
|
for (int i = 0; i < getSpineItemsCount(); i++) {
|
|
|
|
|
const auto& spineHref = getSpineItem(i).href;
|
|
|
|
|
// Try exact match first
|
|
|
|
|
if (spineHref == target) return i;
|
|
|
|
|
// Then filename-only match
|
|
|
|
|
size_t spineSlash = spineHref.find_last_of('/');
|
|
|
|
|
std::string spineFilename = (spineSlash != std::string::npos) ? spineHref.substr(spineSlash + 1) : spineHref;
|
|
|
|
|
if (spineFilename == targetFilename) return i;
|
|
|
|
|
}
|
|
|
|
|
return -1;
|
|
|
|
|
}
|