Merge remote-tracking branch 'origin/develop' into feat-bluetooth

This commit is contained in:
Justin Mitchell
2026-07-14 04:20:50 -04:00
118 changed files with 22879 additions and 6746 deletions
+19 -59
View File
@@ -153,18 +153,11 @@ bool Epub::parseTocNcxFile() const {
LOG_DBG("EBP", "Parsing toc ncx file: %s", tocNcxItem.c_str());
const auto tmpNcxPath = getCachePath() + "/toc.ncx";
HalFile tempNcxFile;
if (!Storage.openFileForWrite("EBP", tmpNcxPath, tempNcxFile)) {
size_t ncxSize;
if (!getItemSize(tocNcxItem, &ncxSize)) {
LOG_ERR("EBP", "Could not get size of toc ncx file");
return false;
}
readItemContentsToStream(tocNcxItem, tempNcxFile, 1024);
// Explicitly close() file before reopening for reading
tempNcxFile.close();
if (!Storage.openFileForRead("EBP", tmpNcxPath, tempNcxFile)) {
return false;
}
const auto ncxSize = tempNcxFile.size();
TocNcxParser ncxParser(contentBasePath, ncxSize, bookMetadataCache.get());
@@ -173,29 +166,13 @@ bool Epub::parseTocNcxFile() const {
return false;
}
const auto ncxBuffer = static_cast<uint8_t*>(malloc(1024));
if (!ncxBuffer) {
LOG_ERR("EBP", "Could not allocate memory for toc ncx parser");
// Stream the decompressed NCX straight into the parser instead of round-tripping
// through a temp file on the SD card (decompress -> write -> reopen -> reread -> delete).
if (!readItemContentsToStream(tocNcxItem, ncxParser, 1024)) {
LOG_ERR("EBP", "Could not read toc ncx file");
return false;
}
while (tempNcxFile.available()) {
const auto readSize = tempNcxFile.read(ncxBuffer, 1024);
if (readSize == 0) break;
const auto processedSize = ncxParser.write(ncxBuffer, readSize);
if (processedSize != readSize) {
LOG_ERR("EBP", "Could not process all toc ncx data");
free(ncxBuffer);
return false;
}
}
free(ncxBuffer);
// Explicitly close() file before calling Storage.remove()
tempNcxFile.close();
Storage.remove(tmpNcxPath.c_str());
LOG_DBG("EBP", "Parsed TOC items");
return true;
}
@@ -209,18 +186,11 @@ bool Epub::parseTocNavFile() const {
LOG_DBG("EBP", "Parsing toc nav file: %s", tocNavItem.c_str());
const auto tmpNavPath = getCachePath() + "/toc.nav";
HalFile tempNavFile;
if (!Storage.openFileForWrite("EBP", tmpNavPath, tempNavFile)) {
size_t navSize;
if (!getItemSize(tocNavItem, &navSize)) {
LOG_ERR("EBP", "Could not get size of toc nav file");
return false;
}
readItemContentsToStream(tocNavItem, tempNavFile, 1024);
// Explicitly close() file before reopening for reading
tempNavFile.close();
if (!Storage.openFileForRead("EBP", tmpNavPath, tempNavFile)) {
return false;
}
const auto navSize = tempNavFile.size();
// 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
@@ -232,28 +202,13 @@ bool Epub::parseTocNavFile() const {
return false;
}
const auto navBuffer = static_cast<uint8_t*>(malloc(1024));
if (!navBuffer) {
LOG_ERR("EBP", "Could not allocate memory for toc nav parser");
// Stream the decompressed nav document straight into the parser instead of round-tripping
// through a temp file on the SD card (decompress -> write -> reopen -> reread -> delete).
if (!readItemContentsToStream(tocNavItem, navParser, 1024)) {
LOG_ERR("EBP", "Could not read toc nav file");
return false;
}
while (tempNavFile.available()) {
const auto readSize = tempNavFile.read(navBuffer, 1024);
const auto processedSize = navParser.write(navBuffer, readSize);
if (processedSize != readSize) {
LOG_ERR("EBP", "Could not process all toc nav data");
free(navBuffer);
return false;
}
}
free(navBuffer);
// Explicitly close() file before calling Storage.remove()
tempNavFile.close();
Storage.remove(tmpNavPath.c_str());
LOG_DBG("EBP", "Parsed TOC nav items");
return true;
}
@@ -396,6 +351,11 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
Storage.removeDir((cachePath + "/sections").c_str());
}
}
// Release the resolved CSS rule map: it is only needed transiently while building
// section caches, and createSectionFile reloads it from cache on demand. Holding it
// resident pins tens of KB for the whole reading session (more on warm resume into
// an already-cached chapter, where createSectionFile never runs to clear it).
cssParser->clear();
LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str());
return true;
}
+129 -58
View File
@@ -1,5 +1,6 @@
#include "BookMetadataCache.h"
#include <BufferedFile.h>
#include <Logging.h>
#include <Serialization.h>
#include <Utf8.h>
@@ -14,6 +15,52 @@ constexpr uint8_t BOOK_CACHE_VERSION = 8; // v8: TOC/book titles stored NFC-com
constexpr char bookBinFile[] = "/book.bin";
constexpr char tmpSpineBinFile[] = "/spine.bin.tmp";
constexpr char tmpTocBinFile[] = "/toc.bin.tmp";
// Buffer size for the buildBookBin streams. 3 buffers x 4KB, transient (freed on
// return); 4KB = 8 SD sectors per transfer, enough to stop the sector-cache thrash.
constexpr size_t BUILD_IO_BUFFER_SIZE = 4096;
// Entry (de)serializers, templated so they run over HalFile and the Buffered*
// wrappers alike (two instantiations each -- a few hundred bytes of flash, in
// exchange for the build path streaming at SD speed instead of per-pod).
template <typename F>
uint32_t writeSpineEntryTo(F& file, const BookMetadataCache::SpineEntry& entry) {
const uint32_t pos = file.position();
serialization::writeString(file, entry.href);
serialization::writePod(file, entry.cumulativeSize);
serialization::writePod(file, entry.tocIndex);
return pos;
}
template <typename F>
uint32_t writeTocEntryTo(F& file, const BookMetadataCache::TocEntry& entry) {
const uint32_t pos = file.position();
serialization::writeString(file, entry.title);
serialization::writeString(file, entry.href);
serialization::writeString(file, entry.anchor);
serialization::writePod(file, entry.level);
serialization::writePod(file, entry.spineIndex);
return pos;
}
template <typename F>
BookMetadataCache::SpineEntry readSpineEntryFrom(F& file) {
BookMetadataCache::SpineEntry entry;
serialization::readString(file, entry.href);
serialization::readPod(file, entry.cumulativeSize);
serialization::readPod(file, entry.tocIndex);
return entry;
}
template <typename F>
BookMetadataCache::TocEntry readTocEntryFrom(F& file) {
BookMetadataCache::TocEntry entry;
serialization::readString(file, entry.title);
serialization::readString(file, entry.href);
serialization::readString(file, entry.anchor);
serialization::readPod(file, entry.level);
serialization::readPod(file, entry.spineIndex);
return entry;
}
} // namespace
/* ============= WRITING / BUILDING FUNCTIONS ================ */
@@ -30,13 +77,23 @@ bool BookMetadataCache::beginContentOpfPass() {
LOG_DBG("BMC", "Beginning content opf pass");
// Open spine file for writing
return Storage.openFileForWrite("BMC", cachePath + tmpSpineBinFile, spineFile);
if (!Storage.openFileForWrite("BMC", cachePath + tmpSpineBinFile, spineFile)) {
return false;
}
// Wrapper OOM is fine: createSpineEntry falls back to unbuffered writes.
passOut = makeUniqueNoThrow<serialization::BufferedFileWriter>(spineFile, BUILD_IO_BUFFER_SIZE);
return true;
}
bool BookMetadataCache::endContentOpfPass() {
const bool flushed = !passOut || passOut->flush();
passOut.reset();
// Explicit close() required: member variable persists beyond function scope
spineFile.close();
return true;
if (!flushed) {
LOG_ERR("BMC", "Failed writing spine tmp file");
}
return flushed;
}
bool BookMetadataCache::beginTocPass() {
@@ -74,10 +131,17 @@ bool BookMetadataCache::beginTocPass() {
useSpineHrefIndex = false;
}
// Wrapper OOM is fine: createTocEntry falls back to unbuffered writes.
passOut = makeUniqueNoThrow<serialization::BufferedFileWriter>(tocFile, BUILD_IO_BUFFER_SIZE);
return true;
}
bool BookMetadataCache::endTocPass() {
const bool flushed = !passOut || passOut->flush();
passOut.reset();
if (!flushed) {
LOG_ERR("BMC", "Failed writing toc tmp file");
}
// Explicit close() required: member variables persist beyond function scope
tocFile.close();
spineFile.close();
@@ -86,7 +150,7 @@ bool BookMetadataCache::endTocPass() {
spineHrefIndex.shrink_to_fit();
useSpineHrefIndex = false;
return true;
return flushed;
}
bool BookMetadataCache::endWrite() {
@@ -119,6 +183,14 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
return false;
}
// Buffered streams for the whole build: every access below is sequential per
// file, but interleaved ACROSS files, which thrashes SdFat's single shared
// sector cache when unbuffered (one 512B SD transaction per 4-byte pod --
// measured 31s for a 1,732-spine omnibus). Three 4KB buffers, freed on return.
serialization::BufferedFileWriter bookOut(bookFile, BUILD_IO_BUFFER_SIZE);
serialization::BufferedFileReader spineIn(spineFile, BUILD_IO_BUFFER_SIZE);
serialization::BufferedFileReader tocIn(tocFile, BUILD_IO_BUFFER_SIZE);
constexpr uint32_t headerASize =
sizeof(BOOK_CACHE_VERSION) + /* LUT Offset */ sizeof(uint32_t) + sizeof(spineCount) + sizeof(tocCount);
const uint32_t metadataSize = metadata.title.size() + metadata.author.size() + metadata.language.size() +
@@ -128,31 +200,34 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
const uint32_t lutOffset = headerASize + metadataSize;
// Header A
serialization::writePod(bookFile, BOOK_CACHE_VERSION);
serialization::writePod(bookFile, lutOffset);
serialization::writePod(bookFile, spineCount);
serialization::writePod(bookFile, tocCount);
serialization::writePod(bookOut, BOOK_CACHE_VERSION);
serialization::writePod(bookOut, lutOffset);
serialization::writePod(bookOut, spineCount);
serialization::writePod(bookOut, tocCount);
// Metadata
serialization::writeString(bookFile, metadata.title);
serialization::writeString(bookFile, metadata.author);
serialization::writeString(bookFile, metadata.language);
serialization::writeString(bookFile, metadata.coverItemHref);
serialization::writeString(bookFile, metadata.textReferenceHref);
serialization::writeString(bookOut, metadata.title);
serialization::writeString(bookOut, metadata.author);
serialization::writeString(bookOut, metadata.language);
serialization::writeString(bookOut, metadata.coverItemHref);
serialization::writeString(bookOut, metadata.textReferenceHref);
// Loop through spine entries, writing LUT positions
spineFile.seek(0);
spineIn.seek(0);
for (int i = 0; i < spineCount; i++) {
uint32_t pos = spineFile.position();
auto spineEntry = readSpineEntry(spineFile);
serialization::writePod(bookFile, pos + lutOffset + lutSize);
const uint32_t pos = spineIn.position();
readSpineEntryFrom(spineIn);
serialization::writePod(bookOut, pos + lutOffset + lutSize);
}
// Total size of the spine tmp file: entries land in book.bin after the toc LUT
// and the full spine block, so toc LUT positions are offset by it.
const auto spineBytes = static_cast<uint32_t>(spineIn.position());
// Loop through toc entries, writing LUT positions
tocFile.seek(0);
tocIn.seek(0);
for (int i = 0; i < tocCount; i++) {
uint32_t pos = tocFile.position();
auto tocEntry = readTocEntry(tocFile);
serialization::writePod(bookFile, pos + lutOffset + lutSize + static_cast<uint32_t>(spineFile.position()));
const uint32_t pos = tocIn.position();
readTocEntryFrom(tocIn);
serialization::writePod(bookOut, pos + lutOffset + lutSize + spineBytes);
}
// LUTs complete
@@ -160,9 +235,9 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
// Build spineIndex->tocIndex mapping in one pass (O(n) instead of O(n*m))
std::deque<int16_t> spineToTocIndex(spineCount, -1);
tocFile.seek(0);
tocIn.seek(0);
for (int j = 0; j < tocCount; j++) {
auto tocEntry = readTocEntry(tocFile);
auto tocEntry = readTocEntryFrom(tocIn);
if (tocEntry.spineIndex >= 0 && tocEntry.spineIndex < spineCount) {
if (spineToTocIndex[tocEntry.spineIndex] == -1) {
spineToTocIndex[tocEntry.spineIndex] = static_cast<int16_t>(j);
@@ -197,9 +272,9 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
std::deque<ZipFile::SizeTarget> targets;
targets.resize(spineCount);
spineFile.seek(0);
spineIn.seek(0);
for (int i = 0; i < spineCount; i++) {
auto entry = readSpineEntry(spineFile);
auto entry = readSpineEntryFrom(spineIn);
std::string path = FsHelpers::normalisePath(entry.href);
ZipFile::SizeTarget t;
@@ -224,10 +299,10 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
}
uint32_t cumSize = 0;
spineFile.seek(0);
spineIn.seek(0);
int lastSpineTocIndex = -1;
for (int i = 0; i < spineCount; i++) {
auto spineEntry = readSpineEntry(spineFile);
auto spineEntry = readSpineEntryFrom(spineIn);
spineEntry.tocIndex = spineToTocIndex[i];
@@ -260,23 +335,33 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
spineEntry.cumulativeSize = cumSize;
// Write out spine data to book.bin
writeSpineEntry(bookFile, spineEntry);
writeSpineEntryTo(bookOut, spineEntry);
}
// Close opened zip file
zip.close();
// Loop through toc entries from toc file writing to book.bin
tocFile.seek(0);
tocIn.seek(0);
for (int i = 0; i < tocCount; i++) {
auto tocEntry = readTocEntry(tocFile);
writeTocEntry(bookFile, tocEntry);
auto tocEntry = readTocEntryFrom(tocIn);
writeTocEntryTo(bookOut, tocEntry);
}
const bool written = bookOut.flush();
// Explicit close() required: member variables persist beyond function scope
bookFile.close();
spineFile.close();
tocFile.close();
if (!written) {
// A short write (card full/removed) would leave a truncated book.bin that
// still passes the version check on load; remove it so the next open rebuilds.
LOG_ERR("BMC", "Failed writing book.bin, removing truncated file");
Storage.remove((cachePath + bookBinFile).c_str());
return false;
}
LOG_DBG("BMC", "Successfully built book.bin");
return true;
}
@@ -294,21 +379,11 @@ bool BookMetadataCache::cleanupTmpFiles() const {
}
uint32_t BookMetadataCache::writeSpineEntry(HalFile& file, const SpineEntry& entry) const {
const uint32_t pos = file.position();
serialization::writeString(file, entry.href);
serialization::writePod(file, entry.cumulativeSize);
serialization::writePod(file, entry.tocIndex);
return pos;
return writeSpineEntryTo(file, entry);
}
uint32_t BookMetadataCache::writeTocEntry(HalFile& file, const TocEntry& entry) const {
const uint32_t pos = file.position();
serialization::writeString(file, entry.title);
serialization::writeString(file, entry.href);
serialization::writeString(file, entry.anchor);
serialization::writePod(file, entry.level);
serialization::writePod(file, entry.spineIndex);
return pos;
return writeTocEntryTo(file, entry);
}
// Note: for the LUT to be accurate, this **MUST** be called for all spine items before `addTocEntry` is ever called
@@ -320,7 +395,11 @@ void BookMetadataCache::createSpineEntry(const std::string& href) {
}
const SpineEntry entry(href, 0, -1);
writeSpineEntry(spineFile, entry);
if (passOut) {
writeSpineEntryTo(*passOut, entry);
} else {
writeSpineEntry(spineFile, entry);
}
spineCount++;
}
@@ -368,7 +447,11 @@ void BookMetadataCache::createTocEntry(const std::string& title, const std::stri
// Compose the title to NFC at index time so the cache stores precomposed glyphs;
// device fonts have no combining-mark positioning, so NFD titles render broken.
const TocEntry entry(utf8ComposeNfc(title), href, anchor, level, spineIndex);
writeTocEntry(tocFile, entry);
if (passOut) {
writeTocEntryTo(*passOut, entry);
} else {
writeTocEntry(tocFile, entry);
}
tocCount++;
}
@@ -442,19 +525,7 @@ BookMetadataCache::TocEntry BookMetadataCache::getTocEntry(const int index) {
}
BookMetadataCache::SpineEntry BookMetadataCache::readSpineEntry(HalFile& file) const {
SpineEntry entry;
serialization::readString(file, entry.href);
serialization::readPod(file, entry.cumulativeSize);
serialization::readPod(file, entry.tocIndex);
return entry;
return readSpineEntryFrom(file);
}
BookMetadataCache::TocEntry BookMetadataCache::readTocEntry(HalFile& file) const {
TocEntry entry;
serialization::readString(file, entry.title);
serialization::readString(file, entry.href);
serialization::readString(file, entry.anchor);
serialization::readPod(file, entry.level);
serialization::readPod(file, entry.spineIndex);
return entry;
}
BookMetadataCache::TocEntry BookMetadataCache::readTocEntry(HalFile& file) const { return readTocEntryFrom(file); }
+7
View File
@@ -1,9 +1,11 @@
#pragma once
#include <BufferedFile.h>
#include <HalStorage.h>
#include <algorithm>
#include <deque>
#include <memory>
#include <string>
class BookMetadataCache {
@@ -54,6 +56,11 @@ class BookMetadataCache {
// Temp file handles during build
HalFile spineFile;
HalFile tocFile;
// Buffers the per-entry tmp-file writes during the OPF/TOC passes: those
// writes interleave with zip-inflate SD reads, and unbuffered they thrash
// SdFat's shared sector cache (one 512B transaction per 4-byte pod). One
// wrapper serves whichever pass is active (spine, then toc).
std::unique_ptr<serialization::BufferedFileWriter> passOut;
// Index for fast href→spineIndex lookup (used only for large EPUBs)
struct SpineHrefIndexEntry {
+15
View File
@@ -57,6 +57,10 @@ void PageImage::render(GfxRenderer& renderer, const int fontId, const int xOffse
imageBlock->render(renderer, xPos + xOffset, yPos + yOffset);
}
void PageImage::renderPlaceholder(GfxRenderer& renderer, const int xOffset, const int yOffset) const {
imageBlock->renderPlaceholder(renderer, xPos + xOffset, yPos + yOffset);
}
bool PageImage::serialize(HalFile& file) {
serialization::writePod(file, xPos);
serialization::writePod(file, yPos);
@@ -125,6 +129,17 @@ void Page::renderImages(GfxRenderer& renderer, const int fontId, const int xOffs
[](const PageElement& element) { return element.getTag() == TAG_PageImage; });
}
void Page::renderWithImagePlaceholders(GfxRenderer& renderer, const int fontId, const int xOffset,
const int yOffset) const {
for (const auto& element : elements) {
if (element->getTag() == TAG_PageImage) {
static_cast<const PageImage&>(*element).renderPlaceholder(renderer, xOffset, yOffset);
} else {
element->render(renderer, fontId, xOffset, yOffset);
}
}
}
bool Page::serialize(HalFile& file) const {
const uint16_t count = elements.size();
serialization::writePod(file, count);
+9
View File
@@ -50,6 +50,7 @@ class PageImage final : public PageElement {
PageImage(std::shared_ptr<ImageBlock> block, const int16_t xPos, const int16_t yPos)
: PageElement(xPos, yPos), imageBlock(std::move(block)) {}
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
void renderPlaceholder(GfxRenderer& renderer, int xOffset, int yOffset) const;
bool serialize(HalFile& file) override;
PageElementTag getTag() const override { return TAG_PageImage; }
static std::unique_ptr<PageImage> deserialize(HalFile& file);
@@ -89,6 +90,7 @@ class Page {
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
void renderImages(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
void renderWithImagePlaceholders(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
bool serialize(HalFile& file) const;
static std::unique_ptr<Page> deserialize(HalFile& file);
@@ -98,6 +100,13 @@ class Page {
[](const std::shared_ptr<PageElement>& el) { return el->getTag() == TAG_PageImage; });
}
bool hasImagesNeedingDecode() const {
return std::any_of(elements.begin(), elements.end(), [](const std::shared_ptr<PageElement>& element) {
return element->getTag() == TAG_PageImage &&
static_cast<const PageImage&>(*element).getImageBlock().needsDecode();
});
}
// Get bounding box of all images on the page (union of image rects)
// Returns false if no images. Coordinates are relative to page origin.
bool getImageBoundingBox(int16_t& outX, int16_t& outY, int16_t& outW, int16_t& outH) const {
+5 -1
View File
@@ -11,9 +11,13 @@
#include "parsers/ChapterHtmlSlimParser.h"
namespace {
// v28: text decoration bits now include line-through in serialized wordStyles.
// v29: TextBlock word data stored as one flat arena (offset table + NUL-terminated
// text blob) instead of length-prefixed strings and per-field arrays.
constexpr uint8_t SECTION_FILE_VERSION = 29;
// v30: Arabic shaping changed both drawing and measurement (getTextAdvanceX now
// measures the shaped visual text); cached word positions from v29 no longer
// match what drawText renders.
constexpr uint8_t SECTION_FILE_VERSION = 30;
// Written into the version field while a build is in progress; patched to
// SECTION_FILE_VERSION only when the build is finalized. An abandoned /
// crash-interrupted .bin therefore carries version 0, which loadSectionFile rejects
+87 -10
View File
@@ -5,6 +5,8 @@
#include <Logging.h>
#include <Serialization.h>
#include <cstdlib>
#include "Epub/converters/DirectPixelWriter.h"
#include "Epub/converters/ImageDecoderFactory.h"
@@ -29,6 +31,54 @@ std::string getCachePath(const std::string& imagePath) {
return imagePath + ".pxc";
}
bool readValidCacheHeader(HalFile& cacheFile, const int expectedWidth, const int expectedHeight, uint16_t& cachedWidth,
uint16_t& cachedHeight) {
if (cacheFile.read(&cachedWidth, 2) != 2 || cacheFile.read(&cachedHeight, 2) != 2) {
return false;
}
const int widthDiff = abs(cachedWidth - expectedWidth);
const int heightDiff = abs(cachedHeight - expectedHeight);
if (widthDiff > 1 || heightDiff > 1) {
return false;
}
const size_t bytesPerRow = (cachedWidth + 3) / 4;
const size_t expectedSize = 4 + bytesPerRow * cachedHeight;
return cacheFile.size() >= expectedSize;
}
// Pages are deserialized afresh on each visit. Keep a bounded, allocation-free
// record so an image that failed renders its placeholder directly for the rest
// of the reader session instead of paying another placeholder refresh and
// decode. The reader clears this on entry so transient memory/storage failures
// are retried.
constexpr size_t MAX_SESSION_IMAGE_FAILURES = 16;
uint64_t failedImageHashes[MAX_SESSION_IMAGE_FAILURES];
size_t failedImageCount = 0;
uint64_t imagePathHash(const std::string& path) {
uint64_t hash = 14695981039346656037ull;
for (const char c : path) {
hash ^= static_cast<uint8_t>(c);
hash *= 1099511628211ull;
}
return hash;
}
bool imageFailedThisSession(const std::string& path) {
const uint64_t hash = imagePathHash(path);
for (size_t i = 0; i < failedImageCount; i++) {
if (failedImageHashes[i] == hash) return true;
}
return false;
}
void rememberImageFailure(const std::string& path) {
if (failedImageCount == MAX_SESSION_IMAGE_FAILURES || imageFailedThisSession(path)) return;
failedImageHashes[failedImageCount++] = imagePathHash(path);
}
bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, int y, int expectedWidth,
int expectedHeight) {
HalFile cacheFile;
@@ -37,16 +87,8 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x,
}
uint16_t cachedWidth, cachedHeight;
if (cacheFile.read(&cachedWidth, 2) != 2 || cacheFile.read(&cachedHeight, 2) != 2) {
return false;
}
// Verify dimensions are close (allow 1 pixel tolerance for rounding differences)
int widthDiff = abs(cachedWidth - expectedWidth);
int heightDiff = abs(cachedHeight - expectedHeight);
if (widthDiff > 1 || heightDiff > 1) {
LOG_ERR("IMG", "Cache dimension mismatch: %dx%d vs %dx%d", cachedWidth, cachedHeight, expectedWidth,
expectedHeight);
if (!readValidCacheHeader(cacheFile, expectedWidth, expectedHeight, cachedWidth, cachedHeight)) {
LOG_ERR("IMG", "Invalid image cache: %s", cachePath.c_str());
return false;
}
@@ -119,6 +161,28 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x,
} // namespace
bool ImageBlock::hasValidCache() const {
const auto cachePath = getCachePath(imagePath);
HalFile cacheFile;
if (!Storage.openFileForRead("IMG", cachePath, cacheFile)) {
return false;
}
uint16_t cachedWidth, cachedHeight;
return readValidCacheHeader(cacheFile, width, height, cachedWidth, cachedHeight);
}
bool ImageBlock::needsDecode() const { return !imageFailedThisSession(imagePath) && !hasValidCache(); }
void ImageBlock::clearSessionRenderFailures() { failedImageCount = 0; }
void ImageBlock::renderPlaceholder(GfxRenderer& renderer, const int x, const int y) const {
renderer.fillRect(x, y, width, height, true);
if (width > 2 && height > 2) {
renderer.fillRect(x + 1, y + 1, width - 2, height - 2, false);
}
}
void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
// The font-prewarm scan pass only accumulates glyphs; an image contributes
// none, and its DirectPixelWriter output bypasses the renderer's scan-mode
@@ -150,6 +214,11 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
return;
}
if (imageFailedThisSession(imagePath)) {
renderPlaceholder(renderer, x, y);
return;
}
// Try to render from cache first
std::string cachePath = getCachePath(imagePath);
if (renderFromCache(renderer, cachePath, x, y, width, height)) {
@@ -161,6 +230,8 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
HalFile file;
if (!Storage.openFileForRead("IMG", imagePath, file)) {
LOG_ERR("IMG", "Image file not found: %s", imagePath.c_str());
rememberImageFailure(imagePath);
renderPlaceholder(renderer, x, y);
return;
}
size_t fileSize = file.size();
@@ -168,6 +239,8 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
if (fileSize == 0) {
LOG_ERR("IMG", "Image file is empty: %s", imagePath.c_str());
rememberImageFailure(imagePath);
renderPlaceholder(renderer, x, y);
return;
}
@@ -187,6 +260,8 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(imagePath);
if (!decoder) {
LOG_ERR("IMG", "No decoder found for image: %s", imagePath.c_str());
rememberImageFailure(imagePath);
renderPlaceholder(renderer, x, y);
return;
}
@@ -195,6 +270,8 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
bool success = decoder->decodeToFramebuffer(imagePath, renderer, config);
if (!success) {
LOG_ERR("IMG", "Failed to decode image: %s", imagePath.c_str());
rememberImageFailure(imagePath);
renderPlaceholder(renderer, x, y);
return;
}
+4
View File
@@ -16,6 +16,10 @@ class ImageBlock final : public Block {
int16_t getHeight() const { return height; }
bool imageExists() const;
bool hasValidCache() const;
bool needsDecode() const;
void renderPlaceholder(GfxRenderer& renderer, int x, int y) const;
static void clearSessionRenderFailures();
BlockType getType() override { return IMAGE_BLOCK; }
bool isEmpty() override { return false; }
@@ -99,24 +99,58 @@ int bytesPerPixelFromType(int pixelType) {
}
}
int requiredPngInternalBufferBytes(int srcWidth, int pixelType) {
int packedRowBytes(int srcWidth, int bitsPerSample) { return (srcWidth * bitsPerSample + 7) / 8; }
int requiredPngInternalBufferBytes(int srcWidth, int pixelType, int bitsPerSample) {
// +1 filter byte per scanline, *2 for current+previous lines, +32 for alignment margin.
int pitch = srcWidth * bytesPerPixelFromType(pixelType);
if ((pixelType == PNG_PIXEL_GRAYSCALE || pixelType == PNG_PIXEL_INDEXED) && bitsPerSample < 8) {
pitch = packedRowBytes(srcWidth, bitsPerSample);
}
return ((pitch + 1) * 2) + 32;
}
bool isSupportedBitDepth(int pixelType, int bitsPerSample) {
if (bitsPerSample == 8) return true;
if (bitsPerSample != 1 && bitsPerSample != 2 && bitsPerSample != 4) return false;
return pixelType == PNG_PIXEL_GRAYSCALE || pixelType == PNG_PIXEL_INDEXED;
}
uint8_t readPackedSample(const uint8_t* pixels, int x, int bitsPerSample) {
if (bitsPerSample == 8) return pixels[x];
const int bitOffset = x * bitsPerSample;
const int shift = 8 - bitsPerSample - (bitOffset & 7);
const uint8_t mask = (1U << bitsPerSample) - 1;
return (pixels[bitOffset >> 3] >> shift) & mask;
}
uint8_t expandSampleToByte(uint8_t sample, int bitsPerSample) {
if (bitsPerSample == 8) return sample;
const uint8_t maxSample = (1U << bitsPerSample) - 1;
return static_cast<uint8_t>((sample * 255U) / maxSample);
}
// Convert entire source line to grayscale with alpha blending to white background.
// Low-bit-depth grayscale/indexed scanlines are packed most-significant sample first.
// For indexed PNGs with tRNS chunk, alpha values are stored at palette[768] onwards.
// Processing the whole line at once improves cache locality and reduces per-pixel overhead.
void convertLineToGray(uint8_t* pPixels, uint8_t* grayLine, int width, int pixelType, uint8_t* palette, int hasAlpha) {
void convertLineToGray(const uint8_t* pPixels, uint8_t* grayLine, int width, int pixelType, int bitsPerSample,
uint8_t* palette, int hasAlpha) {
switch (pixelType) {
case PNG_PIXEL_GRAYSCALE:
memcpy(grayLine, pPixels, width);
if (bitsPerSample == 8) {
memcpy(grayLine, pPixels, width);
} else {
for (int x = 0; x < width; x++) {
grayLine[x] = expandSampleToByte(readPackedSample(pPixels, x, bitsPerSample), bitsPerSample);
}
}
break;
case PNG_PIXEL_TRUECOLOR:
for (int x = 0; x < width; x++) {
uint8_t* p = &pPixels[x * 3];
const uint8_t* p = &pPixels[x * 3];
grayLine[x] = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
}
break;
@@ -125,7 +159,7 @@ void convertLineToGray(uint8_t* pPixels, uint8_t* grayLine, int width, int pixel
if (palette) {
if (hasAlpha) {
for (int x = 0; x < width; x++) {
uint8_t idx = pPixels[x];
uint8_t idx = readPackedSample(pPixels, x, bitsPerSample);
uint8_t* p = &palette[idx * 3];
uint8_t gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
uint8_t alpha = palette[768 + idx];
@@ -133,12 +167,15 @@ void convertLineToGray(uint8_t* pPixels, uint8_t* grayLine, int width, int pixel
}
} else {
for (int x = 0; x < width; x++) {
uint8_t* p = &palette[pPixels[x] * 3];
uint8_t idx = readPackedSample(pPixels, x, bitsPerSample);
uint8_t* p = &palette[idx * 3];
grayLine[x] = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
}
}
} else {
memcpy(grayLine, pPixels, width);
for (int x = 0; x < width; x++) {
grayLine[x] = expandSampleToByte(readPackedSample(pPixels, x, bitsPerSample), bitsPerSample);
}
}
break;
@@ -152,7 +189,7 @@ void convertLineToGray(uint8_t* pPixels, uint8_t* grayLine, int width, int pixel
case PNG_PIXEL_TRUECOLOR_ALPHA:
for (int x = 0; x < width; x++) {
uint8_t* p = &pPixels[x * 4];
const uint8_t* p = &pPixels[x * 4];
uint8_t gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
uint8_t alpha = p[3];
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
@@ -172,74 +209,83 @@ int pngDrawCallback(PNGDRAW* pDraw) {
int srcY = pDraw->y;
int srcWidth = ctx->srcWidth;
// Calculate destination Y with scaling
int dstY = (int)(srcY * ctx->scale);
// Map source rows with the exact output-height ratio. During downscaling,
// multiple source rows can select the same output row; during upscaling, one
// source row must be repeated across every output row in its range. Emitting
// only the first row of an upscale leaves zero-filled (black) gaps in the
// streamed pixel cache.
int firstDstY = (srcY * ctx->dstHeight) / ctx->srcHeight;
int endDstY = firstDstY + 1;
if (ctx->dstHeight > ctx->srcHeight) {
endDstY = ((srcY + 1) * ctx->dstHeight) / ctx->srcHeight;
}
// Skip if we already rendered this destination row (multiple source rows map to same dest)
if (dstY == ctx->lastDstY) return 1;
ctx->lastDstY = dstY;
// Check bounds
if (dstY >= ctx->dstHeight) return 1;
int outY = ctx->config->y + dstY;
if (outY >= ctx->screenHeight) return 1;
if (firstDstY <= ctx->lastDstY) firstDstY = ctx->lastDstY + 1;
if (firstDstY >= endDstY || firstDstY >= ctx->dstHeight) return 1;
if (endDstY > ctx->dstHeight) endDstY = ctx->dstHeight;
// Convert entire source line to grayscale (improves cache locality)
convertLineToGray(pDraw->pPixels, ctx->grayLineBuffer, srcWidth, pDraw->iPixelType, pDraw->pPalette,
convertLineToGray(pDraw->pPixels, ctx->grayLineBuffer, srcWidth, pDraw->iPixelType, pDraw->iBpp, pDraw->pPalette,
pDraw->iHasAlpha);
// Render scaled row using Bresenham-style integer stepping (no floating-point division)
// Render scaled rows using Bresenham-style integer stepping (no floating-point division)
int dstWidth = ctx->dstWidth;
int outXBase = ctx->config->x;
int screenWidth = ctx->screenWidth;
bool useDithering = ctx->config->useDithering;
bool caching = ctx->caching;
// Pre-compute orientation and render-mode state once per row
// Pre-compute orientation and render-mode state once per callback.
DirectPixelWriter pw;
pw.init(*ctx->renderer);
pw.beginRow(outY);
// The cache streams to disk one row at a time. Flushing rows below this one
// (PNGdec delivers scanlines top to bottom) repositions the single-row band.
// A flush failure stops caching for the rest of the decode so we never write
// past the band buffer; finalize() then drops the partial file.
DirectCacheWriter cw;
if (caching) {
if (!ctx->cache.advanceTo(dstY)) {
caching = false;
ctx->caching = false;
} else {
cw.init(ctx->cache.buffer, ctx->cache.bytesPerRow, ctx->cache.bandRows, ctx->cache.originX);
cw.beginRow(outY, ctx->config->y + ctx->cache.bandStart);
}
}
for (int dstY = firstDstY; dstY < endDstY; dstY++) {
ctx->lastDstY = dstY;
int outY = ctx->config->y + dstY;
if (outY >= ctx->screenHeight) continue;
int srcX = 0;
int error = 0;
pw.beginRow(outY);
for (int dstX = 0; dstX < dstWidth; dstX++) {
int outX = outXBase + dstX;
if (outX < screenWidth) {
uint8_t gray = ctx->grayLineBuffer[srcX];
uint8_t ditheredGray;
if (useDithering) {
ditheredGray = applyBayerDither4Level(gray, outX, outY);
// The cache streams to disk one row at a time. Flushing rows below this one
// (PNGdec delivers scanlines top to bottom) repositions the single-row band.
// A flush failure stops caching for the rest of the decode so we never write
// past the band buffer; finalize() then drops the partial file.
bool caching = ctx->caching;
DirectCacheWriter cw;
if (caching) {
if (!ctx->cache.advanceTo(dstY)) {
caching = false;
ctx->caching = false;
} else {
ditheredGray = gray / 85;
if (ditheredGray > 3) ditheredGray = 3;
cw.init(ctx->cache.buffer, ctx->cache.bytesPerRow, ctx->cache.bandRows, ctx->cache.originX);
cw.beginRow(outY, ctx->config->y + ctx->cache.bandStart);
}
pw.writePixel(outX, ditheredGray);
if (caching) cw.writePixel(outX, ditheredGray);
}
// Bresenham-style stepping: advance srcX based on ratio srcWidth/dstWidth
error += srcWidth;
while (error >= dstWidth) {
error -= dstWidth;
srcX++;
int srcX = 0;
int error = 0;
for (int dstX = 0; dstX < dstWidth; dstX++) {
int outX = outXBase + dstX;
if (outX < screenWidth) {
uint8_t gray = ctx->grayLineBuffer[srcX];
uint8_t ditheredGray;
if (useDithering) {
ditheredGray = applyBayerDither4Level(gray, outX, outY);
} else {
ditheredGray = gray / 85;
if (ditheredGray > 3) ditheredGray = 3;
}
pw.writePixel(outX, ditheredGray);
if (caching) cw.writePixel(outX, ditheredGray);
}
// Bresenham-style stepping: advance srcX based on ratio srcWidth/dstWidth
error += srcWidth;
while (error >= dstWidth) {
error -= dstWidth;
srcX++;
}
}
}
@@ -332,30 +378,44 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
}
ctx.lastDstY = -1; // Reset row tracking
LOG_DBG("PNG", "PNG %dx%d -> %dx%d (scale %.2f), bpp: %d", ctx.srcWidth, ctx.srcHeight, ctx.dstWidth, ctx.dstHeight,
ctx.scale, png->getBpp());
const int pixelType = png->getPixelType();
const int requiredInternal = requiredPngInternalBufferBytes(ctx.srcWidth, pixelType);
const int bitsPerSample = png->getBpp();
LOG_DBG("PNG", "PNG %dx%d -> %dx%d (scale %.2f), type: %d, bpp: %d", ctx.srcWidth, ctx.srcHeight, ctx.dstWidth,
ctx.dstHeight, ctx.scale, pixelType, bitsPerSample);
const int requiredInternal = requiredPngInternalBufferBytes(ctx.srcWidth, pixelType, bitsPerSample);
if (requiredInternal > PNG_MAX_BUFFERED_PIXELS) {
LOG_ERR("PNG",
"PNG row buffer too small: need %d bytes for width=%d type=%d, configured PNG_MAX_BUFFERED_PIXELS=%d",
requiredInternal, ctx.srcWidth, pixelType, PNG_MAX_BUFFERED_PIXELS);
LOG_ERR(
"PNG",
"PNG row buffer too small: need %d bytes for width=%d type=%d bpp=%d, configured PNG_MAX_BUFFERED_PIXELS=%d",
requiredInternal, ctx.srcWidth, pixelType, bitsPerSample, PNG_MAX_BUFFERED_PIXELS);
LOG_ERR("PNG", "Aborting decode to avoid PNGdec internal buffer overflow");
return false;
}
if (png->getBpp() != 8) {
warnUnsupportedFeature("bit depth (" + std::to_string(png->getBpp()) + "bpp)", imagePath);
if (!isSupportedBitDepth(pixelType, bitsPerSample)) {
warnUnsupportedFeature(
"bit depth (" + std::to_string(bitsPerSample) + "bpp) for pixel type " + std::to_string(pixelType), imagePath);
return false;
}
// Allocate grayscale line buffer on demand (~3.2 KB) - freed after decode
const size_t grayBufSize = PNG_MAX_BUFFERED_PIXELS / 2;
ctx.grayLineBuffer = static_cast<uint8_t*>(malloc(grayBufSize));
if (!ctx.grayLineBuffer) {
// The converter expands each source row to 8-bit grayscale before dithering,
// so this scratch buffer is sized by source pixels even when PNGdec reads a
// packed 1/2/4-bit row internally.
constexpr size_t MAX_GRAY_LINE_BUFFER_BYTES = PNG_MAX_BUFFERED_PIXELS / 2;
const size_t grayBufSize = static_cast<size_t>(ctx.srcWidth);
if (grayBufSize > MAX_GRAY_LINE_BUFFER_BYTES) {
LOG_ERR("PNG", "Expanded gray row too wide: need %u bytes for width=%d, max=%u", static_cast<unsigned>(grayBufSize),
ctx.srcWidth, static_cast<unsigned>(MAX_GRAY_LINE_BUFFER_BYTES));
return false;
}
auto grayLineBuffer = makeUniqueNoThrow<uint8_t[]>(grayBufSize);
if (!grayLineBuffer) {
LOG_ERR("PNG", "Failed to allocate gray line buffer");
return false;
}
ctx.grayLineBuffer = grayLineBuffer.get();
// Stream the pixel cache to disk. PNGdec delivers source scanlines top to
// bottom and we emit at most one (downscaled) output row per callback, so the
@@ -375,7 +435,6 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
rc = png->decode(&ctx, 0);
unsigned long decodeTime = millis() - decodeStart;
free(ctx.grayLineBuffer);
ctx.grayLineBuffer = nullptr;
if (rc != PNG_SUCCESS) {
+3
View File
@@ -803,6 +803,9 @@ bool CssParser::loadFromCache() {
return false;
}
// Size the bucket array up front to avoid incremental rehashes while loading rules.
rulesBySelector_.reserve(ruleCount);
auto hasRemainingBytes = [&file](const size_t neededBytes) -> bool {
return static_cast<size_t>(file.available()) >= neededBytes;
};
+1 -1
View File
@@ -24,7 +24,7 @@ struct Iso639Mapping {
};
static constexpr Iso639Mapping kIso639Mappings[] = {{"eng", "en"}, {"fra", "fr"}, {"fre", "fr"}, {"deu", "de"},
{"ger", "de"}, {"rus", "ru"}, {"spa", "es"}, {"ita", "it"},
{"ukr", "uk"}, {"swe", "sv"}};
{"ukr", "uk"}, {"swe", "sv"}, {"fin", "fi"}};
// Maps a BCP-47 or ISO 639-2 language tag to a language-specific hyphenator.
const LanguageHyphenator* hyphenatorForLanguage(const std::string& langTag) {
@@ -7,6 +7,7 @@
#include "generated/hyph-de.trie.h"
#include "generated/hyph-en.trie.h"
#include "generated/hyph-es.trie.h"
#include "generated/hyph-fi.trie.h"
#include "generated/hyph-fr.trie.h"
#include "generated/hyph-it.trie.h"
#include "generated/hyph-pl.trie.h"
@@ -26,8 +27,9 @@ LanguageHyphenator italianHyphenator(it_patterns, isLatinLetter, toLowerLatin);
LanguageHyphenator swedishHyphenator(sv_patterns, isLatinLetter, toLowerLatin);
LanguageHyphenator ukrainianHyphenator(uk_patterns, isCyrillicLetter, toLowerCyrillic);
LanguageHyphenator polishHyphenator(pl_patterns, isLatinLetter, toLowerLatin);
LanguageHyphenator finnishHyphenator(fi_patterns, isLatinLetter, toLowerLatin);
using EntryArray = std::array<LanguageEntry, 9>;
using EntryArray = std::array<LanguageEntry, 10>;
const EntryArray& entries() {
static const EntryArray kEntries = {{{"english", "en", &englishHyphenator},
@@ -38,7 +40,8 @@ const EntryArray& entries() {
{"italian", "it", &italianHyphenator},
{"polish", "pl", &polishHyphenator},
{"swedish", "sv", &swedishHyphenator},
{"ukrainian", "uk", &ukrainianHyphenator}}};
{"ukrainian", "uk", &ukrainianHyphenator},
{"finnish", "fi", &finnishHyphenator}}};
return kEntries;
}
@@ -0,0 +1,95 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include "Epub/hyphenation/SerializedHyphenationTrie.h"
// Auto-generated by generate_hyphenation_trie.py. Do not edit manually.
alignas(4) constexpr uint8_t fi_trie_data[] = {
0x01, 0x01, 0x16, 0x0B, 0x0C, 0x17, 0x0C, 0x15, 0x0C, 0x0B, 0x16, 0x29, 0x2B, 0x20, 0x1F, 0x0C,
0x33, 0x02, 0x0B, 0x02, 0x0B, 0x0C, 0x01, 0x0C, 0x34, 0x0B, 0x2A, 0x0B, 0x2A, 0x0B, 0x0C, 0x20,
0x0B, 0x21, 0xA0, 0x00, 0x41, 0xA0, 0x02, 0x51, 0x21, 0x69, 0xFD, 0x21, 0x6C, 0xFD, 0xA1, 0x00,
0x41, 0x62, 0xFD, 0xA0, 0x01, 0xA2, 0xA1, 0x00, 0x81, 0x6F, 0xFD, 0xA3, 0x00, 0x81, 0x69, 0x6F,
0x75, 0xF8, 0xF8, 0xF8, 0x28, 0x61, 0x65, 0x69, 0x6F, 0x75, 0x79, 0x6C, 0x72, 0xDE, 0xDE, 0xEA,
0xDE, 0xDE, 0xDE, 0xF2, 0xF7, 0x22, 0xA4, 0xB6, 0xCD, 0xCD, 0xA1, 0x00, 0x81, 0x61, 0xD9, 0x28,
0x61, 0x65, 0x69, 0x6F, 0x75, 0x79, 0xC3, 0x72, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xF6, 0xFB,
0xA2, 0x00, 0x81, 0x61, 0x65, 0xC3, 0xC3, 0x48, 0x61, 0x65, 0x69, 0x6F, 0x75, 0x79, 0x6C, 0x72,
0xFF, 0xAB, 0xFF, 0xAB, 0xFF, 0xAB, 0xFF, 0xAB, 0xFF, 0xAB, 0xFF, 0xAB, 0xFF, 0xE3, 0xFF, 0xF9,
0x49, 0x61, 0x65, 0x69, 0x6F, 0x75, 0x79, 0xC3, 0x6C, 0x72, 0xFF, 0x92, 0xFF, 0x92, 0xFF, 0x92,
0xFF, 0x92, 0xFF, 0x92, 0xFF, 0x92, 0xFF, 0xC5, 0xFF, 0xA6, 0xFF, 0xCA, 0x47, 0x61, 0x65, 0x69,
0x6F, 0x75, 0x79, 0xC3, 0xFF, 0x76, 0xFF, 0x76, 0xFF, 0x76, 0xFF, 0x76, 0xFF, 0x76, 0xFF, 0x76,
0xFF, 0xA9, 0xA0, 0x00, 0xF1, 0x21, 0x73, 0xFD, 0xA1, 0x00, 0x41, 0x75, 0xFD, 0xA0, 0x00, 0x81,
0x43, 0x61, 0x65, 0x69, 0xFF, 0x63, 0xFF, 0x63, 0xFF, 0x63, 0xC1, 0x01, 0xA2, 0x61, 0xFF, 0x59,
0x4A, 0x61, 0x65, 0x69, 0x6F, 0x75, 0x79, 0xC3, 0x6C, 0x72, 0x76, 0xFF, 0x42, 0xFF, 0xE8, 0xFF,
0x42, 0xFF, 0x42, 0xFF, 0x42, 0xFF, 0x42, 0xFF, 0x75, 0xFF, 0xED, 0xFF, 0xF0, 0xFF, 0xFA, 0xA1,
0x00, 0x41, 0x73, 0xCE, 0x47, 0x61, 0x65, 0x69, 0x6F, 0x75, 0x79, 0xC3, 0xFF, 0xFB, 0xFF, 0x1E,
0xFF, 0x1E, 0xFF, 0x1E, 0xFF, 0x1E, 0xFF, 0x1E, 0xFF, 0x51, 0xA0, 0x01, 0x52, 0x21, 0x6F, 0xFD,
0x22, 0x74, 0x6E, 0xFD, 0xFD, 0xA0, 0x01, 0x73, 0x21, 0x6E, 0xFD, 0x22, 0x61, 0x6F, 0xFD, 0xFA,
0x21, 0x61, 0xEA, 0x21, 0x6B, 0xFD, 0x21, 0x65, 0xF2, 0xA4, 0x00, 0x41, 0x6E, 0x6A, 0x69, 0x6C,
0xE7, 0xF2, 0xFA, 0xFD, 0x21, 0x73, 0xE1, 0x21, 0x75, 0xFD, 0xA1, 0x00, 0x41, 0x64, 0xFD, 0x21,
0x74, 0xD6, 0x21, 0x73, 0xFD, 0x22, 0x65, 0x69, 0xFA, 0xFD, 0x21, 0x61, 0xCB, 0x21, 0x6E, 0xBD,
0x22, 0x74, 0x6F, 0xBD, 0xFD, 0x21, 0x69, 0xC0, 0x21, 0x61, 0xFD, 0xA4, 0x00, 0x41, 0x70, 0x73,
0x74, 0x6D, 0xEA, 0xEF, 0xF5, 0xFD, 0x21, 0x69, 0xD9, 0xA1, 0x00, 0x41, 0x6C, 0xFD, 0x47, 0x61,
0x65, 0x69, 0x6F, 0x75, 0x79, 0xC3, 0xFF, 0xBB, 0xFF, 0xCC, 0xFE, 0xA4, 0xFF, 0xED, 0xFE, 0xA4,
0xFF, 0xFB, 0xFE, 0xD7, 0xA0, 0x01, 0x41, 0x21, 0x73, 0xFD, 0x21, 0x75, 0xFD, 0xA1, 0x00, 0x41,
0x72, 0xFD, 0x49, 0x61, 0x65, 0x69, 0x6F, 0x75, 0x79, 0xC3, 0x6C, 0x72, 0xFE, 0x80, 0xFF, 0xFB,
0xFE, 0x80, 0xFE, 0x80, 0xFE, 0x80, 0xFE, 0x80, 0xFE, 0xB3, 0xFF, 0x2B, 0xFE, 0x94, 0x21, 0x61,
0xDC, 0x48, 0x61, 0x65, 0x69, 0x6F, 0x75, 0x79, 0xC3, 0x74, 0xFF, 0x3E, 0xFE, 0x61, 0xFE, 0x61,
0xFE, 0x61, 0xFE, 0x61, 0xFE, 0x61, 0xFE, 0x94, 0xFF, 0xFD, 0x42, 0x69, 0x65, 0xFF, 0x80, 0xFF,
0x40, 0x42, 0x6F, 0x65, 0xFF, 0x84, 0xFF, 0x47, 0x41, 0x75, 0xFF, 0x32, 0x21, 0x74, 0xFC, 0x42,
0x61, 0x6F, 0xFF, 0xFD, 0xFF, 0x36, 0xA4, 0x00, 0x41, 0x73, 0x6C, 0x6A, 0x70, 0xE4, 0xEB, 0xF9,
0xF2, 0x41, 0x79, 0xFF, 0x24, 0x21, 0x74, 0xFC, 0x21, 0x69, 0xFD, 0xA1, 0x00, 0x41, 0x73, 0xFD,
0x42, 0x2E, 0x6E, 0xFF, 0x15, 0xFF, 0x15, 0x21, 0x61, 0xF9, 0x21, 0x65, 0xFD, 0xA1, 0x00, 0x41,
0x64, 0xFD, 0x41, 0x65, 0xFE, 0xF8, 0x21, 0x6A, 0xFC, 0x42, 0x6B, 0x74, 0xFE, 0xFC, 0xFE, 0xFC,
0x21, 0x73, 0xF9, 0x21, 0x69, 0xFD, 0xC3, 0x00, 0x41, 0x68, 0x70, 0x73, 0xFF, 0xF0, 0xFF, 0xFD,
0xFF, 0x24, 0x41, 0x74, 0xFF, 0x23, 0xC2, 0x00, 0x41, 0x72, 0x68, 0xFF, 0x30, 0xFF, 0xFC, 0xA0,
0x00, 0x52, 0x21, 0x72, 0xFD, 0x21, 0x69, 0xFA, 0x21, 0x6C, 0xFD, 0xA0, 0x00, 0x61, 0x21, 0x68,
0xFD, 0x4A, 0x61, 0x65, 0x69, 0x6F, 0x75, 0x79, 0xC3, 0x74, 0x70, 0x63, 0xFF, 0x95, 0xFF, 0xAA,
0xFF, 0xBC, 0xFF, 0xD5, 0xFD, 0xC1, 0xFF, 0xE5, 0xFD, 0xF4, 0xFF, 0xF1, 0xFF, 0xF7, 0xFF, 0xFD,
0x48, 0x61, 0x65, 0x69, 0x6F, 0x75, 0x79, 0xC3, 0x73, 0xFD, 0xA2, 0xFD, 0xA2, 0xFD, 0xA2, 0xFD,
0xA2, 0xFD, 0xA2, 0xFD, 0xA2, 0xFD, 0xD5, 0xFF, 0xDE, 0xA0, 0x00, 0x92, 0xA0, 0x00, 0xB2, 0xA0,
0x01, 0x01, 0xC3, 0x00, 0x61, 0x69, 0x65, 0x79, 0xFE, 0x20, 0xFE, 0x20, 0xFF, 0xFD, 0x22, 0xA4,
0xB6, 0xF4, 0xAD, 0x25, 0x79, 0x61, 0x6F, 0x75, 0xC3, 0xA8, 0xE6, 0xE6, 0xE9, 0xFB, 0x42, 0xB6,
0xA4, 0xFF, 0x9D, 0xFF, 0x9D, 0x46, 0x79, 0x61, 0x6F, 0x75, 0xC3, 0x65, 0xFF, 0x96, 0xFF, 0xD4,
0xFF, 0xD4, 0xFF, 0xD7, 0xFF, 0xF9, 0xFF, 0xD7, 0x22, 0xA4, 0xB6, 0xDB, 0xED, 0xA0, 0x00, 0x72,
0xA0, 0x00, 0x71, 0x21, 0xA4, 0xFD, 0x21, 0xC3, 0xFD, 0x21, 0xA4, 0xFD, 0x21, 0x69, 0xF4, 0xA0,
0x01, 0x22, 0x21, 0x70, 0xFD, 0x21, 0x6F, 0xFD, 0x21, 0x69, 0xFD, 0x26, 0x61, 0x6F, 0x75, 0xC3,
0x65, 0x6C, 0xE2, 0xE2, 0xE2, 0xEE, 0xF1, 0xFD, 0x22, 0xA4, 0xB6, 0xD8, 0xD8, 0x21, 0x61, 0xD3,
0xA0, 0x00, 0xB1, 0x24, 0x75, 0x69, 0x65, 0x6F, 0xCD, 0xCD, 0xFD, 0xFD, 0x24, 0x61, 0x65, 0x6F,
0x75, 0xF4, 0xF4, 0xF4, 0xF4, 0x25, 0x79, 0xC3, 0x61, 0x75, 0x69, 0xBB, 0xE3, 0xE8, 0xEE, 0xF7,
0xA0, 0x00, 0xD2, 0x22, 0xA4, 0xB6, 0xFD, 0xFD, 0x44, 0x61, 0x65, 0x6F, 0x69, 0xFF, 0x64, 0xFF,
0x64, 0xFF, 0x64, 0xFF, 0x64, 0x42, 0x65, 0x61, 0xFF, 0x9B, 0xFF, 0xCB, 0x21, 0x65, 0xC4, 0x22,
0x61, 0x75, 0xC1, 0xC1, 0xA0, 0x02, 0x32, 0x21, 0x73, 0xFD, 0x21, 0x6F, 0xFD, 0x49, 0x79, 0xC3,
0x75, 0x61, 0x65, 0x69, 0x6F, 0x73, 0x6C, 0xFF, 0x80, 0xFF, 0xD6, 0xFF, 0xDB, 0xFF, 0xB0, 0xFF,
0xE8, 0xFF, 0xEF, 0xFF, 0xF2, 0xFD, 0x70, 0xFF, 0xFD, 0x44, 0x69, 0x65, 0x6F, 0x75, 0xFF, 0x23,
0xFF, 0x23, 0xFF, 0x23, 0xFF, 0x23, 0x43, 0x75, 0x61, 0x65, 0xFF, 0x5A, 0xFF, 0x8A, 0xFF, 0x8A,
0x41, 0x76, 0xFF, 0x5F, 0x21, 0x61, 0xFC, 0xA0, 0x01, 0xC2, 0x21, 0x73, 0xFD, 0x21, 0x69, 0xFD,
0x21, 0x65, 0xFD, 0x43, 0x69, 0x6F, 0x6B, 0xFF, 0xF1, 0xFD, 0xF7, 0xFF, 0xFD, 0xA0, 0x01, 0xA4,
0x21, 0x73, 0xFD, 0x21, 0x61, 0xFD, 0x43, 0x6E, 0x74, 0x6B, 0xFC, 0x7D, 0xFC, 0x7D, 0xFF, 0xFD,
0x41, 0x69, 0xFC, 0x73, 0x22, 0x61, 0x6F, 0xF2, 0xFC, 0x21, 0x69, 0xFB, 0x48, 0xC3, 0x61, 0x75,
0x65, 0x6F, 0x69, 0x6C, 0x73, 0xFF, 0x3C, 0xFF, 0xAD, 0xFF, 0xBA, 0xFF, 0x20, 0xFF, 0x20, 0xFF,
0x50, 0xFF, 0xD7, 0xFF, 0xFD, 0x44, 0x61, 0x69, 0x75, 0x79, 0xFE, 0xB7, 0xFE, 0xB7, 0xFE, 0xB7,
0xFE, 0xB7, 0x42, 0x61, 0x69, 0xFE, 0xEE, 0xFE, 0xEE, 0x42, 0x75, 0x61, 0xFE, 0xE7, 0xFF, 0x17,
0x42, 0xA4, 0xB6, 0xFE, 0xE6, 0xFF, 0x30, 0x24, 0x65, 0x61, 0x75, 0xC3, 0xDE, 0xEB, 0xF2, 0xF9,
0x43, 0x61, 0x65, 0x6F, 0xFE, 0x8C, 0xFE, 0x8C, 0xFE, 0x8C, 0x42, 0x61, 0x75, 0xFE, 0xC6, 0xFE,
0xC6, 0x44, 0x75, 0x61, 0x65, 0x6F, 0xFE, 0xBF, 0xFE, 0xEF, 0xFE, 0xEF, 0xFE, 0xEF, 0x41, 0xB6,
0xFE, 0xB2, 0x21, 0xC3, 0xFC, 0x42, 0xA4, 0xB6, 0xFE, 0xB1, 0xFF, 0xFD, 0x43, 0x61, 0x6F, 0x79,
0xFE, 0xD4, 0xFE, 0xD4, 0xFE, 0xD4, 0x42, 0x61, 0x65, 0xFE, 0x56, 0xFE, 0x56, 0x26, 0x69, 0x61,
0x75, 0xC3, 0x65, 0x6F, 0xC3, 0xCD, 0xD4, 0xE8, 0xEF, 0xF9, 0xA0, 0x01, 0x11, 0x21, 0xA4, 0xFD,
0xA0, 0x01, 0xE2, 0x21, 0x6E, 0xFD, 0x21, 0x69, 0xFD, 0x21, 0x64, 0xFD, 0xA0, 0x02, 0x03, 0x21,
0x61, 0xFD, 0x21, 0x72, 0xFD, 0x21, 0x75, 0xFD, 0x21, 0x75, 0xFD, 0x23, 0xC3, 0x79, 0x73, 0xE2,
0xEE, 0xFD, 0x41, 0x72, 0xFD, 0xD9, 0x42, 0x6C, 0x68, 0xFC, 0x47, 0xFF, 0xFC, 0xC1, 0x00, 0x81,
0x69, 0xFB, 0xA6, 0x21, 0x76, 0xFA, 0x59, 0x62, 0x64, 0x66, 0x67, 0x68, 0x6A, 0x6B, 0x6C, 0x6D,
0x6E, 0x70, 0x72, 0x73, 0x74, 0x76, 0xC3, 0x79, 0x6F, 0x75, 0x61, 0x65, 0x69, 0x2E, 0x63, 0x71,
0xFB, 0xAE, 0xFB, 0xC9, 0xFB, 0xE1, 0xFB, 0xFA, 0xFC, 0x16, 0xFC, 0x16, 0xFC, 0x4A, 0xFC, 0x6E,
0xFC, 0x16, 0xFC, 0xE8, 0xFD, 0x0C, 0xFD, 0x2B, 0xFD, 0xCB, 0xFD, 0xEA, 0xFC, 0x16, 0xFE, 0x42,
0xFE, 0x65, 0xFE, 0x8F, 0xFE, 0xC7, 0xFF, 0x36, 0xFF, 0x71, 0xFF, 0xB7, 0xFF, 0xE5, 0xFF, 0xF0,
0xFF, 0xFD,
};
constexpr SerializedHyphenationPatterns fi_patterns = {
0x496u,
fi_trie_data,
sizeof(fi_trie_data),
};
+41 -22
View File
@@ -35,7 +35,7 @@ constexpr const char* BOLD_TAGS[] = {"b", "strong"};
constexpr const char* ITALIC_TAGS[] = {"i", "em"};
constexpr const char* UNDERLINE_TAGS[] = {"u", "ins"};
constexpr const char* LINETHROUGH_TAGS[] = {"del", "s", "strike"};
constexpr const char* IMAGE_TAGS[] = {"img"};
constexpr const char* IMAGE_TAGS[] = {"img", "image"};
constexpr const char* SKIP_TAGS[] = {"head"};
bool isWhitespace(const char c) { return c == ' ' || c == '\r' || c == '\n' || c == '\t'; }
@@ -225,6 +225,7 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() {
currentTextBlock->addWord(partWordBuffer, fontStyle, false, nextWordContinues);
partWordBufferIndex = 0;
nextWordContinues = false;
listItemBulletOnly = false;
}
// start a new text block if needed
@@ -254,6 +255,17 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) {
return;
}
// <li> added a bullet as the first word, making the block non-empty. When a nested
// block-level child (<p>, <div>, etc.) opens, reuse the block instead of flushing
// the bullet to its own line. The bullet stays inline with the child's text.
if (listItemBulletOnly) {
const auto style = currentTextBlock->getBlockStyle();
currentTextBlock->setBlockStyle(style.getCombinedBlockStyle(blockStyle, BlockStyle::CombineAxis::Vertical));
listItemBulletOnly = false;
flushPendingAnchor();
return;
}
makePages();
}
// If the pending anchor is a TOC chapter boundary, force a page break after the previous
@@ -261,6 +273,7 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) {
flushPendingAnchor();
currentTextBlock.reset(new ParsedText(extraParagraphSpacing, hyphenationEnabled, focusReadingEnabled, blockStyle));
wordsExtractedInBlock = 0;
listItemBulletOnly = false;
}
void ChapterHtmlSlimParser::emitHorizontalRule(const BlockStyle& blockStyle) {
@@ -495,11 +508,18 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "src") == 0) {
src = atts[i + 1];
} else if (src.empty() && (strcmp(atts[i], "href") == 0 || strcmp(atts[i], "xlink:href") == 0)) {
src = atts[i + 1];
} else if (strcmp(atts[i], "alt") == 0) {
alt = atts[i + 1];
}
}
const size_t fragmentPos = src.find('#');
if (fragmentPos != std::string::npos) {
src.resize(fragmentPos);
}
// imageRendering: 0=display, 1=placeholder (alt text only), 2=suppress entirely
if (self->imageRendering == 2) {
self->skipUntilDepth = self->depth;
@@ -507,19 +527,6 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
return;
}
// Skip image if CSS display:none
if (self->cssParser) {
CssStyle imgDisplayStyle = self->cssParser->resolveStyle("img", classAttr);
if (!styleAttr.empty()) {
imgDisplayStyle.applyOver(CssParser::parseInlineStyle(styleAttr));
}
if (imgDisplayStyle.hasDisplay() && imgDisplayStyle.display == CssDisplay::None) {
self->skipUntilDepth = self->depth;
self->depth += 1;
return;
}
}
if (!src.empty() && self->imageRendering != 1) {
LOG_DBG("EHP", "Found image: src=%s", src.c_str());
@@ -543,24 +550,28 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
extractSuccess = self->epub->readItemContentsToStream(resolvedPath, cachedImageFile, 4096);
cachedImageFile.flush();
cachedImageFile.close();
delay(50); // Give SD card time to sync
}
if (extractSuccess) {
// Get image dimensions
// Get image dimensions, retrying to absorb SD-card sync latency on slow
// cards. Replaces a blanket delay(50) that cost ~50ms on every image, and
// closes the silent-drop bug where a single getDimensions failure was fatal.
ImageDimensions dims = {0, 0};
ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(cachedImagePath);
if (decoder && decoder->getDimensions(cachedImagePath, dims)) {
bool gotDimensions = false;
for (int attempt = 0; attempt < 3 && !gotDimensions; attempt++) {
if (attempt > 0) {
delay(50); // Give a slow SD card time to finish syncing before retrying
}
gotDimensions = decoder && decoder->getDimensions(cachedImagePath, dims);
}
if (gotDimensions) {
LOG_DBG("EHP", "Image dimensions: %dx%d", dims.width, dims.height);
int displayWidth = 0;
int displayHeight = 0;
const float emSize = static_cast<float>(self->renderer.getFontAscenderSize(self->fontId));
CssStyle imgStyle = self->cssParser ? self->cssParser->resolveStyle("img", classAttr) : CssStyle{};
// Merge inline style (e.g. style="height: 2em") so it overrides stylesheet rules
if (!styleAttr.empty()) {
imgStyle.applyOver(CssParser::parseInlineStyle(styleAttr));
}
const CssStyle& imgStyle = cssStyle;
const bool hasCssHeight = imgStyle.hasImageHeight();
const bool hasCssWidth = imgStyle.hasImageWidth();
@@ -882,6 +893,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
if (strcmp(name, "li") == 0) {
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
self->listItemBulletOnly = true;
}
}
} else if (matches(name, UNDERLINE_TAGS, std::size(UNDERLINE_TAGS))) {
@@ -1288,6 +1300,13 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
}
self->blockStyleStack.pop_back();
}
// </li> closes: if the bullet never got inline text (empty <li> or <li> with only
// block children that were flushed), clear the flag so the next sibling doesn't
// merge into this block.
if (strcmp(name, "li") == 0) {
self->listItemBulletOnly = false;
}
}
}
@@ -80,6 +80,7 @@ class ChapterHtmlSlimParser {
int tableDepth = 0;
int tableRowIndex = 0;
int tableColIndex = 0;
bool listItemBulletOnly = false; // true when currentTextBlock has only the <li> bullet
// Anchor-to-page mapping: tracks which page each HTML id attribute lands on
int completedPageCount = 0;
+6 -5
View File
@@ -137,8 +137,10 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
LOG_ERR("COF", "Couldn't open temp items file for reading. This is probably going to be a fatal error.");
}
// Sort item index for binary search if we have enough items
if (self->itemIndex.size() >= LARGE_SPINE_THRESHOLD) {
// Sort the (unconditionally-built) item index so every idref lookup uses binary
// search. Without this, small/medium manifests fell back to an O(spine × manifest)
// linear rescan of .items.bin per itemref (up to ~200ms/item at large scale).
if (!self->itemIndex.empty()) {
std::sort(self->itemIndex.begin(), self->itemIndex.end(), [](const ItemIndexEntry& a, const ItemIndexEntry& b) {
return a.idHash < b.idHash || (a.idHash == b.idHash && a.idLen < b.idLen);
});
@@ -284,9 +286,8 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
++it;
}
} else {
// Slow path: linear scan (for small manifests, keeps original behavior)
// TODO: This lookup is slow as need to scan through all items each time.
// It can take up to 200ms per item when getting to 1500 items.
// Fallback linear scan, only reached when the index is empty (no manifest
// items). The fast binary-search path above is used for all real manifests.
self->tempItemStore.seek(0);
std::string itemId;
while (self->tempItemStore.available()) {
+1 -3
View File
@@ -32,7 +32,7 @@ class ContentOpfParser final : public Print {
HalFile tempItemStore;
std::string coverItemId;
// Index for fast idref→href lookup (used only for large EPUBs)
// Index for fast idref→href lookup (binary search over .items.bin)
struct ItemIndexEntry {
uint32_t idHash; // FNV-1a hash of itemId
uint16_t idLen; // length for collision reduction
@@ -41,8 +41,6 @@ class ContentOpfParser final : public Print {
std::deque<ItemIndexEntry> itemIndex;
bool useItemIndex = false;
static constexpr uint16_t LARGE_SPINE_THRESHOLD = 400;
// FNV-1a hash function
static uint32_t fnvHash(const std::string& s) {
uint32_t hash = 2166136261u;