Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
285717fe92 | ||
|
|
28b4ab7b5a | ||
|
|
86d01c2d9b | ||
|
|
f96601e1ba | ||
|
|
1ddb079b33 | ||
|
|
2f9d052060 | ||
|
|
5cb6176486 | ||
|
|
3ce6fc13d4 | ||
|
|
732c38e0de | ||
|
|
09ec0908cc |
+18
-38
@@ -14,38 +14,13 @@
|
||||
|
||||
#include "Epub/parsers/ContainerParser.h"
|
||||
#include "Epub/parsers/ContentOpfParser.h"
|
||||
#include "Epub/parsers/PageListSink.h"
|
||||
#include "Epub/parsers/PageMapParser.h"
|
||||
#include "Epub/parsers/TocNavParser.h"
|
||||
#include "Epub/parsers/TocNcxParser.h"
|
||||
|
||||
namespace {
|
||||
|
||||
// Serialise a list of printed-page entries (href, anchor, label) to pagelist.bin in the
|
||||
// book cache. Templated on the parser's entry type so both NCX <pageList> and EPUB 3
|
||||
// <nav epub:type="page-list"> share the same writer.
|
||||
template <typename Entry>
|
||||
void writePageListBin(const std::string& cachePath, const std::vector<Entry>& pageList) {
|
||||
const auto pageListPath = cachePath + "/pagelist.bin";
|
||||
if (pageList.empty()) {
|
||||
Storage.remove(pageListPath.c_str());
|
||||
return;
|
||||
}
|
||||
FsFile pageListFile;
|
||||
if (!Storage.openFileForWrite("EBP", pageListPath, pageListFile)) {
|
||||
LOG_ERR("EBP", "Could not write pagelist.bin");
|
||||
return;
|
||||
}
|
||||
serialization::writePod(pageListFile, static_cast<uint16_t>(pageList.size()));
|
||||
for (const auto& entry : pageList) {
|
||||
serialization::writeString(pageListFile, entry.href);
|
||||
serialization::writeString(pageListFile, entry.anchor);
|
||||
serialization::writeString(pageListFile, entry.label);
|
||||
}
|
||||
pageListFile.flush();
|
||||
pageListFile.close();
|
||||
LOG_DBG("EBP", "Wrote pagelist.bin with %u entries", static_cast<unsigned>(pageList.size()));
|
||||
}
|
||||
|
||||
enum class CoverImageFormat { Unknown, Jpeg, Png };
|
||||
|
||||
CoverImageFormat detectCoverImageFormat(FsFile& imageFile) {
|
||||
@@ -373,7 +348,10 @@ bool Epub::parseTocNcxFile() const {
|
||||
}
|
||||
const auto ncxSize = tempNcxFile.size();
|
||||
|
||||
TocNcxParser ncxParser(contentBasePath, ncxSize, bookMetadataCache.get());
|
||||
// Stream <pageList> entries straight to pagelist.bin (long printed-page lists used to
|
||||
// blow the X3 heap when accumulated in a std::vector — see PageListSink).
|
||||
PageListSink ncxPageListSink(getCachePath());
|
||||
TocNcxParser ncxParser(contentBasePath, ncxSize, bookMetadataCache.get(), &ncxPageListSink);
|
||||
|
||||
if (!ncxParser.setup()) {
|
||||
LOG_ERR("EBP", "Could not setup toc ncx parser");
|
||||
@@ -405,11 +383,10 @@ bool Epub::parseTocNcxFile() const {
|
||||
tempNcxFile.close();
|
||||
Storage.remove(tmpNcxPath.c_str());
|
||||
|
||||
// Persist the printed-page list (NCX <pageList>) to a small cache file so the
|
||||
// section builder can stamp printed-page labels onto rendered pages without
|
||||
// re-parsing the NCX. Format: u16 count, then per entry: writeString(href),
|
||||
// writeString(anchor), writeString(label).
|
||||
writePageListBin(getCachePath(), ncxParser.getPageList());
|
||||
// Flush u16 count + close pagelist.bin (or remove it if no <pageList> entries were
|
||||
// streamed). The section builder later reads this file to stamp printed-page labels
|
||||
// onto rendered pages without re-parsing the NCX.
|
||||
ncxPageListSink.finalize();
|
||||
|
||||
LOG_DBG("EBP", "Parsed TOC items");
|
||||
return true;
|
||||
@@ -439,7 +416,9 @@ bool Epub::parseTocNavFile() const {
|
||||
// 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());
|
||||
// Stream <nav epub:type="page-list"> entries straight to pagelist.bin (see PageListSink).
|
||||
PageListSink navPageListSink(getCachePath());
|
||||
TocNavParser navParser(navContentBasePath, navSize, bookMetadataCache.get(), &navPageListSink);
|
||||
|
||||
if (!navParser.setup()) {
|
||||
LOG_ERR("EBP", "Could not setup toc nav parser");
|
||||
@@ -468,9 +447,8 @@ bool Epub::parseTocNavFile() const {
|
||||
tempNavFile.close();
|
||||
Storage.remove(tmpNavPath.c_str());
|
||||
|
||||
// Persist EPUB 3 <nav epub:type="page-list"> entries to pagelist.bin (same format
|
||||
// as the NCX writer); the section builder consumes either source uniformly.
|
||||
writePageListBin(getCachePath(), navParser.getPageList());
|
||||
// Flush u16 count + close pagelist.bin (or remove it if no entries were streamed).
|
||||
navPageListSink.finalize();
|
||||
|
||||
LOG_DBG("EBP", "Parsed TOC nav items");
|
||||
return true;
|
||||
@@ -500,7 +478,9 @@ bool Epub::parsePageMapFile() const {
|
||||
|
||||
// page-map hrefs are relative to the page-map file itself (typically content.opf's dir).
|
||||
const std::string pageMapBasePath = pageMapItem.substr(0, pageMapItem.find_last_of('/') + 1);
|
||||
PageMapParser pageMapParser(pageMapBasePath, pageMapSize);
|
||||
// Stream page-map entries straight to pagelist.bin (see PageListSink).
|
||||
PageListSink pageMapPageListSink(getCachePath());
|
||||
PageMapParser pageMapParser(pageMapBasePath, pageMapSize, &pageMapPageListSink);
|
||||
|
||||
if (!pageMapParser.setup()) {
|
||||
LOG_ERR("EBP", "Could not setup page-map parser");
|
||||
@@ -531,7 +511,7 @@ bool Epub::parsePageMapFile() const {
|
||||
tempPageMapFile.close();
|
||||
Storage.remove(tmpPageMapPath.c_str());
|
||||
|
||||
writePageListBin(getCachePath(), pageMapParser.getPageList());
|
||||
pageMapPageListSink.finalize();
|
||||
LOG_DBG("EBP", "Parsed page-map entries");
|
||||
return true;
|
||||
}
|
||||
|
||||
+20
-4
@@ -30,8 +30,9 @@ void PageImage::render(GfxRenderer& renderer, const int fontId, const int xOffse
|
||||
imageBlock->render(renderer, xPos + xOffset, yPos + yOffset);
|
||||
}
|
||||
|
||||
void PageImage::renderWithForceLoad(GfxRenderer& renderer, const int xOffset, const int yOffset, const bool forceLoad) {
|
||||
imageBlock->render(renderer, xPos + xOffset, yPos + yOffset, forceLoad);
|
||||
void PageImage::renderWithForceLoad(GfxRenderer& renderer, const int xOffset, const int yOffset, const bool forceLoad,
|
||||
const bool monochromeOutput) {
|
||||
imageBlock->render(renderer, xPos + xOffset, yPos + yOffset, forceLoad, monochromeOutput);
|
||||
}
|
||||
|
||||
bool PageImage::serialize(FsFile& file) {
|
||||
@@ -205,10 +206,12 @@ std::unique_ptr<PageTableFragment> PageTableFragment::deserialize(FsFile& file)
|
||||
}
|
||||
|
||||
void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset,
|
||||
const bool forceLoadLargeImages) const {
|
||||
const bool forceLoadLargeImages, const bool skipDecodedImages, const bool monochromeImages) const {
|
||||
for (auto& element : elements) {
|
||||
if (element->getTag() == TAG_PageImage) {
|
||||
static_cast<PageImage&>(*element).renderWithForceLoad(renderer, xOffset, yOffset, forceLoadLargeImages);
|
||||
auto& pi = static_cast<PageImage&>(*element);
|
||||
if (skipDecodedImages && !pi.getImageBlock().wouldShowPlaceholder(forceLoadLargeImages)) continue;
|
||||
pi.renderWithForceLoad(renderer, xOffset, yOffset, forceLoadLargeImages, monochromeImages);
|
||||
} else {
|
||||
element->render(renderer, fontId, xOffset, yOffset);
|
||||
}
|
||||
@@ -262,6 +265,19 @@ void Page::renderTextOnly(GfxRenderer& renderer, const int fontId, const int xOf
|
||||
}
|
||||
}
|
||||
|
||||
void Page::renderImagesOnly(GfxRenderer& renderer, const int xOffset, const int yOffset,
|
||||
const bool forceLoadLargeImages) const {
|
||||
for (auto& element : elements) {
|
||||
if (element->getTag() == TAG_PageImage) {
|
||||
auto& pi = static_cast<PageImage&>(*element);
|
||||
// Placeholders already drew in the BW pass; the grayscale path is only
|
||||
// for images with actual decoded pixel data.
|
||||
if (pi.getImageBlock().wouldShowPlaceholder(forceLoadLargeImages)) continue;
|
||||
pi.renderWithForceLoad(renderer, xOffset, yOffset, forceLoadLargeImages, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool Page::serialize(FsFile& file) const {
|
||||
const uint16_t count = elements.size();
|
||||
serialization::writePod(file, count);
|
||||
|
||||
+14
-2
@@ -58,7 +58,8 @@ 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 renderWithForceLoad(GfxRenderer& renderer, int xOffset, int yOffset, bool forceLoad);
|
||||
void renderWithForceLoad(GfxRenderer& renderer, int xOffset, int yOffset, bool forceLoad,
|
||||
bool monochromeOutput = false);
|
||||
bool serialize(FsFile& file) override;
|
||||
PageElementTag getTag() const override { return TAG_PageImage; }
|
||||
static std::unique_ptr<PageImage> deserialize(FsFile& file);
|
||||
@@ -129,8 +130,19 @@ class Page {
|
||||
footnotes.push_back(entry);
|
||||
}
|
||||
|
||||
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset, bool forceLoadLargeImages = true) const;
|
||||
// skipDecodedImages=true: PageImages that would decode (not show as a placeholder)
|
||||
// are skipped. Used by the BW pass when an AA grayscale-image pass will redraw
|
||||
// them at full 4-level tonality. Placeholders still render in BW (they're just
|
||||
// a box+text, not dithered pixels).
|
||||
// monochromeImages=true: render images via the 1-bit Atkinson path. Used when
|
||||
// no grayscale image pass will follow (AA off or low-mem) so the BW
|
||||
// DirectPixelWriter `<3` rule yields clean black/white instead of muddy dark.
|
||||
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset, bool forceLoadLargeImages = true,
|
||||
bool skipDecodedImages = false, bool monochromeImages = false) const;
|
||||
void renderTextOnly(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
|
||||
// Renders only PageImages that have actual decoded pixels (skips placeholders,
|
||||
// which already rendered in the BW pass as a box+text).
|
||||
void renderImagesOnly(GfxRenderer& renderer, int xOffset, int yOffset, bool forceLoadLargeImages) const;
|
||||
// Decode any missing .pxc pixel caches for images on this page. Called before the
|
||||
// BW render so the large (~60 KB contiguous) PNG decoder allocation runs while heap
|
||||
// contig is at its peak — before font prewarm and BW backup chunks fragment it.
|
||||
|
||||
@@ -25,13 +25,16 @@ bool ImageBlock::imageExists() const { return Storage.exists(imagePath.c_str());
|
||||
|
||||
namespace {
|
||||
|
||||
std::string getCachePath(const std::string& imagePath, ImageDitherMode ditherMode) {
|
||||
std::string getCachePath(const std::string& imagePath, ImageDitherMode ditherMode, bool monochromeOutput) {
|
||||
// The monochrome (1-bit Atkinson) cache only stores values 0/3, so it can't be
|
||||
// reused for the 4-level grayscale render path — keep it in a separate file.
|
||||
const char* monoSuffix = monochromeOutput ? "_mono" : "";
|
||||
// Replace extension with .pxc (pixel cache)
|
||||
size_t dotPos = imagePath.rfind('.');
|
||||
if (dotPos != std::string::npos) {
|
||||
return imagePath.substr(0, dotPos) + getImageDitherCacheSuffix(ditherMode) + ".pxc";
|
||||
return imagePath.substr(0, dotPos) + getImageDitherCacheSuffix(ditherMode) + monoSuffix + ".pxc";
|
||||
}
|
||||
return imagePath + getImageDitherCacheSuffix(ditherMode) + ".pxc";
|
||||
return imagePath + getImageDitherCacheSuffix(ditherMode) + monoSuffix + ".pxc";
|
||||
}
|
||||
|
||||
bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, int y, int expectedWidth,
|
||||
@@ -118,7 +121,7 @@ bool ImageBlock::isLargeImage() const {
|
||||
|
||||
bool ImageBlock::hasPixelCache() const {
|
||||
const ImageDitherMode ditherMode = imageDitherModeFromSetting(SETTINGS.imageDithering);
|
||||
return Storage.exists(getCachePath(imagePath, ditherMode).c_str());
|
||||
return Storage.exists(getCachePath(imagePath, ditherMode, false).c_str());
|
||||
}
|
||||
|
||||
bool ImageBlock::wouldShowPlaceholder(bool forceLoad) const {
|
||||
@@ -126,12 +129,7 @@ bool ImageBlock::wouldShowPlaceholder(bool forceLoad) const {
|
||||
if (!isLargeImage()) return false;
|
||||
// If the pixel cache already exists the render is instant — no placeholder needed
|
||||
const ImageDitherMode ditherMode = imageDitherModeFromSetting(SETTINGS.imageDithering);
|
||||
const std::string pxcPath = [&] {
|
||||
size_t dot = imagePath.rfind('.');
|
||||
return (dot != std::string::npos ? imagePath.substr(0, dot) : imagePath) + getImageDitherCacheSuffix(ditherMode) +
|
||||
".pxc";
|
||||
}();
|
||||
return !Storage.exists(pxcPath.c_str());
|
||||
return !Storage.exists(getCachePath(imagePath, ditherMode, false).c_str());
|
||||
}
|
||||
|
||||
void ImageBlock::renderPlaceholder(GfxRenderer& renderer, const int x, const int y) const {
|
||||
@@ -156,7 +154,8 @@ void ImageBlock::renderPlaceholder(GfxRenderer& renderer, const int x, const int
|
||||
}
|
||||
}
|
||||
|
||||
void ImageBlock::render(GfxRenderer& renderer, const int x, const int y, const bool forceLoad) {
|
||||
void ImageBlock::render(GfxRenderer& renderer, const int x, const int y, const bool forceLoad,
|
||||
const bool monochromeOutput) {
|
||||
LOG_DBG("IMG", "Rendering image at %d,%d: %s (%dx%d)", x, y, imagePath.c_str(), width, height);
|
||||
|
||||
const int screenWidth = renderer.getScreenWidth();
|
||||
@@ -169,9 +168,11 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y, const b
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to render from pixel cache first (always, regardless of forceLoad)
|
||||
// Try to render from pixel cache first (always, regardless of forceLoad).
|
||||
// Mono and 4-level caches are kept in separate files so each render path gets
|
||||
// the dither output its DirectPixelWriter branch expects.
|
||||
const ImageDitherMode ditherMode = imageDitherModeFromSetting(SETTINGS.imageDithering);
|
||||
std::string cachePath = getCachePath(imagePath, ditherMode);
|
||||
std::string cachePath = getCachePath(imagePath, ditherMode, monochromeOutput);
|
||||
if (renderFromCache(renderer, cachePath, x, y, width, height)) {
|
||||
return;
|
||||
}
|
||||
@@ -210,6 +211,7 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y, const b
|
||||
config.performanceMode = false;
|
||||
config.useExactDimensions = true;
|
||||
config.cachePath = cachePath;
|
||||
config.monochromeOutput = monochromeOutput;
|
||||
|
||||
ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(imagePath);
|
||||
if (!decoder) {
|
||||
|
||||
@@ -38,7 +38,11 @@ class ImageBlock final : public Block {
|
||||
BlockType getType() override { return IMAGE_BLOCK; }
|
||||
bool isEmpty() override { return false; }
|
||||
|
||||
void render(GfxRenderer& renderer, int x, int y, bool forceLoad = true);
|
||||
// monochromeOutput=true switches the decode pipeline to a 1-bit Atkinson dither
|
||||
// that emits only levels 0 and 3, so the BW DirectPixelWriter `<3` rule yields
|
||||
// clean black/white instead of collapsing mid-greys to black. Used by the BW
|
||||
// pass when the AA grayscale image pass isn't available (AA off or low-mem).
|
||||
void render(GfxRenderer& renderer, int x, int y, bool forceLoad = true, bool monochromeOutput = false);
|
||||
bool serialize(FsFile& file);
|
||||
static std::unique_ptr<ImageBlock> deserialize(FsFile& file);
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
#include "PageListSink.h"
|
||||
|
||||
#include <Logging.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
PageListSink::PageListSink(const std::string& cachePath) : path(cachePath + "/pagelist.bin") {
|
||||
if (!Storage.openFileForWrite("EBP", path, file)) {
|
||||
LOG_ERR("EBP", "PageListSink: could not open pagelist.bin for writing");
|
||||
return;
|
||||
}
|
||||
// Placeholder count; patched in finalize().
|
||||
serialization::writePod(file, static_cast<uint16_t>(0));
|
||||
}
|
||||
|
||||
PageListSink::~PageListSink() {
|
||||
if (!finalized) {
|
||||
finalize();
|
||||
}
|
||||
}
|
||||
|
||||
void PageListSink::addEntry(const std::string& href, const std::string& anchor, const std::string& label) {
|
||||
if (!file.isOpen() || finalized) return;
|
||||
serialization::writeString(file, href);
|
||||
serialization::writeString(file, anchor);
|
||||
serialization::writeString(file, label);
|
||||
count++;
|
||||
}
|
||||
|
||||
void PageListSink::finalize() {
|
||||
if (finalized) return;
|
||||
finalized = true;
|
||||
if (!file.isOpen()) return;
|
||||
|
||||
if (count == 0) {
|
||||
file.close();
|
||||
Storage.remove(path.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
file.flush();
|
||||
if (!file.seek(0)) {
|
||||
LOG_ERR("EBP", "PageListSink: could not seek to patch count");
|
||||
file.close();
|
||||
return;
|
||||
}
|
||||
serialization::writePod(file, count);
|
||||
file.flush();
|
||||
file.close();
|
||||
LOG_DBG("EBP", "Wrote pagelist.bin with %u entries", static_cast<unsigned>(count));
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
#include <HalStorage.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
// Streams printed-page entries (href, anchor, label) directly to pagelist.bin
|
||||
// instead of buffering them in a std::vector. Buffering blows the X3 heap on
|
||||
// books with long EPUB 3 <nav epub:type="page-list"> sections (a few hundred
|
||||
// entries × 3 std::string each is enough to throw std::bad_alloc).
|
||||
//
|
||||
// File format matches the legacy writePageListBin() in Epub.cpp so the reader
|
||||
// side (Epub::loadPrintedPageList) is unchanged: u16 count, then per entry
|
||||
// writeString(href), writeString(anchor), writeString(label).
|
||||
//
|
||||
// Lifecycle: construct (opens the file and writes a placeholder count of 0),
|
||||
// addEntry(...) for each parsed entry, then finalize() to seek back and patch
|
||||
// the real count. If no entries were added, finalize() removes the file —
|
||||
// matching the legacy writer's "remove on empty" behaviour so subsequent
|
||||
// fallback parsers (NCX after nav, page-map after both) see no stale file.
|
||||
//
|
||||
// Not copyable; not thread-safe (each book parse is single-threaded).
|
||||
class PageListSink {
|
||||
public:
|
||||
explicit PageListSink(const std::string& cachePath);
|
||||
~PageListSink();
|
||||
|
||||
PageListSink(const PageListSink&) = delete;
|
||||
PageListSink& operator=(const PageListSink&) = delete;
|
||||
|
||||
// True if the file was opened successfully. When false, addEntry() and
|
||||
// finalize() are no-ops; callers don't need to check before each push.
|
||||
bool isOpen() const { return file.isOpen(); }
|
||||
|
||||
// Number of entries successfully streamed so far. Callers (e.g. Epub.cpp
|
||||
// orchestrator deciding whether the NCX fallback should run) read this
|
||||
// instead of inspecting the file.
|
||||
uint16_t entryCount() const { return count; }
|
||||
|
||||
void addEntry(const std::string& href, const std::string& anchor, const std::string& label);
|
||||
|
||||
// Patches the placeholder count at offset 0 with the real entry count and
|
||||
// closes the file. If no entries were added, the file is removed instead.
|
||||
// Safe to call multiple times — subsequent calls are no-ops.
|
||||
void finalize();
|
||||
|
||||
private:
|
||||
std::string path;
|
||||
FsFile file;
|
||||
uint16_t count = 0;
|
||||
bool finalized = false;
|
||||
};
|
||||
@@ -3,6 +3,8 @@
|
||||
#include <FsHelpers.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include "PageListSink.h"
|
||||
|
||||
bool PageMapParser::setup() {
|
||||
parser = XML_ParserCreate(nullptr);
|
||||
if (!parser) {
|
||||
@@ -88,7 +90,7 @@ void XMLCALL PageMapParser::startElement(void* userData, const XML_Char* name, c
|
||||
}
|
||||
}
|
||||
|
||||
if (label.empty() || rawHref.empty()) {
|
||||
if (!self->pageListSink || label.empty() || rawHref.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -99,5 +101,5 @@ void XMLCALL PageMapParser::startElement(void* userData, const XML_Char* name, c
|
||||
anchor = href.substr(pos + 1);
|
||||
href = href.substr(0, pos);
|
||||
}
|
||||
self->pageList.push_back({std::move(href), std::move(anchor), std::move(label)});
|
||||
self->pageListSink->addEntry(href, anchor, label);
|
||||
}
|
||||
|
||||
@@ -3,36 +3,30 @@
|
||||
#include <expat.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
class PageListSink;
|
||||
|
||||
// Parser for EPUB 2.01 page-map.xml. Each <page name="X" href="...#anchor"/> element
|
||||
// maps a printed page number to a spine location. Same output shape as TocNcxParser
|
||||
// and TocNavParser so all three feed the shared pagelist.bin writer.
|
||||
class PageMapParser final : public Print {
|
||||
public:
|
||||
struct PageListEntry {
|
||||
std::string href;
|
||||
std::string anchor;
|
||||
std::string label;
|
||||
};
|
||||
|
||||
private:
|
||||
const std::string& baseContentPath;
|
||||
size_t remainingSize;
|
||||
XML_Parser parser = nullptr;
|
||||
std::vector<PageListEntry> pageList;
|
||||
// Page-list entries are streamed straight to disk via this sink. Owned by
|
||||
// the caller (Epub.cpp); may be null when no page-list output is wanted.
|
||||
PageListSink* pageListSink;
|
||||
|
||||
static void startElement(void* userData, const XML_Char* name, const XML_Char** atts);
|
||||
|
||||
public:
|
||||
explicit PageMapParser(const std::string& baseContentPath, const size_t xmlSize)
|
||||
: baseContentPath(baseContentPath), remainingSize(xmlSize) {}
|
||||
explicit PageMapParser(const std::string& baseContentPath, const size_t xmlSize, PageListSink* pageListSink)
|
||||
: baseContentPath(baseContentPath), remainingSize(xmlSize), pageListSink(pageListSink) {}
|
||||
~PageMapParser() override;
|
||||
|
||||
bool setup();
|
||||
|
||||
size_t write(uint8_t) override;
|
||||
size_t write(const uint8_t* buffer, size_t size) override;
|
||||
|
||||
const std::vector<PageListEntry>& getPageList() const { return pageList; }
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <Logging.h>
|
||||
|
||||
#include "../BookMetadataCache.h"
|
||||
#include "PageListSink.h"
|
||||
|
||||
bool TocNavParser::setup() {
|
||||
parser = XML_ParserCreate(nullptr);
|
||||
@@ -178,7 +179,7 @@ void XMLCALL TocNavParser::endElement(void* userData, const XML_Char* name) {
|
||||
// ---- Page-list nav close handlers (checked before TOC handlers because IN_PL_* states
|
||||
// sort after IN_NAV_TOC, but we want exact-state matching either way).
|
||||
if (strcmp(name, "a") == 0 && self->state == IN_PL_ANCHOR) {
|
||||
if (!self->currentPageLabel.empty() && !self->currentPageHref.empty()) {
|
||||
if (self->pageListSink && !self->currentPageLabel.empty() && !self->currentPageHref.empty()) {
|
||||
std::string href = FsHelpers::normalisePath(self->baseContentPath + self->currentPageHref);
|
||||
std::string anchor;
|
||||
const size_t pos = href.find('#');
|
||||
@@ -186,10 +187,10 @@ void XMLCALL TocNavParser::endElement(void* userData, const XML_Char* name) {
|
||||
anchor = href.substr(pos + 1);
|
||||
href = href.substr(0, pos);
|
||||
}
|
||||
self->pageList.push_back({std::move(href), std::move(anchor), std::move(self->currentPageLabel)});
|
||||
self->currentPageLabel.clear();
|
||||
self->currentPageHref.clear();
|
||||
self->pageListSink->addEntry(href, anchor, self->currentPageLabel);
|
||||
}
|
||||
self->currentPageLabel.clear();
|
||||
self->currentPageHref.clear();
|
||||
self->state = IN_PL_LI;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
#include <expat.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
class BookMetadataCache;
|
||||
class PageListSink;
|
||||
|
||||
// Parser for EPUB 3 nav.xhtml navigation documents
|
||||
// Parses HTML5 nav elements with epub:type="toc" (table of contents) and
|
||||
@@ -25,22 +25,15 @@ class TocNavParser final : public Print {
|
||||
IN_PL_ANCHOR, // Inside <a> (within page-list nav)
|
||||
};
|
||||
|
||||
public:
|
||||
// One printed-page entry from <nav epub:type="page-list">: file href (normalised),
|
||||
// anchor fragment, and visible label. Matches TocNcxParser::PageListEntry in shape so
|
||||
// both parsers can feed the same pagelist.bin writer.
|
||||
struct PageListEntry {
|
||||
std::string href;
|
||||
std::string anchor;
|
||||
std::string label;
|
||||
};
|
||||
|
||||
private:
|
||||
const std::string& baseContentPath;
|
||||
size_t remainingSize;
|
||||
XML_Parser parser = nullptr;
|
||||
ParserState state = START;
|
||||
BookMetadataCache* cache;
|
||||
// Page-list entries are streamed straight to disk via this sink. Owned by
|
||||
// the caller (Epub.cpp); may be null when no page-list output is wanted.
|
||||
PageListSink* pageListSink;
|
||||
|
||||
// Track nesting depth for <ol> elements to determine TOC depth
|
||||
uint8_t olDepth = 0;
|
||||
@@ -53,21 +46,19 @@ class TocNavParser final : public Print {
|
||||
uint8_t plOlDepth = 0;
|
||||
std::string currentPageLabel;
|
||||
std::string currentPageHref;
|
||||
std::vector<PageListEntry> pageList;
|
||||
|
||||
static void startElement(void* userData, const XML_Char* name, const XML_Char** atts);
|
||||
static void characterData(void* userData, const XML_Char* s, int len);
|
||||
static void endElement(void* userData, const XML_Char* name);
|
||||
|
||||
public:
|
||||
explicit TocNavParser(const std::string& baseContentPath, const size_t xmlSize, BookMetadataCache* cache)
|
||||
: baseContentPath(baseContentPath), remainingSize(xmlSize), cache(cache) {}
|
||||
explicit TocNavParser(const std::string& baseContentPath, const size_t xmlSize, BookMetadataCache* cache,
|
||||
PageListSink* pageListSink)
|
||||
: baseContentPath(baseContentPath), remainingSize(xmlSize), cache(cache), pageListSink(pageListSink) {}
|
||||
~TocNavParser() override;
|
||||
|
||||
bool setup();
|
||||
|
||||
size_t write(uint8_t) override;
|
||||
size_t write(const uint8_t* buffer, size_t size) override;
|
||||
|
||||
const std::vector<PageListEntry>& getPageList() const { return pageList; }
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <Logging.h>
|
||||
|
||||
#include "../BookMetadataCache.h"
|
||||
#include "PageListSink.h"
|
||||
|
||||
bool TocNcxParser::setup() {
|
||||
parser = XML_ParserCreate(nullptr);
|
||||
@@ -228,7 +229,7 @@ void XMLCALL TocNcxParser::endElement(void* userData, const XML_Char* name) {
|
||||
}
|
||||
|
||||
if (self->state == IN_PAGE_TARGET && strcmp(name, "pageTarget") == 0) {
|
||||
if (!self->currentPageLabel.empty() && !self->currentPageSrc.empty()) {
|
||||
if (self->pageListSink && !self->currentPageLabel.empty() && !self->currentPageSrc.empty()) {
|
||||
std::string href = FsHelpers::normalisePath(self->baseContentPath + self->currentPageSrc);
|
||||
std::string anchor;
|
||||
const size_t pos = href.find('#');
|
||||
@@ -236,7 +237,7 @@ void XMLCALL TocNcxParser::endElement(void* userData, const XML_Char* name) {
|
||||
anchor = href.substr(pos + 1);
|
||||
href = href.substr(0, pos);
|
||||
}
|
||||
self->pageList.push_back({std::move(href), std::move(anchor), self->currentPageLabel});
|
||||
self->pageListSink->addEntry(href, anchor, self->currentPageLabel);
|
||||
}
|
||||
self->currentPageLabel.clear();
|
||||
self->currentPageSrc.clear();
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
#include <expat.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
class BookMetadataCache;
|
||||
class PageListSink;
|
||||
|
||||
class TocNcxParser final : public Print {
|
||||
enum ParserState {
|
||||
@@ -22,20 +22,15 @@ class TocNcxParser final : public Print {
|
||||
IN_PAGE_TARGET_LABEL_TEXT,
|
||||
};
|
||||
|
||||
public:
|
||||
// One printed-page reference from <pageList>: file href (normalised) + anchor fragment + visible label.
|
||||
struct PageListEntry {
|
||||
std::string href; // normalised path to spine item
|
||||
std::string anchor; // fragment (empty = top of file)
|
||||
std::string label; // value shown to the reader (e.g. "1", "iv")
|
||||
};
|
||||
|
||||
private:
|
||||
const std::string& baseContentPath;
|
||||
size_t remainingSize;
|
||||
XML_Parser parser = nullptr;
|
||||
ParserState state = START;
|
||||
BookMetadataCache* cache;
|
||||
// Page-list entries are streamed straight to disk via this sink. Owned by
|
||||
// the caller (Epub.cpp); may be null when no page-list output is wanted.
|
||||
PageListSink* pageListSink;
|
||||
|
||||
std::string currentLabel;
|
||||
std::string currentSrc;
|
||||
@@ -44,21 +39,19 @@ class TocNcxParser final : public Print {
|
||||
// <pageList> collection state
|
||||
std::string currentPageLabel;
|
||||
std::string currentPageSrc;
|
||||
std::vector<PageListEntry> pageList;
|
||||
|
||||
static void startElement(void* userData, const XML_Char* name, const XML_Char** atts);
|
||||
static void characterData(void* userData, const XML_Char* s, int len);
|
||||
static void endElement(void* userData, const XML_Char* name);
|
||||
|
||||
public:
|
||||
explicit TocNcxParser(const std::string& baseContentPath, const size_t xmlSize, BookMetadataCache* cache)
|
||||
: baseContentPath(baseContentPath), remainingSize(xmlSize), cache(cache) {}
|
||||
explicit TocNcxParser(const std::string& baseContentPath, const size_t xmlSize, BookMetadataCache* cache,
|
||||
PageListSink* pageListSink)
|
||||
: baseContentPath(baseContentPath), remainingSize(xmlSize), cache(cache), pageListSink(pageListSink) {}
|
||||
~TocNcxParser() override;
|
||||
|
||||
bool setup();
|
||||
|
||||
size_t write(uint8_t) override;
|
||||
size_t write(const uint8_t* buffer, size_t size) override;
|
||||
|
||||
const std::vector<PageListEntry>& getPageList() const { return pageList; }
|
||||
};
|
||||
|
||||
@@ -624,7 +624,9 @@ static void renderGlyphFast2BitPortrait(uint8_t* const frameBuffer, const uint8_
|
||||
const int widthBytes, const int fbOriginY, const int fbRows) {
|
||||
for (int glyphX = 0; glyphX < glyphWidth; glyphX++) {
|
||||
const int phyY = inverted ? (screenXBase + glyphX) : (displayHeight - 1 - (screenXBase + glyphX));
|
||||
if (phyY < 0 || phyY >= displayHeight) continue;
|
||||
// Single unsigned compare drops both off-band rows (strip mode) and any
|
||||
// out-of-frame row (full-frame mode: fbOriginY=0, fbRows=displayHeight),
|
||||
// matching what the Landscape* cases above do.
|
||||
const int rowY = phyY - fbOriginY;
|
||||
if (static_cast<unsigned>(rowY) >= static_cast<unsigned>(fbRows)) continue;
|
||||
uint8_t* const row = frameBuffer + rowY * widthBytes;
|
||||
@@ -1945,6 +1947,32 @@ void GfxRenderer::beginStripTarget(uint8_t* scratch, int stripY0, int stripRows)
|
||||
stripY0_ = stripY0;
|
||||
stripRows_ = stripRows;
|
||||
stripActive_ = true;
|
||||
|
||||
// Latch the orientation→phyY linear coefficients used by glyphIntersectsStrip()
|
||||
// so the cull is one multiply-add per bbox corner instead of a switch.
|
||||
// Derived from rotateCoordinates() with only the y-output retained.
|
||||
switch (getOrientation()) {
|
||||
case Portrait:
|
||||
stripPhyYStepX_ = -1;
|
||||
stripPhyYStepY_ = 0;
|
||||
stripPhyYBase_ = panelHeight - 1;
|
||||
break;
|
||||
case LandscapeClockwise:
|
||||
stripPhyYStepX_ = 0;
|
||||
stripPhyYStepY_ = -1;
|
||||
stripPhyYBase_ = panelHeight - 1;
|
||||
break;
|
||||
case PortraitInverted:
|
||||
stripPhyYStepX_ = 1;
|
||||
stripPhyYStepY_ = 0;
|
||||
stripPhyYBase_ = 0;
|
||||
break;
|
||||
case LandscapeCounterClockwise:
|
||||
stripPhyYStepX_ = 0;
|
||||
stripPhyYStepY_ = 1;
|
||||
stripPhyYBase_ = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void GfxRenderer::endStripTarget() const {
|
||||
@@ -1954,16 +1982,44 @@ void GfxRenderer::endStripTarget() const {
|
||||
stripRows_ = 0;
|
||||
}
|
||||
|
||||
bool GfxRenderer::acquireStripScratch() {
|
||||
if (stripScratch_) return true;
|
||||
if (panelWidthBytes == 0 || panelHeight == 0) {
|
||||
LOG_ERR("GFX", "acquireStripScratch called before begin()");
|
||||
return false;
|
||||
}
|
||||
int rows = STRIP_SCRATCH_TARGET_BYTES / panelWidthBytes;
|
||||
if (rows < 1) rows = 1;
|
||||
if (rows > static_cast<int>(panelHeight)) rows = panelHeight;
|
||||
const size_t bytes = static_cast<size_t>(panelWidthBytes) * rows;
|
||||
stripScratch_ = static_cast<uint8_t*>(heap_caps_malloc(bytes, MALLOC_CAP_8BIT | MALLOC_CAP_DEFAULT));
|
||||
if (!stripScratch_) {
|
||||
LOG_INF("GFX", "Strip scratch alloc failed (%zu bytes)", bytes);
|
||||
return false;
|
||||
}
|
||||
stripScratchRows_ = rows;
|
||||
return true;
|
||||
}
|
||||
|
||||
void GfxRenderer::releaseStripScratch() {
|
||||
if (!stripScratch_) return;
|
||||
heap_caps_free(stripScratch_);
|
||||
stripScratch_ = nullptr;
|
||||
stripScratchRows_ = 0;
|
||||
}
|
||||
|
||||
bool GfxRenderer::glyphIntersectsStrip(int x0, int y0, int x1, int y1) const {
|
||||
if (!stripActive_) {
|
||||
return true;
|
||||
}
|
||||
// Rotate the two opposite bbox corners to physical coords. For 90-degree
|
||||
// orientations the physical bbox stays axis-aligned, so min/max of the two
|
||||
// rotated corners' Y bounds the glyph's physical y-extent.
|
||||
int ax, ay, bx, by;
|
||||
rotateCoordinates(getOrientation(), x0, y0, &ax, &ay, panelWidth, panelHeight);
|
||||
rotateCoordinates(getOrientation(), x1, y1, &bx, &by, panelWidth, panelHeight);
|
||||
// Use the precomputed (stepX, stepY, base) latched in beginStripTarget() so
|
||||
// each call is two multiply-adds + a range check, no rotateCoordinates
|
||||
// switch. The four 90-degree orientations all reduce to "phyY depends on
|
||||
// exactly one of (x, y)" — exactly one of stepX/stepY is non-zero — so phyY
|
||||
// is monotonic across the bbox and the two opposite-corner phyY values
|
||||
// bracket the full physical y-extent.
|
||||
const int ay = stripPhyYStepX_ * x0 + stripPhyYStepY_ * y0 + stripPhyYBase_;
|
||||
const int by = stripPhyYStepX_ * x1 + stripPhyYStepY_ * y1 + stripPhyYBase_;
|
||||
const int minY = ay < by ? ay : by;
|
||||
const int maxY = ay > by ? ay : by;
|
||||
return !(maxY < stripY0_ || minY >= stripY0_ + stripRows_);
|
||||
@@ -2602,10 +2658,11 @@ void GfxRenderer::restoreBwBuffer() {
|
||||
bwSnapshotRowEnd = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup grayscale buffers using the current frame buffer.
|
||||
* Use this when BW buffer was re-rendered instead of stored/restored.
|
||||
*/
|
||||
// Cleanup grayscale buffers using the current frame buffer.
|
||||
// Use this when BW buffer was re-rendered instead of stored/restored.
|
||||
// On X3 the display call transiently Y-flips frameBuffer in place and flips
|
||||
// it back before returning; the logical contents are unchanged but callers
|
||||
// must not race a framebuffer reader against this call. See the header.
|
||||
void GfxRenderer::cleanupGrayscaleWithFrameBuffer() const {
|
||||
if (frameBuffer) {
|
||||
display.cleanupGrayscaleBuffers(frameBuffer);
|
||||
|
||||
@@ -85,6 +85,26 @@ class GfxRenderer {
|
||||
mutable int stripRows_ = 0;
|
||||
mutable bool stripActive_ = false;
|
||||
|
||||
// Precomputed orientation→physicalY linear coefficients for the band-cull
|
||||
// fast path. Latched once in beginStripTarget() and used by every
|
||||
// glyphIntersectsStrip() call to avoid the 4-case rotateCoordinates switch
|
||||
// per glyph. phyY = stripPhyYStepX_ * x + stripPhyYStepY_ * y + stripPhyYBase_,
|
||||
// with steps in {-1, 0, 1}. Orientation can't change mid-pass; the strip
|
||||
// session is the natural latch point.
|
||||
mutable int8_t stripPhyYStepX_ = 0;
|
||||
mutable int8_t stripPhyYStepY_ = 0;
|
||||
mutable int stripPhyYBase_ = 0;
|
||||
|
||||
// Session-owned strip scratch. acquireStripScratch() allocates once (sized
|
||||
// STRIP_SCRATCH_TARGET_BYTES, rounded to a whole number of rows of
|
||||
// panelWidthBytes) and the buffer persists until releaseStripScratch().
|
||||
// Allocating per page turn fragments the tight ESP32-C3 heap badly enough
|
||||
// to cause AA to suspend after a few pages; hold one buffer for the reader
|
||||
// session instead. The strip height we pick at acquire time is exposed via
|
||||
// getStripScratchRows() so the caller plans its band loop around it.
|
||||
uint8_t* stripScratch_ = nullptr;
|
||||
int stripScratchRows_ = 0;
|
||||
|
||||
void renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, int* y, bool pixelState,
|
||||
EpdFontFamily::Style style) const;
|
||||
void freeBwBufferChunks();
|
||||
@@ -108,7 +128,10 @@ class GfxRenderer {
|
||||
orientation(static_cast<int>(Portrait)),
|
||||
fadingFix(false),
|
||||
textDarkness(1) {}
|
||||
~GfxRenderer() { freeBwBufferChunks(); }
|
||||
~GfxRenderer() {
|
||||
freeBwBufferChunks();
|
||||
releaseStripScratch();
|
||||
}
|
||||
|
||||
static constexpr int VIEWABLE_MARGIN_TOP = 9;
|
||||
static constexpr int VIEWABLE_MARGIN_RIGHT = 3;
|
||||
@@ -245,6 +268,22 @@ class GfxRenderer {
|
||||
void beginStripTarget(uint8_t* scratch, int stripY0, int stripRows) const;
|
||||
void endStripTarget() const;
|
||||
|
||||
// Session-owned strip scratch lifecycle. Reader activities call acquire on
|
||||
// onEnter() and release on onExit(); the buffer is then reused across all
|
||||
// page-turn AA passes for that session. Acquire is idempotent and returns
|
||||
// true on success or when the buffer is already held. Sizing uses the
|
||||
// current panel geometry, so begin() must have run first.
|
||||
bool acquireStripScratch();
|
||||
void releaseStripScratch();
|
||||
uint8_t* getStripScratch() const { return stripScratch_; }
|
||||
int getStripScratchRows() const { return stripScratchRows_; }
|
||||
|
||||
// Target byte budget for the session-owned strip scratch. ~24 KB lands at
|
||||
// 240 rows on both X4 (100 B/row, panel 480) → 2 bands/plane and X3
|
||||
// (99 B/row, panel 528) → 3 bands/plane. acquire clamps to panelHeight so
|
||||
// a smaller panel never over-allocates.
|
||||
static constexpr int STRIP_SCRATCH_TARGET_BYTES = 24000;
|
||||
|
||||
// Active pixel-write target for raw writers that bypass drawPixel for speed.
|
||||
// When a strip target is active these return the band scratch plus its
|
||||
// physical-row origin and extent; otherwise the full framebuffer ([0,
|
||||
@@ -264,6 +303,18 @@ class GfxRenderer {
|
||||
bool storeBwBuffer(); // Returns true if buffer was stored successfully
|
||||
bool storeBwBufferRect(int x, int y, int width, int height); // Store only rows intersecting logical rect
|
||||
void restoreBwBuffer(); // Restore and free the stored buffer
|
||||
// Re-syncs the controller's RED RAM from the current BW framebuffer so the
|
||||
// next differential page turn has a clean baseline. Called after the tiled
|
||||
// grayscale path, which leaves the panel's gray planes loaded but the BW
|
||||
// framebuffer untouched.
|
||||
//
|
||||
// const-correctness caveat: on X3 the underlying display call (see
|
||||
// EInkDisplay::cleanupGrayscaleBuffers) performs an in-place Y-flip of the
|
||||
// framebuffer bytes, sends them, and flips back. The framebuffer's logical
|
||||
// contents are identical before and after, but during the call the bytes
|
||||
// are transiently reordered. The method stays `const` because the renderer's
|
||||
// observable state doesn't change; callers must not race a framebuffer
|
||||
// reader against this call.
|
||||
void cleanupGrayscaleWithFrameBuffer() const;
|
||||
|
||||
// Font helpers
|
||||
|
||||
+1
-1
Submodule open-x4-sdk updated: 66e013fb7f...213e206597
@@ -226,8 +226,13 @@ class CrossPointSettings {
|
||||
// X3-only: when on, the AA refresh uses the 7-frame community grayscale LUT
|
||||
// (~130 ms panel time) instead of the OEM 53-frame LUT (~2.4 s). Mid-tones
|
||||
// run slightly darker than X4. Matches what papyrix-reader has shipped since
|
||||
// 2025-11. Default off preserves OEM-fidelity grays. No effect on X4.
|
||||
uint8_t fastAntiAliasing = 0;
|
||||
// 2025-11. Default on — the 2.2 s/page win dwarfs the subtle mid-tone shift.
|
||||
// No effect on X4.
|
||||
//
|
||||
// JSON key was bumped from "fastAntiAliasing" to "fastAntiAliasingV2" when
|
||||
// the default flipped to 1: existing settings files with the old key are
|
||||
// ignored, so every device picks up the new C++ default on next load.
|
||||
uint8_t fastAntiAliasing = 1;
|
||||
// Text darkness (0 = normal, 1 = dark, 2 = extra dark). Default 1 preserves
|
||||
// historical AA rendering (both grayscale shades drawn in the MSB pass).
|
||||
uint8_t textDarkness = DARKNESS_DARK;
|
||||
|
||||
+1
-1
@@ -115,7 +115,7 @@ inline const std::vector<SettingInfo> list = {
|
||||
// (~2.4 s panel time, X4-accurate grays) for the 7-frame community LUT
|
||||
// (~130 ms, mid-tones slightly darker). See open-x4-sdk
|
||||
// EInkDisplay::setFastGrayscaleLut for trade-offs.
|
||||
SettingInfo::Toggle(StrId::STR_FAST_AA, &CrossPointSettings::fastAntiAliasing, "fastAntiAliasing",
|
||||
SettingInfo::Toggle(StrId::STR_FAST_AA, &CrossPointSettings::fastAntiAliasing, "fastAntiAliasingV2",
|
||||
StrId::STR_CAT_READER)
|
||||
.withSubmenu(StrId::STR_MENU_READER_FONT)
|
||||
.withDeviceTarget(SettingDeviceTarget::X3),
|
||||
|
||||
@@ -129,15 +129,18 @@ inline void logReaderMemSnapshot(const char*) {}
|
||||
//
|
||||
// Returns true when the strip path ran end-to-end (controller now holds the AA
|
||||
// planes and the live BW frame is clean). Returns false when the controller
|
||||
// doesn't support strip grayscale OR the scratch allocation fails — caller
|
||||
// should fall back to the legacy storeBwBufferRect path.
|
||||
// doesn't support strip grayscale OR the session strip scratch isn't held
|
||||
// (acquireStripScratch in onEnter failed, e.g. heap was already too tight at
|
||||
// reader open). Caller should fall back to the legacy storeBwBufferRect path.
|
||||
//
|
||||
// The page is re-rendered ceil(panelHeight/STRIP_ROWS) times per plane, but
|
||||
// The page is re-rendered ceil(panelHeight/stripRows) times per plane, but
|
||||
// renderCharImpl culls out-of-band glyphs before bitmap decode so the cost
|
||||
// stays close to one render. Only renderTextOnly() is called here, matching the
|
||||
// legacy AA pass — images and HRs do not participate in grayscale.
|
||||
// stays close to one render. Text always participates; images participate when
|
||||
// `includeImages` is set (caller decides — typically true when the page has any
|
||||
// decoded images, false otherwise to skip the per-strip image cost on text-only
|
||||
// pages).
|
||||
bool runTiledGrayscalePass(GfxRenderer& renderer, const Page& page, int fontId, int marginLeft, int contentTop,
|
||||
bool fastAA) {
|
||||
bool fastAA, bool includeImages, bool forceLoadLargeImages) {
|
||||
if (!renderer.supportsStripGrayscale()) return false;
|
||||
|
||||
// Push the SETTINGS toggle into the SDK before the AA refresh. No-op on X4;
|
||||
@@ -146,31 +149,32 @@ bool runTiledGrayscalePass(GfxRenderer& renderer, const Page& page, int fontId,
|
||||
// effect on the next page flip without rebooting.
|
||||
renderer.setFastGrayscaleLut(fastAA);
|
||||
|
||||
// Strip height trades scratch size for the number of re-renders. Each render
|
||||
// pays layout + glyph-cull overhead even when bitmap decode is skipped, so
|
||||
// fewer/bigger bands win as long as the scratch fits. 240 rows × ~100 bytes
|
||||
// ≈ ~24 KB — still well below the legacy partial-snapshot footprint while
|
||||
// cutting X3 (480 px) to 2 bands/plane and X4 (800 px) to 4 bands/plane.
|
||||
constexpr int STRIP_ROWS = 240;
|
||||
const int gh = renderer.getDisplayHeight();
|
||||
const int gwBytes = renderer.getDisplayWidthBytes();
|
||||
|
||||
auto scratch = std::unique_ptr<uint8_t[]>(new (std::nothrow) uint8_t[static_cast<size_t>(gwBytes) * STRIP_ROWS]);
|
||||
if (!scratch) {
|
||||
LOG_INF("ERS", "Tiled grayscale: scratch alloc failed (%d bytes); falling back to legacy path",
|
||||
gwBytes * STRIP_ROWS);
|
||||
// Strip scratch is owned by GfxRenderer for the reader session
|
||||
// (acquireStripScratch in onEnter, releaseStripScratch in onExit). Allocating
|
||||
// per page turn fragmented the ESP32-C3 heap badly enough to flip AA into the
|
||||
// "suspended low memory" state after a few pages, so this path now refuses
|
||||
// and falls back to the legacy snapshot if no session scratch is held.
|
||||
uint8_t* const scratch = renderer.getStripScratch();
|
||||
const int stripRows = renderer.getStripScratchRows();
|
||||
if (!scratch || stripRows <= 0) {
|
||||
return false;
|
||||
}
|
||||
const int gh = renderer.getDisplayHeight();
|
||||
|
||||
auto renderPlane = [&](GfxRenderer::RenderMode mode, bool lsbPlane) {
|
||||
renderer.setRenderMode(mode);
|
||||
for (int y = 0; y < gh; y += STRIP_ROWS) {
|
||||
const int rows = (gh - y < STRIP_ROWS) ? (gh - y) : STRIP_ROWS;
|
||||
renderer.beginStripTarget(scratch.get(), y, rows);
|
||||
for (int y = 0; y < gh; y += stripRows) {
|
||||
const int rows = (gh - y < stripRows) ? (gh - y) : stripRows;
|
||||
renderer.beginStripTarget(scratch, y, rows);
|
||||
renderer.clearScreen(0x00);
|
||||
page.renderTextOnly(renderer, fontId, marginLeft, contentTop);
|
||||
if (includeImages) {
|
||||
// DirectPixelWriter clips against the active strip via originY/clipRows,
|
||||
// so off-band image pixels are dropped automatically.
|
||||
page.renderImagesOnly(renderer, marginLeft, contentTop, forceLoadLargeImages);
|
||||
}
|
||||
renderer.endStripTarget();
|
||||
renderer.writeGrayscalePlaneStrip(lsbPlane, scratch.get(), y, rows);
|
||||
renderer.writeGrayscalePlaneStrip(lsbPlane, scratch, y, rows);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -288,6 +292,14 @@ void EpubReaderActivity::onEnter() {
|
||||
}
|
||||
logReaderMemSnapshot("onEnter_after_orientation");
|
||||
|
||||
// Allocate the strip scratch once per reader session so tiled grayscale
|
||||
// (runTiledGrayscalePass) doesn't have to malloc ~24 KB on every page turn.
|
||||
// Failure is non-fatal: the AA pass falls back to the legacy snapshot path.
|
||||
if (!renderer.acquireStripScratch()) {
|
||||
LOG_INF("ERS", "Strip scratch unavailable; tiled grayscale will fall back to legacy snapshot");
|
||||
}
|
||||
logReaderMemSnapshot("onEnter_after_strip_scratch");
|
||||
|
||||
epub->setupCacheDir();
|
||||
logReaderMemSnapshot("onEnter_after_setupCacheDir");
|
||||
|
||||
@@ -402,6 +414,7 @@ void EpubReaderActivity::onExit() {
|
||||
epub.reset();
|
||||
currentPageFootnotes.clear();
|
||||
currentPageFootnotes.shrink_to_fit();
|
||||
renderer.releaseStripScratch();
|
||||
logReaderMemSnapshot("onExit_after_release");
|
||||
}
|
||||
|
||||
@@ -2201,7 +2214,14 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
lastRenderStats.forcedHalfRefresh = forceHalfRefreshThisPage;
|
||||
|
||||
logReaderMemSnapshot("before_bw_render");
|
||||
page->render(renderer, getEffectiveReaderFontId(), orientedMarginLeft, contentTop, effectiveForceLoad);
|
||||
// When AA + grayscale image pass will run, skip decoded images in BW so they
|
||||
// don't get squashed by DirectPixelWriter's `<3` rule (3/4 dither levels → black).
|
||||
// When AA isn't available, render images via the 1-bit Atkinson dither so the
|
||||
// BW pass produces clean monochrome instead of muddy dark grays.
|
||||
const bool grayscaleImagePassWillRun = imagePageWithAA;
|
||||
page->render(renderer, getEffectiveReaderFontId(), orientedMarginLeft, contentTop, effectiveForceLoad,
|
||||
/*skipDecodedImages=*/grayscaleImagePassWillRun,
|
||||
/*monochromeImages=*/!grayscaleImagePassWillRun);
|
||||
renderStatusBar();
|
||||
if (showTruncatedSectionHintThisRender) {
|
||||
const int hintX = orientedMarginLeft + 4;
|
||||
@@ -2225,27 +2245,11 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
logReaderMemSnapshot("after_bw_render");
|
||||
|
||||
if (imagePageWithAA) {
|
||||
// Double FAST_REFRESH with selective image blanking (pablohc's technique):
|
||||
// HALF_REFRESH sets particles too firmly for the grayscale LUT to adjust.
|
||||
// Instead, blank only the image area and do two fast refreshes.
|
||||
// Step 1: Display page with image area blanked (text appears, image area white)
|
||||
// Step 2: Re-render with images and display again (images appear clean)
|
||||
int16_t imgX, imgY, imgW, imgH;
|
||||
if (page->getImageBoundingBox(imgX, imgY, imgW, imgH)) {
|
||||
renderer.fillRect(imgX + orientedMarginLeft, imgY + contentTop, imgW, imgH, false);
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
|
||||
// Re-render page content to restore images into the blanked area
|
||||
// Status bar is not re-rendered here to avoid reading stale dynamic values (e.g. battery %)
|
||||
page->render(renderer, getEffectiveReaderFontId(), orientedMarginLeft, contentTop, effectiveForceLoad);
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
} else {
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
}
|
||||
// Double FAST_REFRESH handles ghosting for image pages; don't count toward full refresh cadence
|
||||
if (forceHalfRefreshThisPage) {
|
||||
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
||||
}
|
||||
// The BW pass skipped decoded images, so the image area is currently blank
|
||||
// in the framebuffer — no need for the legacy double-FAST_REFRESH blanking
|
||||
// dance (pablohc's technique). The image will be painted by the subsequent
|
||||
// grayscale image pass, which produces the cleaner 4-level result anyway.
|
||||
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);
|
||||
} else if (forceHalfRefreshThisPage) {
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
||||
@@ -2272,7 +2276,8 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
logReaderMemSnapshot("tiled_gray_begin");
|
||||
const auto tTiledBegin = millis();
|
||||
grayscaleDone = runTiledGrayscalePass(renderer, *page, getEffectiveReaderFontId(), orientedMarginLeft, contentTop,
|
||||
SETTINGS.fastAntiAliasing);
|
||||
SETTINGS.fastAntiAliasing,
|
||||
/*includeImages=*/imagePageWithAA, effectiveForceLoad);
|
||||
if (grayscaleDone) {
|
||||
tiledGrayMs = millis() - tTiledBegin;
|
||||
fcm->logStats("tiled_gray");
|
||||
@@ -2336,6 +2341,9 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
renderer.clearScreen(0x00);
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
|
||||
page->renderTextOnly(renderer, getEffectiveReaderFontId(), orientedMarginLeft, contentTop);
|
||||
if (imagePageWithAA) {
|
||||
page->renderImagesOnly(renderer, orientedMarginLeft, contentTop, effectiveForceLoad);
|
||||
}
|
||||
renderer.copyGrayscaleLsbBuffers();
|
||||
const auto tGrayLsb = millis();
|
||||
logReaderMemSnapshot("gray_lsb_end");
|
||||
@@ -2345,6 +2353,9 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
renderer.clearScreen(0x00);
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
|
||||
page->renderTextOnly(renderer, getEffectiveReaderFontId(), orientedMarginLeft, contentTop);
|
||||
if (imagePageWithAA) {
|
||||
page->renderImagesOnly(renderer, orientedMarginLeft, contentTop, effectiveForceLoad);
|
||||
}
|
||||
renderer.copyGrayscaleMsbBuffers();
|
||||
const auto tGrayMsb = millis();
|
||||
logReaderMemSnapshot("gray_msb_end");
|
||||
@@ -2384,6 +2395,16 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
bwRestoreMs = tBwRestore - tBwStore;
|
||||
}
|
||||
|
||||
// Recovery: the BW pass deliberately skipped decoded images expecting the
|
||||
// grayscale image pass to paint them, but no grayscale pass ran. Re-render
|
||||
// the images in BW using the 1-bit Atkinson dither (monochromeImages=true)
|
||||
// so the cover at least shows up as clean black/white instead of nothing.
|
||||
if (imagePageWithAA) {
|
||||
page->render(renderer, getEffectiveReaderFontId(), orientedMarginLeft, contentTop, effectiveForceLoad,
|
||||
/*skipDecodedImages=*/false, /*monochromeImages=*/true);
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
}
|
||||
|
||||
const auto tEnd = millis();
|
||||
lastRenderStats.usedGrayscale = false;
|
||||
lastRenderStats.phases = {
|
||||
@@ -2453,8 +2474,9 @@ void EpubReaderActivity::displayPreRenderedPage(const Page& page, const int orie
|
||||
// support strip grayscale or when the strip scratch can't be allocated.
|
||||
const bool aaConfigured = getEffectiveTextAntiAliasing() && !antiAliasingSuspendedLowMemory;
|
||||
if (aaConfigured) {
|
||||
// Pre-rendered pages are text-only — no image pass needed.
|
||||
if (runTiledGrayscalePass(renderer, page, getEffectiveReaderFontId(), orientedMarginLeft, contentTop,
|
||||
SETTINGS.fastAntiAliasing)) {
|
||||
SETTINGS.fastAntiAliasing, /*includeImages=*/false, /*forceLoadLargeImages=*/false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2535,8 +2557,14 @@ void EpubReaderActivity::renderStatusBar() const {
|
||||
static_cast<uint16_t>(section->currentPage));
|
||||
std::string printedPageLabel;
|
||||
if (section) {
|
||||
if (const auto label = section->getPrintedPageLabelForPage(static_cast<uint16_t>(section->currentPage))) {
|
||||
const auto page = static_cast<uint16_t>(section->currentPage);
|
||||
if (const auto label = section->getPrintedPageLabelForPage(page)) {
|
||||
// Exact-match label (already parenthesised, may be "7/8" when multiple anchors collapse).
|
||||
printedPageLabel = *label;
|
||||
} else if (const auto nearest = section->getNearestPrintedPageLabelAtOrBefore(page)) {
|
||||
// No pagebreak on this device page: show the last printed-page label we passed within
|
||||
// this section so the status bar still tells the reader which printed page they're on.
|
||||
printedPageLabel = std::string("(") + *nearest + ")";
|
||||
}
|
||||
}
|
||||
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, isStarred, printedPageLabel);
|
||||
@@ -2791,7 +2819,8 @@ void EpubReaderActivity::openReaderMenu() {
|
||||
renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent, SETTINGS.orientation,
|
||||
!currentPageFootnotes.empty(), bookEmbeddedStyleOverride, bookImageRenderingOverride, bookFontFamilyOverride,
|
||||
bookSdFontFamilyOverride, bookFontSizeOverride, SETTINGS.textDarkness, getEffectiveBionicReading(),
|
||||
bookParagraphAlignmentOverride, !bookmarkStore.isEmpty(), isCurrentPageStarred, hasPrintedPages),
|
||||
bookParagraphAlignmentOverride, bookTextAntiAliasingOverride, bookHyphenationOverride,
|
||||
!bookmarkStore.isEmpty(), isCurrentPageStarred, hasPrintedPages),
|
||||
[this](const ActivityResult& result) {
|
||||
const auto& menu = std::get<MenuResult>(result.data);
|
||||
applyOrientation(menu.orientation);
|
||||
@@ -2799,7 +2828,8 @@ void EpubReaderActivity::openReaderMenu() {
|
||||
toggleAutoPageTurn(menu.pageTurnOption);
|
||||
applyBookReaderOverrides(menu.embeddedStyleOverride, menu.imageRenderingOverride, menu.fontFamilyOverride,
|
||||
menu.sdFontFamilyOverride, menu.fontSizeOverride,
|
||||
static_cast<bool>(menu.bionicReadingOverride), menu.paragraphAlignmentOverride);
|
||||
static_cast<bool>(menu.bionicReadingOverride), menu.paragraphAlignmentOverride,
|
||||
menu.textAntiAliasingOverride, menu.hyphenationOverride);
|
||||
if (!result.isCancelled) {
|
||||
onReaderMenuConfirm(static_cast<EpubReaderMenuActivity::MenuAction>(menu.action));
|
||||
}
|
||||
|
||||
@@ -11,6 +11,24 @@
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
// Three-state cycle helper for overrides represented as int8_t with -1 = default.
|
||||
// Mirrors the helpers in QuickOverridesActivity.cpp; kept duplicated rather
|
||||
// than extracted so each activity stays self-contained for 16 lines of code.
|
||||
// slot 0 -> -1 (default)
|
||||
// slot 1 -> 1 (on)
|
||||
// slot 2 -> 0 (off)
|
||||
uint8_t threeStateSlotFromOverride(int8_t value) {
|
||||
if (value < 0) return 0;
|
||||
if (value > 0) return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
int8_t threeStateOverrideFromSlot(uint8_t slot) {
|
||||
if (slot == 0) return -1;
|
||||
if (slot == 1) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Returns the localized name of the family currently used as the global default
|
||||
// for the reader. When the user has selected an SD card font globally, the
|
||||
// override menu's "Default" label should reflect that family by name even
|
||||
@@ -40,7 +58,8 @@ EpubReaderMenuActivity::EpubReaderMenuActivity(
|
||||
const int8_t initialEmbeddedStyleOverride, const int8_t initialImageRenderingOverride,
|
||||
const int8_t initialFontFamilyOverride, const std::string& initialSdFontFamilyOverride,
|
||||
const int8_t initialFontSizeOverride, const uint8_t initialTextDarkness, const bool initialBionicReadingOverride,
|
||||
const int8_t initialParagraphAlignmentOverride, const bool hasStarredPages, const bool isCurrentPageStarred,
|
||||
const int8_t initialParagraphAlignmentOverride, const int8_t initialTextAntiAliasingOverride,
|
||||
const int8_t initialHyphenationOverride, const bool hasStarredPages, const bool isCurrentPageStarred,
|
||||
const bool hasPrintedPages)
|
||||
: MenuListActivity("EpubReaderMenu", renderer, mappedInput),
|
||||
currentPageStarred(isCurrentPageStarred),
|
||||
@@ -53,6 +72,8 @@ EpubReaderMenuActivity::EpubReaderMenuActivity(
|
||||
pendingTextDarkness(initialTextDarkness),
|
||||
pendingBionicReading(initialBionicReadingOverride),
|
||||
pendingParagraphAlignmentOverride(initialParagraphAlignmentOverride),
|
||||
pendingTextAntiAliasingOverride(initialTextAntiAliasingOverride),
|
||||
pendingHyphenationOverride(initialHyphenationOverride),
|
||||
title(title),
|
||||
currentPage(currentPage),
|
||||
totalPages(totalPages),
|
||||
@@ -242,6 +263,32 @@ void EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes, bool hasStarredPa
|
||||
})
|
||||
.withSubmenu(StrId::STR_READER_OVERRIDES));
|
||||
|
||||
// Text anti-aliasing: default / on / off (mirrors QuickOverrides)
|
||||
menuItems.push_back(
|
||||
SettingInfo::DynamicEnumCtx(
|
||||
StrId::STR_TEXT_AA, {StrId::STR_DEFAULT_VALUE, StrId::STR_STATE_ON, StrId::STR_STATE_OFF}, self,
|
||||
[](const void* ctx) -> uint8_t {
|
||||
return threeStateSlotFromOverride(
|
||||
static_cast<const EpubReaderMenuActivity*>(ctx)->pendingTextAntiAliasingOverride);
|
||||
},
|
||||
[](void* ctx, uint8_t v) {
|
||||
static_cast<EpubReaderMenuActivity*>(ctx)->pendingTextAntiAliasingOverride = threeStateOverrideFromSlot(v);
|
||||
})
|
||||
.withSubmenu(StrId::STR_READER_OVERRIDES));
|
||||
|
||||
// Hyphenation: default / on / off (mirrors QuickOverrides)
|
||||
menuItems.push_back(
|
||||
SettingInfo::DynamicEnumCtx(
|
||||
StrId::STR_HYPHENATION, {StrId::STR_DEFAULT_VALUE, StrId::STR_STATE_ON, StrId::STR_STATE_OFF}, self,
|
||||
[](const void* ctx) -> uint8_t {
|
||||
return threeStateSlotFromOverride(
|
||||
static_cast<const EpubReaderMenuActivity*>(ctx)->pendingHyphenationOverride);
|
||||
},
|
||||
[](void* ctx, uint8_t v) {
|
||||
static_cast<EpubReaderMenuActivity*>(ctx)->pendingHyphenationOverride = threeStateOverrideFromSlot(v);
|
||||
})
|
||||
.withSubmenu(StrId::STR_READER_OVERRIDES));
|
||||
|
||||
// Helper functions, reading ruler, auto page turn, orientation
|
||||
menuItems.push_back(SettingInfo::Separator(StrId::STR_READER_UTILS));
|
||||
// Auto page turn: ACTION type with custom cycling in onActionSelected
|
||||
@@ -336,7 +383,8 @@ void EpubReaderMenuActivity::finishWithAction(MenuAction action) {
|
||||
setResult(MenuResult{static_cast<int>(action), -1, pendingOrientation, selectedPageTurnOption,
|
||||
pendingEmbeddedStyleOverride, pendingImageRenderingOverride, pendingFontFamilyOverride,
|
||||
pendingSdFontFamilyOverride, pendingFontSizeOverride, pendingTextDarkness,
|
||||
static_cast<uint8_t>(pendingBionicReading), pendingParagraphAlignmentOverride});
|
||||
static_cast<uint8_t>(pendingBionicReading), pendingParagraphAlignmentOverride,
|
||||
pendingTextAntiAliasingOverride, pendingHyphenationOverride});
|
||||
finish();
|
||||
}
|
||||
|
||||
@@ -372,7 +420,9 @@ void EpubReaderMenuActivity::onBackPressed() {
|
||||
pendingFontSizeOverride,
|
||||
pendingTextDarkness,
|
||||
static_cast<uint8_t>(pendingBionicReading),
|
||||
pendingParagraphAlignmentOverride};
|
||||
pendingParagraphAlignmentOverride,
|
||||
pendingTextAntiAliasingOverride,
|
||||
pendingHyphenationOverride};
|
||||
setResult(std::move(result));
|
||||
finish();
|
||||
}
|
||||
|
||||
@@ -42,8 +42,10 @@ class EpubReaderMenuActivity final : public MenuListActivity {
|
||||
const int8_t initialFontFamilyOverride,
|
||||
const std::string& initialSdFontFamilyOverride, const int8_t initialFontSizeOverride,
|
||||
const uint8_t initialTextDarkness, const bool initialBionicReadingOverride,
|
||||
const int8_t initialParagraphAlignmentOverride, const bool hasStarredPages,
|
||||
const bool isCurrentPageStarred, const bool hasPrintedPages);
|
||||
const int8_t initialParagraphAlignmentOverride,
|
||||
const int8_t initialTextAntiAliasingOverride, const int8_t initialHyphenationOverride,
|
||||
const bool hasStarredPages, const bool isCurrentPageStarred,
|
||||
const bool hasPrintedPages);
|
||||
|
||||
void onEnter() override;
|
||||
void render(RenderLock&&) override;
|
||||
@@ -77,6 +79,8 @@ class EpubReaderMenuActivity final : public MenuListActivity {
|
||||
uint8_t pendingTextDarkness = 1;
|
||||
bool pendingBionicReading = false;
|
||||
int8_t pendingParagraphAlignmentOverride = -1;
|
||||
int8_t pendingTextAntiAliasingOverride = -1;
|
||||
int8_t pendingHyphenationOverride = -1;
|
||||
|
||||
static constexpr const char* pageTurnLabels[] = {"", "1", "3", "6", "12"};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user