Deduplicate identical CSS files in EPUB parsing & probe images for dimensions instead of reading the full file

Some EPUB converters emit byte-identical stylesheets per chapter (100+ entries). Now scan the ZIP central directory once to identify duplicates by CRC32 and compressed size, then skip parsing identical files. This avoids redundant ZIP lookups and SD extraction round-trips while preserving all styles since rules merge into a global set. Also add extractItemToFile helper and allowEarlyStop parameter to readItemContentsToStream.
This commit is contained in:
Justin Mitchell
2026-07-20 17:16:54 -04:00
parent 792aab6d97
commit 9d147cade9
13 changed files with 420 additions and 50 deletions
+56 -4
View File
@@ -256,8 +256,44 @@ void Epub::parseCssFiles() const {
return;
}
// Some converters emit one byte-identical stylesheet per chapter (100+ .css
// entries), and each parse costs a zip locate plus an SD extract round-trip.
// Map every CSS path to its central-directory (CRC32, compressed size) in a
// single scan and parse only the first of each identical pair. Rules merge
// into one global set, so dropping exact duplicates cannot lose styles. A
// path that never matches a directory entry keeps key 0 and always parses.
std::vector<uint64_t> dedupKeys(cssFiles.size(), 0);
if (cssFiles.size() > 1) {
std::unordered_map<std::string, size_t> pathToIndex;
pathToIndex.reserve(cssFiles.size());
for (size_t i = 0; i < cssFiles.size(); i++) {
pathToIndex.emplace(FsHelpers::normalisePath(cssFiles[i]), i);
}
ZipFile(filepath).enumerateFileEntries([&](std::string_view entryPath, uint32_t crc32, uint32_t compressedSize) {
if (!FsHelpers::hasCssExtension(entryPath)) {
return;
}
const auto it = pathToIndex.find(std::string{entryPath});
if (it != pathToIndex.end()) {
dedupKeys[it->second] = (static_cast<uint64_t>(crc32) << 32) | compressedSize;
}
});
}
std::vector<uint64_t> seenKeys;
seenKeys.reserve(cssFiles.size());
size_t skippedDuplicates = 0;
// No cache yet - parse CSS files
for (const auto& cssPath : cssFiles) {
for (size_t cssIndex = 0; cssIndex < cssFiles.size(); cssIndex++) {
const auto& cssPath = cssFiles[cssIndex];
const uint64_t dedupKey = dedupKeys[cssIndex];
if (dedupKey != 0) {
if (std::find(seenKeys.begin(), seenKeys.end(), dedupKey) != seenKeys.end()) {
skippedDuplicates++;
continue;
}
seenKeys.push_back(dedupKey);
}
LOG_DBG("EBP", "Parsing CSS file: %s", cssPath.c_str());
// Check heap before parsing - CSS parsing allocates heavily
@@ -312,7 +348,8 @@ void Epub::parseCssFiles() const {
LOG_ERR("EBP", "Failed to save CSS rules to cache");
}
LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size());
LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files (%zu identical duplicates skipped)",
cssParser->ruleCount(), cssFiles.size(), skippedDuplicates);
cssParser->clear();
}
@@ -728,14 +765,29 @@ uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size
return content;
}
bool Epub::readItemContentsToStream(const std::string& itemHref, Print& out, const size_t chunkSize) const {
bool Epub::readItemContentsToStream(const std::string& itemHref, Print& out, const size_t chunkSize,
const bool allowEarlyStop) const {
if (itemHref.empty()) {
LOG_DBG("EBP", "Failed to read item, empty href");
return false;
}
const std::string path = FsHelpers::normalisePath(itemHref);
return ZipFile(filepath).readFileToStream(path.c_str(), out, chunkSize);
return ZipFile(filepath).readFileToStream(path.c_str(), out, chunkSize, allowEarlyStop);
}
bool Epub::extractItemToFile(const std::string& itemHref, const std::string& destPath) const {
HalFile out;
if (!Storage.openFileForWrite("EBP", destPath, out)) {
return false;
}
const bool ok = readItemContentsToStream(itemHref, out, 4096);
out.flush();
out.close();
if (!ok) {
Storage.remove(destPath.c_str());
}
return ok;
}
bool Epub::getItemSize(const std::string& itemHref, size_t* size) const {
+4 -1
View File
@@ -59,7 +59,10 @@ class Epub {
bool generateThumbBmp(int height) const;
uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr,
bool trailingNullByte = false) const;
bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const;
bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize,
bool allowEarlyStop = false) const;
// Extract an item to a file on SD. On failure the partial file is removed.
bool extractItemToFile(const std::string& itemHref, const std::string& destPath) const;
bool getItemSize(const std::string& itemHref, size_t* size) const;
BookMetadataCache::SpineEntry getSpineItem(int spineIndex) const;
BookMetadataCache::TocEntry getTocItem(int tocIndex) const;
+4 -1
View File
@@ -17,7 +17,10 @@ namespace {
// 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 = 31;
// v32: ImageBlock serializes the book-internal source href after the cache path
// (lazy extraction: images are header-probed at build time and extracted on
// first render).
constexpr uint8_t SECTION_FILE_VERSION = 32;
// 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
+24 -3
View File
@@ -6,6 +6,7 @@
#include <Serialization.h>
#include <cstdlib>
#include <new>
#include "Epub/converters/DirectPixelWriter.h"
#include "Epub/converters/ImageDecoderFactory.h"
@@ -15,8 +16,16 @@
// - uint16_t height
// - uint8_t pixels[...] - 2 bits per pixel, packed (4 pixels per byte), row-major order
ImageBlock::ImageBlock(const std::string& imagePath, int16_t width, int16_t height)
: imagePath(imagePath), width(width), height(height) {}
ImageBlock::ImageBlock(const std::string& imagePath, const std::string& srcPath, int16_t width, int16_t height)
: imagePath(imagePath), srcPath(srcPath), width(width), height(height) {}
void* ImageBlock::extractCtx = nullptr;
ImageBlock::ExtractFn ImageBlock::extractFn = nullptr;
void ImageBlock::setExtractor(void* ctx, ExtractFn fn) {
extractCtx = ctx;
extractFn = fn;
}
bool ImageBlock::imageExists() const { return Storage.exists(imagePath.c_str()); }
@@ -225,6 +234,15 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
return; // Successfully rendered from cache
}
// The build only header-probed the image for dimensions; pull the actual
// file out of the book now, on first visit to the page.
if (!srcPath.empty() && extractFn && !Storage.exists(imagePath.c_str())) {
LOG_DBG("IMG", "Lazy-extracting %s -> %s", srcPath.c_str(), imagePath.c_str());
if (!extractFn(extractCtx, srcPath.c_str(), imagePath.c_str())) {
LOG_ERR("IMG", "Lazy extraction failed: %s", srcPath.c_str());
}
}
// No cache - need to decode the image
// Check if image file exists
HalFile file;
@@ -280,6 +298,7 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
bool ImageBlock::serialize(HalFile& file) {
serialization::writeString(file, imagePath);
serialization::writeString(file, srcPath);
serialization::writePod(file, width);
serialization::writePod(file, height);
return true;
@@ -287,9 +306,11 @@ bool ImageBlock::serialize(HalFile& file) {
std::unique_ptr<ImageBlock> ImageBlock::deserialize(HalFile& file) {
std::string path;
std::string src;
serialization::readString(file, path);
serialization::readString(file, src);
int16_t w, h;
serialization::readPod(file, w);
serialization::readPod(file, h);
return std::unique_ptr<ImageBlock>(new ImageBlock(path, w, h));
return std::unique_ptr<ImageBlock>(new (std::nothrow) ImageBlock(path, src, w, h));
}
+13 -1
View File
@@ -8,7 +8,7 @@
class ImageBlock final : public Block {
public:
ImageBlock(const std::string& imagePath, int16_t width, int16_t height);
ImageBlock(const std::string& imagePath, const std::string& srcPath, int16_t width, int16_t height);
~ImageBlock() override = default;
const std::string& getImagePath() const { return imagePath; }
@@ -21,6 +21,14 @@ class ImageBlock final : public Block {
void renderPlaceholder(GfxRenderer& renderer, int x, int y) const;
static void clearSessionRenderFailures();
// Lazy extraction hook: the section build only header-probes images for their
// dimensions; the file at imagePath is extracted out of the book on first
// render, via this callback (function pointer + context, not std::function —
// this is render-loop code). Registered by the reader activity that owns the
// Epub, cleared on its exit.
using ExtractFn = bool (*)(void* ctx, const char* srcPath, const char* destPath);
static void setExtractor(void* ctx, ExtractFn fn);
BlockType getType() override { return IMAGE_BLOCK; }
bool isEmpty() override { return false; }
@@ -30,6 +38,10 @@ class ImageBlock final : public Block {
private:
std::string imagePath;
std::string srcPath; // book-internal source href; empty once known-extracted
int16_t width;
int16_t height;
static void* extractCtx;
static ExtractFn extractFn;
};
+144
View File
@@ -0,0 +1,144 @@
#include "ImageDimsProbe.h"
#include <cstdint>
namespace {
// SOFn markers carry the frame dimensions. C4 (DHT), C8 (JPG extension) and
// CC (DAC) share the 0xCn range but are not frame headers.
bool isJpegSof(const uint8_t marker) {
return marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC;
}
constexpr uint8_t PNG_SIG[8] = {0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
} // namespace
bool ImageDimsProbe::feed(const uint8_t b) {
switch (state) {
case State::Sniff:
if (b == 0xFF) {
state = State::JpegSoi;
} else if (b == PNG_SIG[0]) {
state = State::PngHeader;
} else {
state = State::Failed;
return false;
}
pos = 1;
return true;
case State::PngHeader:
// Bytes 1..7: signature; 8..11: IHDR length; 12..15: "IHDR"; 16..23: dims.
if (pos < 8) {
if (b != PNG_SIG[pos]) {
state = State::Failed;
return false;
}
} else if (pos >= 12 && pos < 16) {
if (b != "IHDR"[pos - 12]) {
state = State::Failed;
return false;
}
} else if (pos >= 16 && pos < 20) {
width = static_cast<uint16_t>((static_cast<uint32_t>(width) << 8) | b);
} else if (pos >= 20 && pos < 24) {
height = static_cast<uint16_t>((static_cast<uint32_t>(height) << 8) | b);
if (pos == 23) {
state = State::Done;
pos++;
return false;
}
}
pos++;
return true;
case State::JpegSoi:
if (b != 0xD8) {
state = State::Failed;
return false;
}
state = State::JpegFf;
return true;
case State::JpegFf:
if (b != 0xFF) {
state = State::Failed;
return false;
}
state = State::JpegMarker;
return true;
case State::JpegMarker:
if (b == 0xFF) return true; // fill bytes before a marker are legal
if (b == 0x01 || (b >= 0xD0 && b <= 0xD8)) {
// TEM / RSTn / SOI: standalone, no length field.
state = State::JpegFf;
return true;
}
if (b == 0xD9 || b == 0xDA) {
// EOI or SOS before any SOF: no dimensions to be found.
state = State::Failed;
return false;
}
sofPending = isJpegSof(b);
state = State::JpegLenHi;
return true;
case State::JpegLenHi:
segLen = static_cast<uint16_t>(b << 8);
state = State::JpegLenLo;
return true;
case State::JpegLenLo:
segLen = static_cast<uint16_t>(segLen | b);
if (segLen < 2 || (sofPending && segLen < 7)) {
state = State::Failed;
return false;
}
if (sofPending) {
sofFill = 0;
state = State::JpegSof;
} else if (segLen == 2) {
state = State::JpegFf;
} else {
skipLeft = static_cast<uint32_t>(segLen) - 2;
state = State::JpegSkip;
}
return true;
case State::JpegSkip:
if (--skipLeft == 0) state = State::JpegFf;
return true;
case State::JpegSof:
sofBuf[sofFill++] = b;
if (sofFill == 5) {
height = static_cast<uint16_t>((sofBuf[1] << 8) | sofBuf[2]);
width = static_cast<uint16_t>((sofBuf[3] << 8) | sofBuf[4]);
state = State::Done;
return false;
}
return true;
case State::Done:
case State::Failed:
return false;
}
return false;
}
size_t ImageDimsProbe::write(const uint8_t b) { return feed(b) ? 1 : 0; }
size_t ImageDimsProbe::write(const uint8_t* data, const size_t len) {
for (size_t i = 0; i < len; i++) {
if (!feed(data[i])) return i; // short write: polite early stop
}
return len;
}
bool ImageDimsProbe::getDimensions(ImageDimensions& out) const {
if (state != State::Done || width == 0 || height == 0 || width > INT16_MAX || height > INT16_MAX) {
return false;
}
out.width = static_cast<int16_t>(width);
out.height = static_cast<int16_t>(height);
return true;
}
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include <Print.h>
#include "ImageToFramebufferDecoder.h"
// Streaming JPEG/PNG header parser: finds image dimensions from the first few
// KB of a compressed stream without inflating the whole image. Feed bytes via
// the Print interface (e.g. Epub::readItemContentsToStream with
// allowEarlyStop=true); write() returns short once the dimensions are known or
// the stream is known to be unusable, which the zip layer treats as a polite
// early stop rather than an error.
//
// JPEG: walks marker segments (skipping EXIF/APPn of any size statefully, so
// nothing is buffered) until a SOFn frame header yields the dimensions.
// PNG: reads the IHDR fields at their fixed offsets (bytes 16..23).
class ImageDimsProbe : public Print {
public:
size_t write(uint8_t b) override;
size_t write(const uint8_t* data, size_t len) override;
// True only when a valid header was found; fills `out`.
bool getDimensions(ImageDimensions& out) const;
private:
bool feed(uint8_t b); // returns false once parsing is finished (found or failed)
enum class State : uint8_t {
Sniff, // first byte decides the format
PngHeader, // PNG signature + IHDR at fixed offsets
JpegSoi, // second SOI byte (0xD8)
JpegFf, // expect a 0xFF marker prefix
JpegMarker, // marker type byte (0xFF padding allowed)
JpegLenHi, // segment length, high byte
JpegLenLo, // segment length, low byte
JpegSkip, // skipping a non-SOF segment body
JpegSof, // collecting the 5 SOF bytes: precision, height(2), width(2)
Done,
Failed,
};
State state = State::Sniff;
uint32_t pos = 0; // absolute stream offset (PNG fixed-offset parsing)
uint32_t skipLeft = 0; // remaining segment bytes to skip
uint16_t segLen = 0;
bool sofPending = false; // current segment is a SOF frame header
uint8_t sofBuf[5] = {0};
uint8_t sofFill = 0;
uint16_t width = 0;
uint16_t height = 0;
};
+41 -22
View File
@@ -15,6 +15,7 @@
#include "Epub.h"
#include "Epub/Page.h"
#include "Epub/converters/ImageDecoderFactory.h"
#include "Epub/converters/ImageDimsProbe.h"
#include "Epub/converters/ImageToFramebufferDecoder.h"
#include "Epub/htmlEntities.h"
@@ -554,28 +555,47 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
}
std::string cachedImagePath = self->imageBasePath + std::to_string(self->imageCounter++) + ext;
// Extract image to cache file
HalFile cachedImageFile;
bool extractSuccess = false;
if (Storage.openFileForWrite("EHP", cachedImagePath, cachedImageFile)) {
extractSuccess = self->epub->readItemContentsToStream(resolvedPath, cachedImageFile, 4096);
cachedImageFile.flush();
cachedImageFile.close();
}
if (extractSuccess) {
// 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.
{
// Probe the dimensions from the entry's first bytes (early-aborted
// inflate, a few KB) instead of extracting the whole image now —
// extraction is deferred to the first render of the page (see
// ImageBlock's lazy extractor). This is what keeps first-open of an
// image-heavy chapter from stalling for seconds per image.
ImageDimensions dims = {0, 0};
ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(cachedImagePath);
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
ImageDimsProbe headerProbe;
self->epub->readItemContentsToStream(resolvedPath, headerProbe, 1024, /*allowEarlyStop=*/true);
bool gotDimensions = headerProbe.getDimensions(dims);
if (!gotDimensions) {
// No header within the stream (rare) — fall back to extracting the
// whole image and probing the file. That can take seconds, so
// surface the indexing popup first (single-shot per parser).
if (self->popupFn && !self->imagePopupFired) {
self->imagePopupFired = true;
self->popupFn();
}
HalFile cachedImageFile;
bool extractSuccess = false;
if (Storage.openFileForWrite("EHP", cachedImagePath, cachedImageFile)) {
extractSuccess = self->epub->readItemContentsToStream(resolvedPath, cachedImageFile, 4096);
cachedImageFile.flush();
cachedImageFile.close();
}
if (extractSuccess) {
// Retry to absorb SD-card sync latency on slow cards, and to close
// the silent-drop bug where a single getDimensions failure was fatal.
ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(cachedImagePath);
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);
}
} else {
LOG_ERR("EHP", "Failed to extract image");
}
gotDimensions = decoder && decoder->getDimensions(cachedImagePath, dims);
}
if (gotDimensions) {
LOG_DBG("EHP", "Image dimensions: %dx%d", dims.width, dims.height);
@@ -722,7 +742,8 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
self->currentPageNextY += imageMarginTop;
// Create ImageBlock and add to page
auto imageBlock = std::make_shared<ImageBlock>(cachedImagePath, displayWidth, displayHeight);
auto imageBlock =
std::make_shared<ImageBlock>(cachedImagePath, resolvedPath, displayWidth, displayHeight);
if (!imageBlock) {
LOG_ERR("EHP", "Failed to create ImageBlock");
return;
@@ -753,8 +774,6 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
LOG_ERR("EHP", "Failed to get image dimensions");
Storage.remove(cachedImagePath.c_str());
}
} else {
LOG_ERR("EHP", "Failed to extract image");
}
} // isFormatSupported
}
@@ -28,6 +28,7 @@ class ChapterHtmlSlimParser {
GfxRenderer& renderer;
std::function<void(std::unique_ptr<Page>, uint16_t, uint16_t)> completePageFn;
std::function<void()> popupFn; // Popup callback
bool imagePopupFired = false; // popupFn fired for the first image probe (single-shot)
int depth = 0;
int skipUntilDepth = INT_MAX;
int boldUntilDepth = INT_MAX;
+8 -3
View File
@@ -437,7 +437,7 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo
return data;
}
bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t chunkSize) {
bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t chunkSize, const bool allowEarlyStop) {
const ScopedOpenClose zip{*this};
if (!zip) return false;
@@ -469,8 +469,9 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
}
if (out.write(buffer, dataRead) != dataRead) {
LOG_ERR("ZIP", "Failed to write all output bytes to stream");
free(buffer);
if (allowEarlyStop) return true; // sink has what it needs
LOG_ERR("ZIP", "Failed to write all output bytes to stream");
return false;
}
remaining -= dataRead;
@@ -525,7 +526,11 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
if (produced > 0) {
if (out.write(outputBuffer, produced) != produced) {
LOG_ERR("ZIP", "Failed to write all output bytes to stream");
if (allowEarlyStop) {
success = true; // sink has what it needs
} else {
LOG_ERR("ZIP", "Failed to write all output bytes to stream");
}
break;
}
}
+18 -3
View File
@@ -69,7 +69,10 @@ class ZipFile {
// Due to the memory required to run each of these, it is recommended to not preopen the zip file for multiple
// These functions will open and close the zip as needed
uint8_t* readFileToMemory(const char* filename, size_t* size = nullptr, bool trailingNullByte = false);
bool readFileToStream(const char* filename, Print& out, size_t chunkSize);
// allowEarlyStop: a short write from `out` is treated as the sink asking to
// stop (returns true) instead of a write failure — used by header probes
// that only need the first bytes of an entry.
bool readFileToStream(const char* filename, Print& out, size_t chunkSize, bool allowEarlyStop = false);
template <typename F>
bool enumerateFilePaths(F&& callback) {
@@ -80,6 +83,14 @@ class ZipFile {
return true;
}
return enumerateFileEntries([&callback](std::string_view path, uint32_t, uint32_t) { callback(path); });
}
// Callback receives (path, crc32, compressedSize) for each central-directory
// entry. Always scans the central directory: the slim-stat cache does not
// hold CRCs.
template <typename F>
bool enumerateFileEntries(F&& callback) {
const bool wasOpen = isOpen();
if (!wasOpen && !open()) {
return false;
@@ -103,7 +114,11 @@ class ZipFile {
break;
}
file.seekCur(24);
file.seekCur(12);
uint32_t crc32, compressedSize;
file.read(&crc32, 4);
file.read(&compressedSize, 4);
file.seekCur(4);
uint16_t nameLen, m, k;
file.read(&nameLen, 2);
file.read(&m, 2);
@@ -113,7 +128,7 @@ class ZipFile {
if (nameLen < sizeof(itemName)) {
file.read(itemName, nameLen);
itemName[nameLen] = '\0';
callback(std::string_view{itemName, nameLen});
callback(std::string_view{itemName, nameLen}, crc32, compressedSize);
} else {
file.seekCur(nameLen);
}