Merge branch 'feat-page-overlay' of https://github.com/jpirnay/crosspoint-reader into mybuild

This commit is contained in:
jpirnay
2026-03-08 09:55:09 +01:00
11 changed files with 731 additions and 100 deletions
@@ -8,6 +8,8 @@
#include <I18n.h>
#include <Logging.h>
#include <memory>
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "EpubReaderChapterSelectionActivity.h"
@@ -850,3 +852,73 @@ void EpubReaderActivity::restoreSavedPosition() {
}
requestUpdate();
}
bool EpubReaderActivity::drawCurrentPageToBuffer(const std::string& filePath, GfxRenderer& renderer) {
auto epub = std::make_shared<Epub>(filePath, "/.crosspoint");
// Load CSS when embeddedStyle is enabled, as createSectionFile may need it to rebuild the cache.
if (!epub->load(true, SETTINGS.embeddedStyle == 0)) {
LOG_DBG("SLP", "EPUB: failed to load %s", filePath.c_str());
return false;
}
epub->setupCacheDir();
// Load saved spine index and page number
int spineIndex = 0, pageNumber = 0;
FsFile f;
if (Storage.openFileForRead("SLP", epub->getCachePath() + "/progress.bin", f)) {
uint8_t data[6];
if (f.read(data, 6) == 6) {
spineIndex = (int)((uint32_t)data[0] | ((uint32_t)data[1] << 8));
pageNumber = (int)((uint32_t)data[2] | ((uint32_t)data[3] << 8));
}
f.close();
}
if (spineIndex < 0 || spineIndex >= epub->getSpineItemsCount()) spineIndex = 0;
// Apply the reader orientation so margins match what the reader would produce
applyReaderOrientation(renderer, SETTINGS.orientation);
// Compute margins exactly as render() does
int marginTop, marginRight, marginBottom, marginLeft;
renderer.getOrientedViewableTRBL(&marginTop, &marginRight, &marginBottom, &marginLeft);
marginTop += SETTINGS.screenMargin;
marginLeft += SETTINGS.screenMargin;
marginRight += SETTINGS.screenMargin;
const uint8_t statusBarHeight = UITheme::getInstance().getStatusBarHeight();
marginBottom += std::max(SETTINGS.screenMargin, statusBarHeight);
const uint16_t viewportWidth = renderer.getScreenWidth() - marginLeft - marginRight;
const uint16_t viewportHeight = renderer.getScreenHeight() - marginTop - marginBottom;
// Load or rebuild the section cache. Rebuilding is needed when the cache is missing or stale
// (e.g. after a firmware update). A no-op popup callback avoids any UI during sleep preparation.
auto section = std::make_unique<Section>(epub, spineIndex, renderer);
if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
SETTINGS.imageRendering)) {
LOG_DBG("SLP", "EPUB: section cache not found for spine %d, rebuilding", spineIndex);
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
SETTINGS.imageRendering, []() {})) {
LOG_ERR("SLP", "EPUB: failed to rebuild section cache for spine %d", spineIndex);
return false;
}
}
if (pageNumber < 0 || pageNumber >= section->pageCount) pageNumber = 0;
section->currentPage = pageNumber;
auto page = section->loadPageFromSectionFile();
if (!page) {
LOG_DBG("SLP", "EPUB: failed to load page %d", pageNumber);
return false;
}
renderer.clearScreen();
page->render(renderer, SETTINGS.getReaderFontId(), marginLeft, marginTop);
// No displayBuffer call — caller (SleepActivity) handles that after compositing the overlay
return true;
}
@@ -61,4 +61,9 @@ class EpubReaderActivity final : public Activity {
void loop() override;
void render(RenderLock&& lock) override;
bool isReaderActivity() const override { return true; }
// Renders the last saved page to the frame buffer without flushing to display.
// Used by SleepActivity to prepare the background for the overlay sleep mode.
// Returns false if the page cannot be loaded (missing cache / file error).
static bool drawCurrentPageToBuffer(const std::string& filePath, GfxRenderer& renderer);
};
+242 -98
View File
@@ -20,6 +20,65 @@ constexpr size_t CHUNK_SIZE = 8 * 1024; // 8KB chunk for reading
// Cache file magic and version
constexpr uint32_t CACHE_MAGIC = 0x54585449; // "TXTI"
constexpr uint8_t CACHE_VERSION = 2; // Increment when cache format changes
constexpr uint32_t MAX_CACHE_PAGES = 65535; // Sanity cap to prevent unbounded reserve()
// Parses and word-wraps lines from a file chunk into outLines.
// Returns the number of bytes consumed from the start of buffer.
size_t parseAndWrapLines(const uint8_t* buffer, size_t chunkSize, size_t fileOffset, size_t fileSize, int linesPerPage,
GfxRenderer& renderer, int fontId, int vw, std::vector<std::string>& outLines) {
size_t pos = 0;
while (pos < chunkSize && static_cast<int>(outLines.size()) < linesPerPage) {
size_t lineEnd = pos;
while (lineEnd < chunkSize && buffer[lineEnd] != '\n') lineEnd++;
bool lineComplete = (lineEnd < chunkSize) || (fileOffset + lineEnd >= fileSize);
if (!lineComplete && !outLines.empty()) break;
size_t lineContentLen = lineEnd - pos;
bool hasCR = (lineContentLen > 0 && buffer[pos + lineContentLen - 1] == '\r');
size_t displayLen = hasCR ? lineContentLen - 1 : lineContentLen;
std::string line(reinterpret_cast<const char*>(buffer + pos), displayLen);
size_t lineBytePos = 0;
while (!line.empty() && static_cast<int>(outLines.size()) < linesPerPage) {
if (renderer.getTextWidth(fontId, line.c_str()) <= vw) {
outLines.push_back(line);
lineBytePos = displayLen;
line.clear();
break;
}
size_t breakPos = line.length();
while (breakPos > 0 && renderer.getTextWidth(fontId, line.substr(0, breakPos).c_str()) > vw) {
size_t spacePos = line.rfind(' ', breakPos - 1);
if (spacePos != std::string::npos && spacePos > 0) {
breakPos = spacePos;
} else {
breakPos--;
while (breakPos > 0 && (line[breakPos] & 0xC0) == 0x80) breakPos--;
}
}
if (breakPos == 0) {
breakPos = 1;
while (breakPos < line.length() && (line[breakPos] & 0xC0) == 0x80) breakPos++;
}
outLines.push_back(line.substr(0, breakPos));
size_t skipChars = breakPos;
if (breakPos < line.length() && line[breakPos] == ' ') skipChars++;
lineBytePos += skipChars;
line = line.substr(skipChars);
}
if (line.empty()) {
pos = lineEnd + 1;
} else {
pos = pos + lineBytePos;
break;
}
}
if (pos == 0 && !outLines.empty()) {
pos = 1;
}
return pos;
}
} // namespace
void TxtReaderActivity::onEnter() {
@@ -216,101 +275,9 @@ bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector<std::string>
}
buffer[chunkSize] = '\0';
// Parse lines from buffer
size_t pos = 0;
while (pos < chunkSize && static_cast<int>(outLines.size()) < linesPerPage) {
// Find end of line
size_t lineEnd = pos;
while (lineEnd < chunkSize && buffer[lineEnd] != '\n') {
lineEnd++;
}
// Check if we have a complete line
bool lineComplete = (lineEnd < chunkSize) || (offset + lineEnd >= fileSize);
if (!lineComplete && static_cast<int>(outLines.size()) > 0) {
// Incomplete line and we already have some lines, stop here
break;
}
// Calculate the actual length of line content in the buffer (excluding newline)
size_t lineContentLen = lineEnd - pos;
// Check for carriage return
bool hasCR = (lineContentLen > 0 && buffer[pos + lineContentLen - 1] == '\r');
size_t displayLen = hasCR ? lineContentLen - 1 : lineContentLen;
// Extract line content for display (without CR/LF)
std::string line(reinterpret_cast<char*>(buffer + pos), displayLen);
// Track position within this source line (in bytes from pos)
size_t lineBytePos = 0;
// Word wrap if needed
while (!line.empty() && static_cast<int>(outLines.size()) < linesPerPage) {
int lineWidth = renderer.getTextWidth(cachedFontId, line.c_str());
if (lineWidth <= viewportWidth) {
outLines.push_back(line);
lineBytePos = displayLen; // Consumed entire display content
line.clear();
break;
}
// Find break point
size_t breakPos = line.length();
while (breakPos > 0 && renderer.getTextWidth(cachedFontId, line.substr(0, breakPos).c_str()) > viewportWidth) {
// Try to break at space
size_t spacePos = line.rfind(' ', breakPos - 1);
if (spacePos != std::string::npos && spacePos > 0) {
breakPos = spacePos;
} else {
// Break at character boundary for UTF-8
breakPos--;
// Make sure we don't break in the middle of a UTF-8 sequence
while (breakPos > 0 && (line[breakPos] & 0xC0) == 0x80) {
breakPos--;
}
}
}
if (breakPos == 0) {
breakPos = 1;
}
outLines.push_back(line.substr(0, breakPos));
// Skip space at break point
size_t skipChars = breakPos;
if (breakPos < line.length() && line[breakPos] == ' ') {
skipChars++;
}
lineBytePos += skipChars;
line = line.substr(skipChars);
}
// Determine how much of the source buffer we consumed
if (line.empty()) {
// Fully consumed this source line, move past the newline
pos = lineEnd + 1;
} else {
// Partially consumed - page is full mid-line
// Move pos to where we stopped in the line (NOT past the line)
pos = pos + lineBytePos;
break;
}
}
// Ensure we make progress even if calculations go wrong
if (pos == 0 && !outLines.empty()) {
// Fallback: at minimum, consume something to avoid infinite loop
pos = 1;
}
size_t pos = parseAndWrapLines(buffer, chunkSize, offset, fileSize, linesPerPage, renderer, cachedFontId,
viewportWidth, outLines);
nextOffset = offset + pos;
// Make sure we don't go past the file
if (nextOffset > fileSize) {
nextOffset = fileSize;
}
@@ -441,12 +408,17 @@ void TxtReaderActivity::renderStatusBar() const {
void TxtReaderActivity::saveProgress() const {
FsFile f;
if (Storage.openFileForWrite("TRS", txt->getCachePath() + "/progress.bin", f)) {
uint8_t data[4];
// 6-byte format: page(2 bytes LE) + file offset(4 bytes LE)
// The offset lets drawCurrentPageToBuffer render without requiring index.bin.
const size_t offset = (currentPage < static_cast<int>(pageOffsets.size())) ? pageOffsets[currentPage] : 0;
uint8_t data[6];
data[0] = currentPage & 0xFF;
data[1] = (currentPage >> 8) & 0xFF;
data[2] = 0;
data[3] = 0;
f.write(data, 4);
data[2] = offset & 0xFF;
data[3] = (offset >> 8) & 0xFF;
data[4] = (offset >> 16) & 0xFF;
data[5] = (offset >> 24) & 0xFF;
f.write(data, 6);
f.close();
}
}
@@ -556,6 +528,11 @@ bool TxtReaderActivity::loadPageIndexCache() {
uint32_t numPages;
serialization::readPod(f, numPages);
if (numPages > MAX_CACHE_PAGES) {
LOG_ERR("TRS", "Cache numPages %u exceeds cap %u, cache invalid", numPages, MAX_CACHE_PAGES);
f.close();
return false;
}
// Read page offsets
pageOffsets.clear();
@@ -600,3 +577,170 @@ void TxtReaderActivity::savePageIndexCache() const {
f.close();
LOG_DBG("TRS", "Saved page index cache: %d pages", totalPages);
}
bool TxtReaderActivity::drawCurrentPageToBuffer(const std::string& filePath, GfxRenderer& renderer) {
Txt txt(filePath, "/.crosspoint");
if (!txt.load()) {
LOG_DBG("SLP", "TXT: failed to load %s", filePath.c_str());
return false;
}
// Apply the reader orientation so margins match what the reader would produce
switch (SETTINGS.orientation) {
case CrossPointSettings::ORIENTATION::PORTRAIT:
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
break;
case CrossPointSettings::ORIENTATION::LANDSCAPE_CW:
renderer.setOrientation(GfxRenderer::Orientation::LandscapeClockwise);
break;
case CrossPointSettings::ORIENTATION::INVERTED:
renderer.setOrientation(GfxRenderer::Orientation::PortraitInverted);
break;
case CrossPointSettings::ORIENTATION::LANDSCAPE_CCW:
renderer.setOrientation(GfxRenderer::Orientation::LandscapeCounterClockwise);
break;
default:
break;
}
// Compute layout values that match what initializeReader() produces
const int fontId = SETTINGS.getReaderFontId();
const uint8_t screenMargin = SETTINGS.screenMargin;
const uint8_t paragraphAlignment = SETTINGS.paragraphAlignment;
int marginTop, marginRight, marginBottom, marginLeft;
renderer.getOrientedViewableTRBL(&marginTop, &marginRight, &marginBottom, &marginLeft);
marginTop += screenMargin;
marginLeft += screenMargin;
marginRight += screenMargin;
marginBottom += std::max(screenMargin, static_cast<uint8_t>(UITheme::getInstance().getStatusBarHeight()));
const int vw = renderer.getScreenWidth() - marginLeft - marginRight;
const int vh = renderer.getScreenHeight() - marginTop - marginBottom;
const int lineHeight = renderer.getLineHeight(fontId);
const int linesPerPage = std::max(1, vh / lineHeight);
// Step 1: Try to read the saved page and its file offset from progress.bin.
// The 6-byte format (written by saveProgress) stores: page(2) + offset(4).
// This lets us skip index.bin entirely, so the overlay works even when the
// page index cache is missing or stale (e.g. after a firmware update).
int savedPage = 0;
size_t savedOffset = 0;
bool offsetKnown = false;
{
FsFile progFile;
if (Storage.openFileForRead("SLP", txt.getCachePath() + "/progress.bin", progFile)) {
uint8_t data[6] = {0};
const int n = progFile.read(data, 6);
progFile.close();
if (n >= 2) {
savedPage = (int)((uint32_t)data[0] | ((uint32_t)data[1] << 8));
}
if (n >= 6) {
const uint32_t off =
(uint32_t)data[2] | ((uint32_t)data[3] << 8) | ((uint32_t)data[4] << 16) | ((uint32_t)data[5] << 24);
if (off < txt.getFileSize()) {
savedOffset = off;
offsetKnown = true;
}
}
}
}
// Step 2: If progress.bin didn't provide the offset, fall back to index.bin.
if (!offsetKnown) {
std::string cachePath = txt.getCachePath() + "/index.bin";
FsFile cacheFile;
if (Storage.openFileForRead("SLP", cachePath, cacheFile)) {
uint32_t magic;
serialization::readPod(cacheFile, magic);
uint8_t version;
serialization::readPod(cacheFile, version);
uint32_t cachedFileSize;
serialization::readPod(cacheFile, cachedFileSize);
int32_t cachedVw, cachedLpp, cachedFontId, cachedMargin;
serialization::readPod(cacheFile, cachedVw);
serialization::readPod(cacheFile, cachedLpp);
serialization::readPod(cacheFile, cachedFontId);
serialization::readPod(cacheFile, cachedMargin);
uint8_t cachedAlignment;
serialization::readPod(cacheFile, cachedAlignment);
uint32_t numPages;
serialization::readPod(cacheFile, numPages);
if (magic == CACHE_MAGIC && version == CACHE_VERSION && cachedFileSize == txt.getFileSize() && cachedVw == vw &&
cachedLpp == linesPerPage && cachedFontId == fontId && cachedMargin == screenMargin &&
cachedAlignment == paragraphAlignment && numPages > 0 && numPages <= MAX_CACHE_PAGES) {
if (savedPage < 0 || savedPage >= static_cast<int>(numPages)) savedPage = 0;
for (uint32_t i = 0; i < numPages; i++) {
uint32_t off;
serialization::readPod(cacheFile, off);
if (static_cast<int>(i) == savedPage) {
if (off < txt.getFileSize()) {
savedOffset = off;
offsetKnown = true;
} else {
LOG_DBG("SLP", "TXT: index.bin offset %u out of range (fileSize=%u), ignoring", off, txt.getFileSize());
}
}
}
} else {
LOG_DBG("SLP", "TXT: index cache invalid or stale");
}
cacheFile.close();
}
// Step 3: No valid cache at all — render from the start of the file as a last resort.
// This shows page 1 rather than a blank screen, which is always preferable.
if (!offsetKnown) {
LOG_DBG("SLP", "TXT: no valid cache, falling back to start of file");
savedOffset = 0;
}
}
// Load the page lines from file
std::vector<std::string> pageLines;
const size_t fileSize = txt.getFileSize();
size_t offset = savedOffset;
if (offset >= fileSize) {
LOG_DBG("SLP", "TXT: page offset out of bounds");
return false;
}
size_t chunkSize = std::min(CHUNK_SIZE, fileSize - offset);
auto* buffer = static_cast<uint8_t*>(malloc(chunkSize + 1));
if (!buffer) return false;
if (!txt.readContent(buffer, offset, chunkSize)) {
free(buffer);
return false;
}
buffer[chunkSize] = '\0';
parseAndWrapLines(buffer, chunkSize, offset, fileSize, linesPerPage, renderer, fontId, vw, pageLines);
free(buffer);
if (pageLines.empty()) return false;
// Render lines to frame buffer (no displayBuffer call)
renderer.clearScreen();
int y = marginTop;
for (const auto& line : pageLines) {
if (!line.empty()) {
int x = marginLeft;
switch (paragraphAlignment) {
case CrossPointSettings::CENTER_ALIGN:
x = marginLeft + (vw - renderer.getTextWidth(fontId, line.c_str())) / 2;
break;
case CrossPointSettings::RIGHT_ALIGN:
x = marginLeft + vw - renderer.getTextWidth(fontId, line.c_str());
break;
default:
break;
}
renderer.drawText(fontId, x, y, line.c_str());
}
y += lineHeight;
}
return true;
}
@@ -49,4 +49,9 @@ class TxtReaderActivity final : public Activity {
void loop() override;
void render(RenderLock&&) override;
bool isReaderActivity() const override { return true; }
// Renders the last saved page to the frame buffer without flushing to display.
// Used by SleepActivity to prepare the background for the overlay sleep mode.
// Returns false if the page cannot be loaded (missing cache / file error).
static bool drawCurrentPageToBuffer(const std::string& filePath, GfxRenderer& renderer);
};
@@ -351,3 +351,78 @@ void XtcReaderActivity::loadProgress() {
f.close();
}
}
bool XtcReaderActivity::drawCurrentPageToBuffer(const std::string& filePath, GfxRenderer& renderer) {
Xtc xtc(filePath, "/.crosspoint");
if (!xtc.load()) {
LOG_DBG("SLP", "XTC: failed to load %s", filePath.c_str());
return false;
}
// Load saved page number
uint32_t savedPage = 0;
FsFile f;
if (Storage.openFileForRead("SLP", xtc.getCachePath() + "/progress.bin", f)) {
uint8_t data[4];
if (f.read(data, 4) == 4) {
savedPage = (uint32_t)data[0] | ((uint32_t)data[1] << 8) | ((uint32_t)data[2] << 16) | ((uint32_t)data[3] << 24);
}
f.close();
}
if (savedPage >= xtc.getPageCount()) savedPage = 0;
const uint16_t pageWidth = xtc.getPageWidth();
const uint16_t pageHeight = xtc.getPageHeight();
const uint8_t bitDepth = xtc.getBitDepth();
// Only use the 1-bit BW path; grayscale is not needed as a background under the overlay
const size_t pageBufferSize = (bitDepth == 2) ? ((static_cast<size_t>(pageWidth) * pageHeight + 7) / 8) * 2
: ((pageWidth + 7) / 8) * pageHeight;
uint8_t* pageBuffer = static_cast<uint8_t*>(malloc(pageBufferSize));
if (!pageBuffer) {
LOG_ERR("SLP", "XTC: failed to allocate page buffer");
return false;
}
if (xtc.loadPage(savedPage, pageBuffer, pageBufferSize) == 0) {
LOG_ERR("SLP", "XTC: failed to load page %lu", savedPage);
free(pageBuffer);
return false;
}
renderer.clearScreen();
if (bitDepth == 2) {
// 2-bit XTH: draw all non-white pixels as black (BW pass only)
const size_t planeSize = (static_cast<size_t>(pageWidth) * pageHeight + 7) / 8;
const uint8_t* plane1 = pageBuffer;
const uint8_t* plane2 = pageBuffer + planeSize;
const size_t colBytes = (pageHeight + 7) / 8;
for (uint16_t y = 0; y < pageHeight; y++) {
for (uint16_t x = 0; x < pageWidth; x++) {
const size_t colIndex = pageWidth - 1 - x;
const size_t byteInCol = y / 8;
const size_t bitInByte = 7 - (y % 8);
const size_t byteOffset = colIndex * colBytes + byteInCol;
const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1;
const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1;
if ((bit1 << 1) | bit2) {
renderer.drawPixel(x, y, true);
}
}
}
} else {
// 1-bit XTG: draw black pixels
const size_t srcRowBytes = (pageWidth + 7) / 8;
for (uint16_t srcY = 0; srcY < pageHeight; srcY++) {
for (uint16_t srcX = 0; srcX < pageWidth; srcX++) {
const bool isBlack = !((pageBuffer[srcY * srcRowBytes + srcX / 8] >> (7 - srcX % 8)) & 1);
if (isBlack) renderer.drawPixel(srcX, srcY, true);
}
}
}
free(pageBuffer);
return true;
}
@@ -29,4 +29,9 @@ class XtcReaderActivity final : public Activity {
void loop() override;
void render(RenderLock&&) override;
bool isReaderActivity() const override { return true; }
// Renders the last saved page to the frame buffer without flushing to display.
// Used by SleepActivity to prepare the background for the overlay sleep mode.
// Returns false if the page cannot be loaded (missing cache / file error).
static bool drawCurrentPageToBuffer(const std::string& filePath, GfxRenderer& renderer);
};