Compare commits
10 Commits
c1dfe92ea3
...
mod/master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b965ce9fb7
|
||
|
|
744d6160e8
|
||
|
|
66f703df69
|
||
|
|
19004eefaa
|
||
|
|
f90aebc891
|
||
|
|
3096d6066b
|
||
|
|
46c2109f1f | ||
|
|
1383d75c84
|
||
|
|
632b76c9ed
|
||
|
|
5dc9d21bdb
|
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#ifndef OMIT_BOOKERLY
|
||||
#include <builtinFonts/bookerly_12_bold.h>
|
||||
#include <builtinFonts/bookerly_12_bolditalic.h>
|
||||
#include <builtinFonts/bookerly_12_italic.h>
|
||||
@@ -16,7 +17,10 @@
|
||||
#include <builtinFonts/bookerly_18_bolditalic.h>
|
||||
#include <builtinFonts/bookerly_18_italic.h>
|
||||
#include <builtinFonts/bookerly_18_regular.h>
|
||||
#endif // OMIT_BOOKERLY
|
||||
|
||||
#include <builtinFonts/notosans_8_regular.h>
|
||||
#ifndef OMIT_NOTOSANS
|
||||
#include <builtinFonts/notosans_12_bold.h>
|
||||
#include <builtinFonts/notosans_12_bolditalic.h>
|
||||
#include <builtinFonts/notosans_12_italic.h>
|
||||
@@ -33,6 +37,9 @@
|
||||
#include <builtinFonts/notosans_18_bolditalic.h>
|
||||
#include <builtinFonts/notosans_18_italic.h>
|
||||
#include <builtinFonts/notosans_18_regular.h>
|
||||
#endif // OMIT_NOTOSANS
|
||||
|
||||
#ifndef OMIT_OPENDYSLEXIC
|
||||
#include <builtinFonts/opendyslexic_10_bold.h>
|
||||
#include <builtinFonts/opendyslexic_10_bolditalic.h>
|
||||
#include <builtinFonts/opendyslexic_10_italic.h>
|
||||
@@ -49,6 +56,8 @@
|
||||
#include <builtinFonts/opendyslexic_8_bolditalic.h>
|
||||
#include <builtinFonts/opendyslexic_8_italic.h>
|
||||
#include <builtinFonts/opendyslexic_8_regular.h>
|
||||
#endif // OMIT_OPENDYSLEXIC
|
||||
|
||||
#include <builtinFonts/ubuntu_10_bold.h>
|
||||
#include <builtinFonts/ubuntu_10_regular.h>
|
||||
#include <builtinFonts/ubuntu_12_bold.h>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
#include "Epub.h"
|
||||
|
||||
#include <FsHelpers.h>
|
||||
#include <HalDisplay.h>
|
||||
#include <HalStorage.h>
|
||||
#include <JpegToBmpConverter.h>
|
||||
#include <Logging.h>
|
||||
#include <ZipFile.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "Epub/parsers/ContainerParser.h"
|
||||
#include "Epub/parsers/ContentOpfParser.h"
|
||||
#include "Epub/parsers/TocNavParser.h"
|
||||
@@ -208,30 +211,14 @@ bool Epub::parseTocNavFile() const {
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string Epub::getCssRulesCache() const { return cachePath + "/css_rules.cache"; }
|
||||
|
||||
bool Epub::loadCssRulesFromCache() const {
|
||||
FsFile cssCacheFile;
|
||||
if (Storage.openFileForRead("EBP", getCssRulesCache(), cssCacheFile)) {
|
||||
if (cssParser->loadFromCache(cssCacheFile)) {
|
||||
cssCacheFile.close();
|
||||
LOG_DBG("EBP", "Loaded CSS rules from cache");
|
||||
return true;
|
||||
}
|
||||
cssCacheFile.close();
|
||||
LOG_DBG("EBP", "CSS cache invalid, reparsing");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void Epub::parseCssFiles() const {
|
||||
if (cssFiles.empty()) {
|
||||
LOG_DBG("EBP", "No CSS files to parse, but CssParser created for inline styles");
|
||||
}
|
||||
|
||||
// Try to load from CSS cache first
|
||||
if (!loadCssRulesFromCache()) {
|
||||
// Cache miss - parse CSS files
|
||||
// See if we have a cached version of the CSS rules
|
||||
if (!cssParser->hasCache()) {
|
||||
// No cache yet - parse CSS files
|
||||
for (const auto& cssPath : cssFiles) {
|
||||
LOG_DBG("EBP", "Parsing CSS file: %s", cssPath.c_str());
|
||||
|
||||
@@ -262,11 +249,10 @@ void Epub::parseCssFiles() const {
|
||||
}
|
||||
|
||||
// Save to cache for next time
|
||||
FsFile cssCacheFile;
|
||||
if (Storage.openFileForWrite("EBP", getCssRulesCache(), cssCacheFile)) {
|
||||
cssParser->saveToCache(cssCacheFile);
|
||||
cssCacheFile.close();
|
||||
if (!cssParser->saveToCache()) {
|
||||
LOG_ERR("EBP", "Failed to save CSS rules to cache");
|
||||
}
|
||||
cssParser->clear();
|
||||
|
||||
LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size());
|
||||
}
|
||||
@@ -279,11 +265,11 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
|
||||
// Initialize spine/TOC cache
|
||||
bookMetadataCache.reset(new BookMetadataCache(cachePath));
|
||||
// Always create CssParser - needed for inline style parsing even without CSS files
|
||||
cssParser.reset(new CssParser());
|
||||
cssParser.reset(new CssParser(cachePath));
|
||||
|
||||
// Try to load existing cache first
|
||||
if (bookMetadataCache->load()) {
|
||||
if (!skipLoadingCss && !loadCssRulesFromCache()) {
|
||||
if (!skipLoadingCss && !cssParser->hasCache()) {
|
||||
LOG_DBG("EBP", "Warning: CSS rules cache not found, attempting to parse CSS files");
|
||||
// to get CSS file list
|
||||
if (!parseContentOpf(bookMetadataCache->coreMetadata)) {
|
||||
@@ -457,9 +443,18 @@ std::string Epub::getCoverBmpPath(bool cropped) const {
|
||||
}
|
||||
|
||||
bool Epub::generateCoverBmp(bool cropped) const {
|
||||
bool invalid = false;
|
||||
// Already generated, return true
|
||||
if (Storage.exists(getCoverBmpPath(cropped).c_str())) {
|
||||
return true;
|
||||
// is this a valid cover or just an empty file we created to mark generation attempts?
|
||||
invalid = !isValidThumbnailBmp(getCoverBmpPath(cropped));
|
||||
if (invalid) {
|
||||
// Remove the old invalid cover so we can attempt to generate a new one
|
||||
Storage.remove(getCoverBmpPath(cropped).c_str());
|
||||
LOG_DBG("EBP", "Previous cover generation attempt failed for %s mode, retrying", cropped ? "cropped" : "fit");
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
||||
@@ -468,13 +463,33 @@ bool Epub::generateCoverBmp(bool cropped) const {
|
||||
}
|
||||
|
||||
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
|
||||
std::string effectiveCoverImageHref = coverImageHref;
|
||||
if (coverImageHref.empty()) {
|
||||
// Fallback: try common cover filenames
|
||||
std::vector<std::string> coverCandidates = getCoverCandidates();
|
||||
for (const auto& candidate : coverCandidates) {
|
||||
effectiveCoverImageHref = candidate;
|
||||
// Try to read a small amount to check if exists
|
||||
uint8_t* test = readItemContentsToBytes(candidate, nullptr, false);
|
||||
if (test) {
|
||||
free(test);
|
||||
break;
|
||||
} else {
|
||||
effectiveCoverImageHref.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (effectiveCoverImageHref.empty()) {
|
||||
LOG_ERR("EBP", "No known cover image");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
|
||||
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
|
||||
// Check for JPG/JPEG extensions (case insensitive)
|
||||
std::string lowerHref = effectiveCoverImageHref;
|
||||
std::transform(lowerHref.begin(), lowerHref.end(), lowerHref.begin(), ::tolower);
|
||||
bool isJpg =
|
||||
lowerHref.substr(lowerHref.length() - 4) == ".jpg" || lowerHref.substr(lowerHref.length() - 5) == ".jpeg";
|
||||
if (isJpg) {
|
||||
LOG_DBG("EBP", "Generating BMP from JPG cover image (%s mode)", cropped ? "cropped" : "fit");
|
||||
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
|
||||
|
||||
@@ -482,7 +497,7 @@ bool Epub::generateCoverBmp(bool cropped) const {
|
||||
if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
|
||||
return false;
|
||||
}
|
||||
readItemContentsToStream(coverImageHref, coverJpg, 1024);
|
||||
readItemContentsToStream(effectiveCoverImageHref, coverJpg, 1024);
|
||||
coverJpg.close();
|
||||
|
||||
if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
|
||||
@@ -516,9 +531,18 @@ std::string Epub::getThumbBmpPath() const { return cachePath + "/thumb_[HEIGHT].
|
||||
std::string Epub::getThumbBmpPath(int height) const { return cachePath + "/thumb_" + std::to_string(height) + ".bmp"; }
|
||||
|
||||
bool Epub::generateThumbBmp(int height) const {
|
||||
bool invalid = false;
|
||||
// Already generated, return true
|
||||
if (Storage.exists(getThumbBmpPath(height).c_str())) {
|
||||
return true;
|
||||
// is this a valid thumbnail or just an empty file we created to mark generation attempts?
|
||||
invalid = !isValidThumbnailBmp(getThumbBmpPath(height));
|
||||
if (invalid) {
|
||||
// Remove the old invalid thumbnail so we can attempt to generate a new one
|
||||
Storage.remove(getThumbBmpPath(height).c_str());
|
||||
LOG_DBG("EBP", "Previous thumbnail generation attempt failed for height %d, retrying", height);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
||||
@@ -527,56 +551,246 @@ bool Epub::generateThumbBmp(int height) const {
|
||||
}
|
||||
|
||||
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
|
||||
std::string effectiveCoverImageHref = coverImageHref;
|
||||
if (coverImageHref.empty()) {
|
||||
// Fallback: try common cover filenames
|
||||
std::vector<std::string> coverCandidates = getCoverCandidates();
|
||||
for (const auto& candidate : coverCandidates) {
|
||||
effectiveCoverImageHref = candidate;
|
||||
// Try to read a small amount to check if exists
|
||||
uint8_t* test = readItemContentsToBytes(candidate, nullptr, false);
|
||||
if (test) {
|
||||
free(test);
|
||||
break;
|
||||
} else {
|
||||
effectiveCoverImageHref.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (effectiveCoverImageHref.empty()) {
|
||||
LOG_DBG("EBP", "No known cover image for thumbnail");
|
||||
} else if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
|
||||
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
|
||||
LOG_DBG("EBP", "Generating thumb BMP from JPG cover image");
|
||||
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
|
||||
|
||||
FsFile coverJpg;
|
||||
if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
|
||||
return false;
|
||||
}
|
||||
readItemContentsToStream(coverImageHref, coverJpg, 1024);
|
||||
coverJpg.close();
|
||||
|
||||
if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
FsFile thumbBmp;
|
||||
if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
|
||||
coverJpg.close();
|
||||
return false;
|
||||
}
|
||||
// Use smaller target size for Continue Reading card (half of screen: 240x400)
|
||||
// Generate 1-bit BMP for fast home screen rendering (no gray passes needed)
|
||||
int THUMB_TARGET_WIDTH = height * 0.6;
|
||||
int THUMB_TARGET_HEIGHT = height;
|
||||
const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH,
|
||||
THUMB_TARGET_HEIGHT);
|
||||
coverJpg.close();
|
||||
thumbBmp.close();
|
||||
Storage.remove(coverJpgTempPath.c_str());
|
||||
|
||||
if (!success) {
|
||||
LOG_ERR("EBP", "Failed to generate thumb BMP from JPG cover image");
|
||||
Storage.remove(getThumbBmpPath(height).c_str());
|
||||
}
|
||||
LOG_DBG("EBP", "Generated thumb BMP from JPG cover image, success: %s", success ? "yes" : "no");
|
||||
return success;
|
||||
} else {
|
||||
LOG_ERR("EBP", "Cover image is not a supported format, skipping thumbnail");
|
||||
// Check for JPG/JPEG extensions (case insensitive)
|
||||
std::string lowerHref = effectiveCoverImageHref;
|
||||
std::transform(lowerHref.begin(), lowerHref.end(), lowerHref.begin(), ::tolower);
|
||||
bool isJpg =
|
||||
lowerHref.substr(lowerHref.length() - 4) == ".jpg" || lowerHref.substr(lowerHref.length() - 5) == ".jpeg";
|
||||
if (isJpg) {
|
||||
LOG_DBG("EBP", "Generating thumb BMP from JPG cover image");
|
||||
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
|
||||
|
||||
FsFile coverJpg;
|
||||
if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
|
||||
return false;
|
||||
}
|
||||
readItemContentsToStream(effectiveCoverImageHref, coverJpg, 1024);
|
||||
coverJpg.close();
|
||||
|
||||
if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
FsFile thumbBmp;
|
||||
if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
|
||||
coverJpg.close();
|
||||
return false;
|
||||
}
|
||||
// Use smaller target size for Continue Reading card (half of screen: 240x400)
|
||||
// Generate 1-bit BMP for fast home screen rendering (no gray passes needed)
|
||||
int THUMB_TARGET_WIDTH = height * 0.6;
|
||||
int THUMB_TARGET_HEIGHT = height;
|
||||
const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH,
|
||||
THUMB_TARGET_HEIGHT);
|
||||
coverJpg.close();
|
||||
thumbBmp.close();
|
||||
Storage.remove(coverJpgTempPath.c_str());
|
||||
|
||||
if (!success) {
|
||||
LOG_ERR("EBP", "Failed to generate thumb BMP from JPG cover image");
|
||||
Storage.remove(getThumbBmpPath(height).c_str());
|
||||
}
|
||||
LOG_DBG("EBP", "Generated thumb BMP from JPG cover image, success: %s", success ? "yes" : "no");
|
||||
return success;
|
||||
} else {
|
||||
LOG_ERR("EBP", "Cover image is not a supported format, skipping thumbnail");
|
||||
}
|
||||
}
|
||||
|
||||
// Write an empty bmp file to avoid generation attempts in the future
|
||||
FsFile thumbBmp;
|
||||
Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp);
|
||||
thumbBmp.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
bool Epub::generateInvalidFormatThumbBmp(int height) const {
|
||||
// Create a simple 1-bit BMP with an X pattern to indicate invalid format.
|
||||
// This BMP is a valid 1-bit file used as a marker to prevent repeated
|
||||
// generation attempts when conversion fails (e.g., progressive JPG).
|
||||
const int width = height * 0.6; // Same aspect ratio as normal thumbnails
|
||||
const int rowBytes = ((width + 31) / 32) * 4; // 1-bit rows padded to 4-byte boundary
|
||||
const int imageSize = rowBytes * height;
|
||||
const int fileSize = 14 + 40 + 8 + imageSize; // Header + DIB + palette + data
|
||||
const int dataOffset = 14 + 40 + 8;
|
||||
|
||||
FsFile thumbBmp;
|
||||
if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// BMP file header (14 bytes)
|
||||
thumbBmp.write('B');
|
||||
thumbBmp.write('M');
|
||||
thumbBmp.write(reinterpret_cast<const uint8_t*>(&fileSize), 4);
|
||||
uint32_t reserved = 0;
|
||||
thumbBmp.write(reinterpret_cast<const uint8_t*>(&reserved), 4);
|
||||
thumbBmp.write(reinterpret_cast<const uint8_t*>(&dataOffset), 4);
|
||||
|
||||
// DIB header (BITMAPINFOHEADER - 40 bytes)
|
||||
uint32_t dibHeaderSize = 40;
|
||||
thumbBmp.write(reinterpret_cast<const uint8_t*>(&dibHeaderSize), 4);
|
||||
int32_t bmpWidth = width;
|
||||
thumbBmp.write(reinterpret_cast<const uint8_t*>(&bmpWidth), 4);
|
||||
int32_t bmpHeight = -height; // Negative for top-down
|
||||
thumbBmp.write(reinterpret_cast<const uint8_t*>(&bmpHeight), 4);
|
||||
uint16_t planes = 1;
|
||||
thumbBmp.write(reinterpret_cast<const uint8_t*>(&planes), 2);
|
||||
uint16_t bitsPerPixel = 1;
|
||||
thumbBmp.write(reinterpret_cast<const uint8_t*>(&bitsPerPixel), 2);
|
||||
uint32_t compression = 0;
|
||||
thumbBmp.write(reinterpret_cast<const uint8_t*>(&compression), 4);
|
||||
thumbBmp.write(reinterpret_cast<const uint8_t*>(&imageSize), 4);
|
||||
int32_t ppmX = 2835; // 72 DPI
|
||||
thumbBmp.write(reinterpret_cast<const uint8_t*>(&ppmX), 4);
|
||||
int32_t ppmY = 2835;
|
||||
thumbBmp.write(reinterpret_cast<const uint8_t*>(&ppmY), 4);
|
||||
uint32_t colorsUsed = 2;
|
||||
thumbBmp.write(reinterpret_cast<const uint8_t*>(&colorsUsed), 4);
|
||||
uint32_t colorsImportant = 2;
|
||||
thumbBmp.write(reinterpret_cast<const uint8_t*>(&colorsImportant), 4);
|
||||
|
||||
// Color palette (2 colors for 1-bit)
|
||||
uint8_t black[4] = {0x00, 0x00, 0x00, 0x00}; // Color 0: Black
|
||||
thumbBmp.write(black, 4);
|
||||
uint8_t white[4] = {0xFF, 0xFF, 0xFF, 0x00}; // Color 1: White
|
||||
thumbBmp.write(white, 4);
|
||||
|
||||
// Generate X pattern bitmap data
|
||||
// In BMP, 0 = black (first color in palette), 1 = white
|
||||
// We'll draw black pixels on white background
|
||||
for (int y = 0; y < height; y++) {
|
||||
std::vector<uint8_t> rowData(rowBytes, 0xFF); // Initialize to all white (1s)
|
||||
|
||||
// Map this row to a horizontal position for diagonals
|
||||
const int scaledY = (y * width) / height;
|
||||
const int thickness = 2; // thickness of diagonal lines in pixels
|
||||
|
||||
for (int x = 0; x < width; x++) {
|
||||
bool drawPixel = false;
|
||||
// Main diagonal (top-left to bottom-right)
|
||||
if (std::abs(x - scaledY) <= thickness) drawPixel = true;
|
||||
// Other diagonal (top-right to bottom-left)
|
||||
if (std::abs(x - (width - 1 - scaledY)) <= thickness) drawPixel = true;
|
||||
|
||||
if (drawPixel) {
|
||||
const int byteIndex = x / 8;
|
||||
const int bitIndex = 7 - (x % 8); // MSB first
|
||||
rowData[byteIndex] &= static_cast<uint8_t>(~(1 << bitIndex));
|
||||
}
|
||||
}
|
||||
|
||||
// Write the row data
|
||||
thumbBmp.write(rowData.data(), rowBytes);
|
||||
}
|
||||
|
||||
thumbBmp.close();
|
||||
LOG_DBG("EBP", "Generated invalid format thumbnail BMP");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Epub::generateInvalidFormatCoverBmp(bool cropped) const {
|
||||
// Create a simple 1-bit BMP with an X pattern to indicate invalid format.
|
||||
// This BMP is intentionally a valid image that visually indicates a
|
||||
// malformed/unsupported cover image instead of leaving an empty marker
|
||||
// file that would cause repeated generation attempts.
|
||||
// Derive logical portrait dimensions from the display hardware constants
|
||||
// EInkDisplay reports native panel orientation as 800x480; use min/max
|
||||
const int hwW = HalDisplay::DISPLAY_WIDTH;
|
||||
const int hwH = HalDisplay::DISPLAY_HEIGHT;
|
||||
const int width = std::min(hwW, hwH); // logical portrait width (480)
|
||||
const int height = std::max(hwW, hwH); // logical portrait height (800)
|
||||
const int rowBytes = ((width + 31) / 32) * 4; // 1-bit rows padded to 4-byte boundary
|
||||
const int imageSize = rowBytes * height;
|
||||
const int fileSize = 14 + 40 + 8 + imageSize; // Header + DIB + palette + data
|
||||
const int dataOffset = 14 + 40 + 8;
|
||||
|
||||
FsFile coverBmp;
|
||||
if (!Storage.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// BMP file header (14 bytes)
|
||||
coverBmp.write('B');
|
||||
coverBmp.write('M');
|
||||
coverBmp.write(reinterpret_cast<const uint8_t*>(&fileSize), 4);
|
||||
uint32_t reserved = 0;
|
||||
coverBmp.write(reinterpret_cast<const uint8_t*>(&reserved), 4);
|
||||
coverBmp.write(reinterpret_cast<const uint8_t*>(&dataOffset), 4);
|
||||
|
||||
// DIB header (BITMAPINFOHEADER - 40 bytes)
|
||||
uint32_t dibHeaderSize = 40;
|
||||
coverBmp.write(reinterpret_cast<const uint8_t*>(&dibHeaderSize), 4);
|
||||
int32_t bmpWidth = width;
|
||||
coverBmp.write(reinterpret_cast<const uint8_t*>(&bmpWidth), 4);
|
||||
int32_t bmpHeight = -height; // Negative for top-down
|
||||
coverBmp.write(reinterpret_cast<const uint8_t*>(&bmpHeight), 4);
|
||||
uint16_t planes = 1;
|
||||
coverBmp.write(reinterpret_cast<const uint8_t*>(&planes), 2);
|
||||
uint16_t bitsPerPixel = 1;
|
||||
coverBmp.write(reinterpret_cast<const uint8_t*>(&bitsPerPixel), 2);
|
||||
uint32_t compression = 0;
|
||||
coverBmp.write(reinterpret_cast<const uint8_t*>(&compression), 4);
|
||||
coverBmp.write(reinterpret_cast<const uint8_t*>(&imageSize), 4);
|
||||
int32_t ppmX = 2835; // 72 DPI
|
||||
coverBmp.write(reinterpret_cast<const uint8_t*>(&ppmX), 4);
|
||||
int32_t ppmY = 2835;
|
||||
coverBmp.write(reinterpret_cast<const uint8_t*>(&ppmY), 4);
|
||||
uint32_t colorsUsed = 2;
|
||||
coverBmp.write(reinterpret_cast<const uint8_t*>(&colorsUsed), 4);
|
||||
uint32_t colorsImportant = 2;
|
||||
coverBmp.write(reinterpret_cast<const uint8_t*>(&colorsImportant), 4);
|
||||
|
||||
// Color palette (2 colors for 1-bit)
|
||||
uint8_t black[4] = {0x00, 0x00, 0x00, 0x00}; // Color 0: Black
|
||||
coverBmp.write(black, 4);
|
||||
uint8_t white[4] = {0xFF, 0xFF, 0xFF, 0x00}; // Color 1: White
|
||||
coverBmp.write(white, 4);
|
||||
|
||||
// Generate X pattern bitmap data
|
||||
// In BMP, 0 = black (first color in palette), 1 = white
|
||||
// We'll draw black pixels on white background
|
||||
for (int y = 0; y < height; y++) {
|
||||
std::vector<uint8_t> rowData(rowBytes, 0xFF); // Initialize to all white (1s)
|
||||
|
||||
const int scaledY = (y * width) / height;
|
||||
const int thickness = 6; // thicker lines for full-cover visibility
|
||||
|
||||
for (int x = 0; x < width; x++) {
|
||||
bool drawPixel = false;
|
||||
if (std::abs(x - scaledY) <= thickness) drawPixel = true;
|
||||
if (std::abs(x - (width - 1 - scaledY)) <= thickness) drawPixel = true;
|
||||
|
||||
if (drawPixel) {
|
||||
const int byteIndex = x / 8;
|
||||
const int bitIndex = 7 - (x % 8);
|
||||
rowData[byteIndex] &= static_cast<uint8_t>(~(1 << bitIndex));
|
||||
}
|
||||
}
|
||||
|
||||
coverBmp.write(rowData.data(), rowBytes);
|
||||
}
|
||||
|
||||
coverBmp.close();
|
||||
LOG_DBG("EBP", "Generated invalid format cover BMP");
|
||||
return true;
|
||||
}
|
||||
|
||||
uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, const bool trailingNullByte) const {
|
||||
if (itemHref.empty()) {
|
||||
LOG_DBG("EBP", "Failed to read item, empty href");
|
||||
@@ -724,3 +938,45 @@ float Epub::calculateProgress(const int currentSpineIndex, const float currentSp
|
||||
const float totalProgress = static_cast<float>(prevChapterSize) + sectionProgSize;
|
||||
return totalProgress / static_cast<float>(bookSize);
|
||||
}
|
||||
|
||||
bool Epub::isValidThumbnailBmp(const std::string& bmpPath) {
|
||||
if (!Storage.exists(bmpPath.c_str())) {
|
||||
return false;
|
||||
}
|
||||
FsFile file = Storage.open(bmpPath.c_str());
|
||||
if (!file) {
|
||||
LOG_ERR("EBP", "Failed to open thumbnail BMP at path: %s", bmpPath.c_str());
|
||||
return false;
|
||||
}
|
||||
size_t fileSize = file.size();
|
||||
if (fileSize == 0) {
|
||||
// Empty file is a marker for "no cover available"
|
||||
LOG_DBG("EBP", "Thumbnail BMP is empty (no cover marker) at path: %s", bmpPath.c_str());
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
// BMP header starts with 'B' 'M'
|
||||
uint8_t header[2];
|
||||
size_t bytesRead = file.read(header, 2);
|
||||
if (bytesRead != 2) {
|
||||
LOG_ERR("EBP", "Failed to read thumbnail BMP header at path: %s", bmpPath.c_str());
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
LOG_DBG("EBP", "Thumbnail BMP header: %c%c", header[0], header[1]);
|
||||
file.close();
|
||||
return header[0] == 'B' && header[1] == 'M';
|
||||
}
|
||||
|
||||
std::vector<std::string> Epub::getCoverCandidates() const {
|
||||
std::vector<std::string> coverDirectories = {".", "images", "Images", "OEBPS", "OEBPS/images", "OEBPS/Images"};
|
||||
std::vector<std::string> coverExtensions = {".jpg", ".jpeg"}; // add ".png" when PNG cover support is implemented
|
||||
std::vector<std::string> coverCandidates;
|
||||
for (const auto& ext : coverExtensions) {
|
||||
for (const auto& dir : coverDirectories) {
|
||||
std::string candidate = (dir == ".") ? "cover" + ext : dir + "/cover" + ext;
|
||||
coverCandidates.push_back(candidate);
|
||||
}
|
||||
}
|
||||
return coverCandidates;
|
||||
}
|
||||
|
||||
@@ -35,8 +35,6 @@ class Epub {
|
||||
bool parseTocNcxFile() const;
|
||||
bool parseTocNavFile() const;
|
||||
void parseCssFiles() const;
|
||||
std::string getCssRulesCache() const;
|
||||
bool loadCssRulesFromCache() const;
|
||||
|
||||
public:
|
||||
explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) {
|
||||
@@ -54,10 +52,23 @@ class Epub {
|
||||
const std::string& getAuthor() const;
|
||||
const std::string& getLanguage() const;
|
||||
std::string getCoverBmpPath(bool cropped = false) const;
|
||||
// Generate a 1-bit BMP cover image from the EPUB cover image.
|
||||
// Returns true on success. On conversion failure, callers may use
|
||||
// `generateInvalidFormatCoverBmp` to create a valid marker BMP.
|
||||
bool generateCoverBmp(bool cropped = false) const;
|
||||
// Create a valid 1-bit BMP that visually indicates an invalid/unsupported
|
||||
// cover format (an X pattern). This prevents repeated generation attempts
|
||||
// by providing a valid BMP file that `isValidThumbnailBmp` accepts.
|
||||
bool generateInvalidFormatCoverBmp(bool cropped = false) const;
|
||||
std::string getThumbBmpPath() const;
|
||||
std::string getThumbBmpPath(int height) const;
|
||||
// Generate a thumbnail BMP at the requested `height`. Returns true on
|
||||
// successful conversion. If conversion fails, `generateInvalidFormatThumbBmp`
|
||||
// can be used to write a valid marker image that prevents retries.
|
||||
bool generateThumbBmp(int height) const;
|
||||
// Create a valid 1-bit thumbnail BMP with an X marker indicating an
|
||||
// invalid/unsupported cover image instead of leaving an empty marker file.
|
||||
bool generateInvalidFormatThumbBmp(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;
|
||||
@@ -73,5 +84,10 @@ class Epub {
|
||||
|
||||
size_t getBookSize() const;
|
||||
float calculateProgress(int currentSpineIndex, float currentSpineRead) const;
|
||||
const CssParser* getCssParser() const { return cssParser.get(); }
|
||||
CssParser* getCssParser() const { return cssParser.get(); }
|
||||
|
||||
static bool isValidThumbnailBmp(const std::string& bmpPath);
|
||||
|
||||
private:
|
||||
std::vector<std::string> getCoverCandidates() const;
|
||||
};
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
#include "Page.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <Logging.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
// Cell padding in pixels (must match TABLE_CELL_PAD_* in ChapterHtmlSlimParser.cpp)
|
||||
static constexpr int TABLE_CELL_PADDING_X = 4;
|
||||
static constexpr int TABLE_CELL_PADDING_TOP = 1;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PageLine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void PageLine::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
|
||||
block->render(renderer, fontId, xPos + xOffset, yPos + yOffset);
|
||||
}
|
||||
@@ -25,6 +34,142 @@ std::unique_ptr<PageLine> PageLine::deserialize(FsFile& file) {
|
||||
return std::unique_ptr<PageLine>(new PageLine(std::move(tb), xPos, yPos));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PageImage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void PageImage::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
|
||||
// Images don't use fontId or text rendering
|
||||
imageBlock->render(renderer, xPos + xOffset, yPos + yOffset);
|
||||
}
|
||||
|
||||
bool PageImage::serialize(FsFile& file) {
|
||||
serialization::writePod(file, xPos);
|
||||
serialization::writePod(file, yPos);
|
||||
|
||||
// serialize ImageBlock
|
||||
return imageBlock->serialize(file);
|
||||
}
|
||||
|
||||
std::unique_ptr<PageImage> PageImage::deserialize(FsFile& file) {
|
||||
int16_t xPos;
|
||||
int16_t yPos;
|
||||
serialization::readPod(file, xPos);
|
||||
serialization::readPod(file, yPos);
|
||||
|
||||
auto ib = ImageBlock::deserialize(file);
|
||||
return std::unique_ptr<PageImage>(new PageImage(std::move(ib), xPos, yPos));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PageTableRow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void PageTableRow::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
|
||||
const int baseX = xPos + xOffset;
|
||||
const int baseY = yPos + yOffset;
|
||||
|
||||
// Draw horizontal borders (top and bottom of this row)
|
||||
renderer.drawLine(baseX, baseY, baseX + totalWidth, baseY);
|
||||
renderer.drawLine(baseX, baseY + rowHeight, baseX + totalWidth, baseY + rowHeight);
|
||||
|
||||
// Draw vertical borders and render cell contents
|
||||
// Left edge
|
||||
renderer.drawLine(baseX, baseY, baseX, baseY + rowHeight);
|
||||
|
||||
for (const auto& cell : cells) {
|
||||
// Right vertical border for this cell
|
||||
const int cellRightX = baseX + cell.xOffset + cell.columnWidth;
|
||||
renderer.drawLine(cellRightX, baseY, cellRightX, baseY + rowHeight);
|
||||
|
||||
// Render each text line within the cell
|
||||
const int cellTextX = baseX + cell.xOffset + TABLE_CELL_PADDING_X;
|
||||
int cellLineY = baseY + 1 + TABLE_CELL_PADDING_TOP; // 1px border + top padding
|
||||
|
||||
for (const auto& line : cell.lines) {
|
||||
line->render(renderer, fontId, cellTextX, cellLineY);
|
||||
cellLineY += lineHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool PageTableRow::serialize(FsFile& file) {
|
||||
serialization::writePod(file, xPos);
|
||||
serialization::writePod(file, yPos);
|
||||
serialization::writePod(file, rowHeight);
|
||||
serialization::writePod(file, totalWidth);
|
||||
serialization::writePod(file, lineHeight);
|
||||
|
||||
const uint16_t cellCount = static_cast<uint16_t>(cells.size());
|
||||
serialization::writePod(file, cellCount);
|
||||
|
||||
for (const auto& cell : cells) {
|
||||
serialization::writePod(file, cell.xOffset);
|
||||
serialization::writePod(file, cell.columnWidth);
|
||||
|
||||
const uint16_t lineCount = static_cast<uint16_t>(cell.lines.size());
|
||||
serialization::writePod(file, lineCount);
|
||||
|
||||
for (const auto& line : cell.lines) {
|
||||
if (!line->serialize(file)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
std::unique_ptr<PageTableRow> PageTableRow::deserialize(FsFile& file) {
|
||||
int16_t xPos, yPos, rowHeight, totalWidth, lineHeight;
|
||||
serialization::readPod(file, xPos);
|
||||
serialization::readPod(file, yPos);
|
||||
serialization::readPod(file, rowHeight);
|
||||
serialization::readPod(file, totalWidth);
|
||||
serialization::readPod(file, lineHeight);
|
||||
|
||||
uint16_t cellCount;
|
||||
serialization::readPod(file, cellCount);
|
||||
|
||||
// Sanity check
|
||||
if (cellCount > 100) {
|
||||
LOG_ERR("PTR", "Deserialization failed: cell count %u exceeds maximum", cellCount);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::vector<PageTableCellData> cells;
|
||||
cells.resize(cellCount);
|
||||
|
||||
for (uint16_t c = 0; c < cellCount; ++c) {
|
||||
serialization::readPod(file, cells[c].xOffset);
|
||||
serialization::readPod(file, cells[c].columnWidth);
|
||||
|
||||
uint16_t lineCount;
|
||||
serialization::readPod(file, lineCount);
|
||||
|
||||
if (lineCount > 1000) {
|
||||
LOG_ERR("PTR", "Deserialization failed: line count %u in cell %u exceeds maximum", lineCount, c);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
cells[c].lines.reserve(lineCount);
|
||||
for (uint16_t l = 0; l < lineCount; ++l) {
|
||||
auto tb = TextBlock::deserialize(file);
|
||||
if (!tb) {
|
||||
return nullptr;
|
||||
}
|
||||
cells[c].lines.push_back(std::move(tb));
|
||||
}
|
||||
}
|
||||
|
||||
return std::unique_ptr<PageTableRow>(
|
||||
new PageTableRow(std::move(cells), rowHeight, totalWidth, lineHeight, xPos, yPos));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const {
|
||||
for (auto& element : elements) {
|
||||
element->render(renderer, fontId, xOffset, yOffset);
|
||||
@@ -36,8 +181,7 @@ bool Page::serialize(FsFile& file) const {
|
||||
serialization::writePod(file, count);
|
||||
|
||||
for (const auto& el : elements) {
|
||||
// Only PageLine exists currently
|
||||
serialization::writePod(file, static_cast<uint8_t>(TAG_PageLine));
|
||||
serialization::writePod(file, static_cast<uint8_t>(el->getTag()));
|
||||
if (!el->serialize(file)) {
|
||||
return false;
|
||||
}
|
||||
@@ -59,6 +203,16 @@ std::unique_ptr<Page> Page::deserialize(FsFile& file) {
|
||||
if (tag == TAG_PageLine) {
|
||||
auto pl = PageLine::deserialize(file);
|
||||
page->elements.push_back(std::move(pl));
|
||||
} else if (tag == TAG_PageTableRow) {
|
||||
auto tr = PageTableRow::deserialize(file);
|
||||
if (!tr) {
|
||||
LOG_ERR("PGE", "Deserialization failed for PageTableRow at element %u", i);
|
||||
return nullptr;
|
||||
}
|
||||
page->elements.push_back(std::move(tr));
|
||||
} else if (tag == TAG_PageImage) {
|
||||
auto pi = PageImage::deserialize(file);
|
||||
page->elements.push_back(std::move(pi));
|
||||
} else {
|
||||
LOG_ERR("PGE", "Deserialization failed: Unknown tag %u", tag);
|
||||
return nullptr;
|
||||
@@ -67,3 +221,50 @@ std::unique_ptr<Page> Page::deserialize(FsFile& file) {
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
bool Page::getImageBoundingBox(int& outX, int& outY, int& outWidth, int& outHeight) const {
|
||||
bool firstImage = true;
|
||||
for (const auto& el : elements) {
|
||||
if (el->getTag() == TAG_PageImage) {
|
||||
PageImage* pi = static_cast<PageImage*>(el.get());
|
||||
ImageBlock* ib = pi->getImageBlock();
|
||||
|
||||
if (firstImage) {
|
||||
// Initialize with first image bounds
|
||||
outX = pi->xPos;
|
||||
outY = pi->yPos;
|
||||
outWidth = ib->getWidth();
|
||||
outHeight = ib->getHeight();
|
||||
firstImage = false;
|
||||
} else {
|
||||
// Expand bounding box to include this image
|
||||
int imgX = pi->xPos;
|
||||
int imgY = pi->yPos;
|
||||
int imgW = ib->getWidth();
|
||||
int imgH = ib->getHeight();
|
||||
|
||||
// Expand right boundary
|
||||
if (imgX + imgW > outX + outWidth) {
|
||||
outWidth = (imgX + imgW) - outX;
|
||||
}
|
||||
// Expand left boundary
|
||||
if (imgX < outX) {
|
||||
int oldRight = outX + outWidth;
|
||||
outX = imgX;
|
||||
outWidth = oldRight - outX;
|
||||
}
|
||||
// Expand bottom boundary
|
||||
if (imgY + imgH > outY + outHeight) {
|
||||
outHeight = (imgY + imgH) - outY;
|
||||
}
|
||||
// Expand top boundary
|
||||
if (imgY < outY) {
|
||||
int oldBottom = outY + outHeight;
|
||||
outY = imgY;
|
||||
outHeight = oldBottom - outY;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return !firstImage; // Return true if at least one image was found
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
#pragma once
|
||||
#include <HalStorage.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "blocks/ImageBlock.h"
|
||||
#include "blocks/TextBlock.h"
|
||||
|
||||
enum PageElementTag : uint8_t {
|
||||
TAG_PageLine = 1,
|
||||
TAG_PageTableRow = 2,
|
||||
TAG_PageImage = 3,
|
||||
};
|
||||
|
||||
// represents something that has been added to a page
|
||||
@@ -17,6 +21,7 @@ class PageElement {
|
||||
int16_t yPos;
|
||||
explicit PageElement(const int16_t xPos, const int16_t yPos) : xPos(xPos), yPos(yPos) {}
|
||||
virtual ~PageElement() = default;
|
||||
virtual PageElementTag getTag() const = 0;
|
||||
virtual void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) = 0;
|
||||
virtual bool serialize(FsFile& file) = 0;
|
||||
};
|
||||
@@ -29,11 +34,58 @@ class PageLine final : public PageElement {
|
||||
PageLine(std::shared_ptr<TextBlock> block, const int16_t xPos, const int16_t yPos)
|
||||
: PageElement(xPos, yPos), block(std::move(block)) {}
|
||||
const std::shared_ptr<TextBlock>& getBlock() const { return block; }
|
||||
PageElementTag getTag() const override { return TAG_PageLine; }
|
||||
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
|
||||
bool serialize(FsFile& file) override;
|
||||
static std::unique_ptr<PageLine> deserialize(FsFile& file);
|
||||
};
|
||||
|
||||
/// Data for a single cell within a PageTableRow.
|
||||
struct PageTableCellData {
|
||||
std::vector<std::shared_ptr<TextBlock>> lines; // Laid-out text lines for this cell
|
||||
uint16_t columnWidth = 0; // Width of this column in pixels
|
||||
uint16_t xOffset = 0; // X offset of this cell within the row
|
||||
};
|
||||
|
||||
/// A table row element that renders cells in a column-aligned grid with borders.
|
||||
class PageTableRow final : public PageElement {
|
||||
std::vector<PageTableCellData> cells;
|
||||
int16_t rowHeight; // Total row height in pixels
|
||||
int16_t totalWidth; // Total table width in pixels
|
||||
int16_t lineHeight; // Height of one text line (for vertical positioning of cell lines)
|
||||
|
||||
public:
|
||||
PageTableRow(std::vector<PageTableCellData> cells, int16_t rowHeight, int16_t totalWidth, int16_t lineHeight,
|
||||
int16_t xPos, int16_t yPos)
|
||||
: PageElement(xPos, yPos),
|
||||
cells(std::move(cells)),
|
||||
rowHeight(rowHeight),
|
||||
totalWidth(totalWidth),
|
||||
lineHeight(lineHeight) {}
|
||||
|
||||
int16_t getHeight() const { return rowHeight; }
|
||||
PageElementTag getTag() const override { return TAG_PageTableRow; }
|
||||
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
|
||||
bool serialize(FsFile& file) override;
|
||||
static std::unique_ptr<PageTableRow> deserialize(FsFile& file);
|
||||
};
|
||||
|
||||
// An image element on a page
|
||||
class PageImage final : public PageElement {
|
||||
std::shared_ptr<ImageBlock> imageBlock;
|
||||
|
||||
public:
|
||||
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;
|
||||
bool serialize(FsFile& file) override;
|
||||
PageElementTag getTag() const override { return TAG_PageImage; }
|
||||
static std::unique_ptr<PageImage> deserialize(FsFile& file);
|
||||
|
||||
// Helper to get image block dimensions (needed for bounding box calculation)
|
||||
ImageBlock* getImageBlock() const { return imageBlock.get(); }
|
||||
};
|
||||
|
||||
class Page {
|
||||
public:
|
||||
// the list of block index and line numbers on this page
|
||||
@@ -41,4 +93,15 @@ class Page {
|
||||
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
|
||||
bool serialize(FsFile& file) const;
|
||||
static std::unique_ptr<Page> deserialize(FsFile& file);
|
||||
|
||||
// Check if page contains any images (used to force full refresh)
|
||||
bool hasImages() const {
|
||||
return std::any_of(elements.begin(), elements.end(),
|
||||
[](const std::shared_ptr<PageElement>& el) { return el->getTag() == TAG_PageImage; });
|
||||
}
|
||||
|
||||
// Get the bounding box of all images on this page.
|
||||
// Returns true if page has images and fills out the bounding box coordinates.
|
||||
// If no images, returns false.
|
||||
bool getImageBoundingBox(int& outX, int& outY, int& outWidth, int& outHeight) const;
|
||||
};
|
||||
|
||||
@@ -62,6 +62,13 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
|
||||
}
|
||||
wordStyles.push_back(combinedStyle);
|
||||
wordContinues.push_back(attachToPrevious);
|
||||
forceBreakAfter.push_back(false);
|
||||
}
|
||||
|
||||
void ParsedText::addLineBreak() {
|
||||
if (!words.empty()) {
|
||||
forceBreakAfter.back() = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Consumes data to minimize memory usage
|
||||
@@ -148,6 +155,11 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
|
||||
const int effectivePageWidth = i == 0 ? pageWidth - firstLineIndent : pageWidth;
|
||||
|
||||
for (size_t j = i; j < totalWordCount; ++j) {
|
||||
// If the previous word has a forced line break, this line cannot include word j
|
||||
if (j > static_cast<size_t>(i) && !forceBreakAfter.empty() && forceBreakAfter[j - 1]) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Add space before word j, unless it's the first word on the line or a continuation
|
||||
const int gap = j > static_cast<size_t>(i) && !continuesVec[j] ? spaceWidth : 0;
|
||||
currlen += wordWidths[j] + gap;
|
||||
@@ -156,8 +168,11 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
|
||||
break;
|
||||
}
|
||||
|
||||
// Cannot break after word j if the next word attaches to it (continuation group)
|
||||
if (j + 1 < totalWordCount && continuesVec[j + 1]) {
|
||||
// Forced line break after word j overrides continuation (must end line here)
|
||||
const bool mustBreakHere = !forceBreakAfter.empty() && forceBreakAfter[j];
|
||||
|
||||
// Cannot break after word j if the next word attaches to it (unless forced)
|
||||
if (!mustBreakHere && j + 1 < totalWordCount && continuesVec[j + 1]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -180,6 +195,11 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
|
||||
dp[i] = cost;
|
||||
ans[i] = j; // j is the index of the last word in this optimal line
|
||||
}
|
||||
|
||||
// After evaluating cost, enforce forced break - no more words on this line
|
||||
if (mustBreakHere) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle oversized word: if no valid configuration found, force single-word line
|
||||
@@ -254,6 +274,11 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
|
||||
|
||||
// Consume as many words as possible for current line, splitting when prefixes fit
|
||||
while (currentIndex < wordWidths.size()) {
|
||||
// If the previous word has a forced line break, stop - this word starts a new line
|
||||
if (currentIndex > lineStart && !forceBreakAfter.empty() && forceBreakAfter[currentIndex - 1]) {
|
||||
break;
|
||||
}
|
||||
|
||||
const bool isFirstWord = currentIndex == lineStart;
|
||||
const int spacing = isFirstWord || continuesVec[currentIndex] ? 0 : spaceWidth;
|
||||
const int candidateWidth = spacing + wordWidths[currentIndex];
|
||||
@@ -262,6 +287,11 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
|
||||
if (lineWidth + candidateWidth <= effectivePageWidth) {
|
||||
lineWidth += candidateWidth;
|
||||
++currentIndex;
|
||||
|
||||
// If the word we just added has a forced break, end this line now
|
||||
if (!forceBreakAfter.empty() && forceBreakAfter[currentIndex - 1]) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -287,7 +317,12 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
|
||||
|
||||
// Don't break before a continuation word (e.g., orphaned "?" after "question").
|
||||
// Backtrack to the start of the continuation group so the whole group moves to the next line.
|
||||
// But don't backtrack past a forced break point.
|
||||
while (currentIndex > lineStart + 1 && currentIndex < wordWidths.size() && continuesVec[currentIndex]) {
|
||||
// Don't backtrack past a forced break
|
||||
if (!forceBreakAfter.empty() && forceBreakAfter[currentIndex - 1]) {
|
||||
break;
|
||||
}
|
||||
--currentIndex;
|
||||
}
|
||||
|
||||
@@ -361,6 +396,13 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
|
||||
wordContinues[wordIndex] = false;
|
||||
wordContinues.insert(wordContinues.begin() + wordIndex + 1, originalContinuedToNext);
|
||||
|
||||
// Forced break belongs to the original whole word; transfer it to the remainder (last part).
|
||||
if (!forceBreakAfter.empty()) {
|
||||
const bool originalForceBreak = forceBreakAfter[wordIndex];
|
||||
forceBreakAfter[wordIndex] = false; // prefix doesn't force break
|
||||
forceBreakAfter.insert(forceBreakAfter.begin() + wordIndex + 1, originalForceBreak);
|
||||
}
|
||||
|
||||
// Update cached widths to reflect the new prefix/remainder pairing.
|
||||
wordWidths[wordIndex] = static_cast<uint16_t>(chosenWidth);
|
||||
const uint16_t remainderWidth = measureWordWidth(renderer, fontId, remainder, style);
|
||||
@@ -447,3 +489,22 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
||||
processLine(
|
||||
std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), blockStyle));
|
||||
}
|
||||
|
||||
uint16_t ParsedText::getNaturalWidth(const GfxRenderer& renderer, const int fontId) const {
|
||||
if (words.empty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const int spaceWidth = renderer.getSpaceWidth(fontId);
|
||||
int totalWidth = 0;
|
||||
|
||||
for (size_t i = 0; i < words.size(); ++i) {
|
||||
totalWidth += measureWordWidth(renderer, fontId, words[i], wordStyles[i]);
|
||||
// Add a space before this word unless it's the first word or a continuation
|
||||
if (i > 0 && !wordContinues[i]) {
|
||||
totalWidth += spaceWidth;
|
||||
}
|
||||
}
|
||||
|
||||
return static_cast<uint16_t>(std::min(totalWidth, static_cast<int>(UINT16_MAX)));
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ class GfxRenderer;
|
||||
class ParsedText {
|
||||
std::vector<std::string> words;
|
||||
std::vector<EpdFontFamily::Style> wordStyles;
|
||||
std::vector<bool> wordContinues; // true = word attaches to previous (no space before it)
|
||||
std::vector<bool> wordContinues; // true = word attaches to previous (no space before it)
|
||||
std::vector<bool> forceBreakAfter; // true = mandatory line break after this word (e.g. <br> in table cells)
|
||||
BlockStyle blockStyle;
|
||||
bool extraParagraphSpacing;
|
||||
bool hyphenationEnabled;
|
||||
@@ -40,6 +41,10 @@ class ParsedText {
|
||||
~ParsedText() = default;
|
||||
|
||||
void addWord(std::string word, EpdFontFamily::Style fontStyle, bool underline = false, bool attachToPrevious = false);
|
||||
|
||||
/// Mark a forced line break after the last word (e.g. for <br> within table cells).
|
||||
/// If no words have been added yet, this is a no-op.
|
||||
void addLineBreak();
|
||||
void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; }
|
||||
BlockStyle& getBlockStyle() { return blockStyle; }
|
||||
size_t size() const { return words.size(); }
|
||||
@@ -47,4 +52,9 @@ class ParsedText {
|
||||
void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, uint16_t viewportWidth,
|
||||
const std::function<void(std::shared_ptr<TextBlock>)>& processLine,
|
||||
bool includeLastLine = true);
|
||||
|
||||
/// Returns the "natural" width of the content if it were laid out on a single line
|
||||
/// (sum of word widths + space widths between non-continuation words).
|
||||
/// Used by table layout to determine column widths before line-breaking.
|
||||
uint16_t getNaturalWidth(const GfxRenderer& renderer, int fontId) const;
|
||||
};
|
||||
@@ -9,7 +9,7 @@
|
||||
#include "parsers/ChapterHtmlSlimParser.h"
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t SECTION_FILE_VERSION = 12;
|
||||
constexpr uint8_t SECTION_FILE_VERSION = 13;
|
||||
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
|
||||
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) +
|
||||
sizeof(uint32_t);
|
||||
@@ -181,11 +181,25 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
||||
viewportHeight, hyphenationEnabled, embeddedStyle);
|
||||
std::vector<uint32_t> lut = {};
|
||||
|
||||
// Derive the content base directory and image cache path prefix for the parser
|
||||
size_t lastSlash = localPath.find_last_of('/');
|
||||
std::string contentBase = (lastSlash != std::string::npos) ? localPath.substr(0, lastSlash + 1) : "";
|
||||
std::string imageBasePath = epub->getCachePath() + "/img_" + std::to_string(spineIndex) + "_";
|
||||
|
||||
CssParser* cssParser = nullptr;
|
||||
if (embeddedStyle) {
|
||||
cssParser = epub->getCssParser();
|
||||
if (cssParser) {
|
||||
if (!cssParser->loadFromCache()) {
|
||||
LOG_ERR("SCT", "Failed to load CSS from cache");
|
||||
}
|
||||
}
|
||||
}
|
||||
ChapterHtmlSlimParser visitor(
|
||||
tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
||||
epub, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
||||
viewportHeight, hyphenationEnabled,
|
||||
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); },
|
||||
embeddedStyle, popupFn, embeddedStyle ? epub->getCssParser() : nullptr);
|
||||
embeddedStyle, contentBase, imageBasePath, popupFn, cssParser);
|
||||
Hyphenator::setPreferredLanguage(epub->getLanguage());
|
||||
success = visitor.parseAndBuildPages();
|
||||
|
||||
@@ -194,6 +208,9 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
||||
LOG_ERR("SCT", "Failed to parse XML and build pages");
|
||||
file.close();
|
||||
Storage.remove(filePath.c_str());
|
||||
if (cssParser) {
|
||||
cssParser->clear();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -220,6 +237,9 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
||||
serialization::writePod(file, pageCount);
|
||||
serialization::writePod(file, lutOffset);
|
||||
file.close();
|
||||
if (cssParser) {
|
||||
cssParser->clear();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
29
lib/Epub/Epub/TableData.h
Normal file
29
lib/Epub/Epub/TableData.h
Normal file
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include "ParsedText.h"
|
||||
#include "css/CssStyle.h"
|
||||
|
||||
/// A single cell in a table row.
|
||||
struct TableCell {
|
||||
std::unique_ptr<ParsedText> content;
|
||||
bool isHeader = false; // true for <th>, false for <td>
|
||||
int colspan = 1; // number of logical columns this cell spans
|
||||
CssLength widthHint; // width hint from HTML attribute or CSS (if hasWidthHint)
|
||||
bool hasWidthHint = false;
|
||||
};
|
||||
|
||||
/// A single row in a table.
|
||||
struct TableRow {
|
||||
std::vector<TableCell> cells;
|
||||
};
|
||||
|
||||
/// Buffered table data collected during SAX parsing.
|
||||
/// The entire table must be buffered before layout because column widths
|
||||
/// depend on content across all rows.
|
||||
struct TableData {
|
||||
std::vector<TableRow> rows;
|
||||
std::vector<CssLength> colWidthHints; // width hints from <col> tags, indexed by logical column
|
||||
};
|
||||
@@ -8,7 +8,7 @@ typedef enum { TEXT_BLOCK, IMAGE_BLOCK } BlockType;
|
||||
class Block {
|
||||
public:
|
||||
virtual ~Block() = default;
|
||||
virtual void layout(GfxRenderer& renderer) = 0;
|
||||
|
||||
virtual BlockType getType() = 0;
|
||||
virtual bool isEmpty() = 0;
|
||||
virtual void finish() {}
|
||||
|
||||
175
lib/Epub/Epub/blocks/ImageBlock.cpp
Normal file
175
lib/Epub/Epub/blocks/ImageBlock.cpp
Normal file
@@ -0,0 +1,175 @@
|
||||
#include "ImageBlock.h"
|
||||
|
||||
#include <FsHelpers.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HardwareSerial.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
#include "../converters/DitherUtils.h"
|
||||
#include "../converters/ImageDecoderFactory.h"
|
||||
|
||||
// Cache file format:
|
||||
// - uint16_t width
|
||||
// - 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) {}
|
||||
|
||||
bool ImageBlock::imageExists() const { return Storage.exists(imagePath.c_str()); }
|
||||
|
||||
namespace {
|
||||
|
||||
std::string getCachePath(const std::string& imagePath) {
|
||||
// Replace extension with .pxc (pixel cache)
|
||||
size_t dotPos = imagePath.rfind('.');
|
||||
if (dotPos != std::string::npos) {
|
||||
return imagePath.substr(0, dotPos) + ".pxc";
|
||||
}
|
||||
return imagePath + ".pxc";
|
||||
}
|
||||
|
||||
bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, int y, int expectedWidth,
|
||||
int expectedHeight) {
|
||||
FsFile cacheFile;
|
||||
if (!Storage.openFileForRead("IMG", cachePath, cacheFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint16_t cachedWidth, cachedHeight;
|
||||
if (cacheFile.read(&cachedWidth, 2) != 2 || cacheFile.read(&cachedHeight, 2) != 2) {
|
||||
cacheFile.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify dimensions are close (allow 1 pixel tolerance for rounding differences)
|
||||
int widthDiff = abs(cachedWidth - expectedWidth);
|
||||
int heightDiff = abs(cachedHeight - expectedHeight);
|
||||
if (widthDiff > 1 || heightDiff > 1) {
|
||||
Serial.printf("[%lu] [IMG] Cache dimension mismatch: %dx%d vs %dx%d\n", millis(), cachedWidth, cachedHeight,
|
||||
expectedWidth, expectedHeight);
|
||||
cacheFile.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use cached dimensions for rendering (they're the actual decoded size)
|
||||
expectedWidth = cachedWidth;
|
||||
expectedHeight = cachedHeight;
|
||||
|
||||
Serial.printf("[%lu] [IMG] Loading from cache: %s (%dx%d)\n", millis(), cachePath.c_str(), cachedWidth, cachedHeight);
|
||||
|
||||
// Read and render row by row to minimize memory usage
|
||||
const int bytesPerRow = (cachedWidth + 3) / 4; // 2 bits per pixel, 4 pixels per byte
|
||||
uint8_t* rowBuffer = (uint8_t*)malloc(bytesPerRow);
|
||||
if (!rowBuffer) {
|
||||
Serial.printf("[%lu] [IMG] Failed to allocate row buffer\n", millis());
|
||||
cacheFile.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int row = 0; row < cachedHeight; row++) {
|
||||
if (cacheFile.read(rowBuffer, bytesPerRow) != bytesPerRow) {
|
||||
Serial.printf("[%lu] [IMG] Cache read error at row %d\n", millis(), row);
|
||||
free(rowBuffer);
|
||||
cacheFile.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
int destY = y + row;
|
||||
for (int col = 0; col < cachedWidth; col++) {
|
||||
int byteIdx = col / 4;
|
||||
int bitShift = 6 - (col % 4) * 2; // MSB first within byte
|
||||
uint8_t pixelValue = (rowBuffer[byteIdx] >> bitShift) & 0x03;
|
||||
|
||||
drawPixelWithRenderMode(renderer, x + col, destY, pixelValue);
|
||||
}
|
||||
}
|
||||
|
||||
free(rowBuffer);
|
||||
cacheFile.close();
|
||||
Serial.printf("[%lu] [IMG] Cache render complete\n", millis());
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
|
||||
Serial.printf("[%lu] [IMG] Rendering image at %d,%d: %s (%dx%d)\n", millis(), x, y, imagePath.c_str(), width, height);
|
||||
|
||||
const int screenWidth = renderer.getScreenWidth();
|
||||
const int screenHeight = renderer.getScreenHeight();
|
||||
|
||||
// Bounds check render position using logical screen dimensions
|
||||
if (x < 0 || y < 0 || x + width > screenWidth || y + height > screenHeight) {
|
||||
Serial.printf("[%lu] [IMG] Invalid render position: (%d,%d) size (%dx%d) screen (%dx%d)\n", millis(), x, y, width,
|
||||
height, screenWidth, screenHeight);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to render from cache first
|
||||
std::string cachePath = getCachePath(imagePath);
|
||||
if (renderFromCache(renderer, cachePath, x, y, width, height)) {
|
||||
return; // Successfully rendered from cache
|
||||
}
|
||||
|
||||
// No cache - need to decode the image
|
||||
// Check if image file exists
|
||||
FsFile file;
|
||||
if (!Storage.openFileForRead("IMG", imagePath, file)) {
|
||||
Serial.printf("[%lu] [IMG] Image file not found: %s\n", millis(), imagePath.c_str());
|
||||
return;
|
||||
}
|
||||
size_t fileSize = file.size();
|
||||
file.close();
|
||||
|
||||
if (fileSize == 0) {
|
||||
Serial.printf("[%lu] [IMG] Image file is empty: %s\n", millis(), imagePath.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [IMG] Decoding and caching: %s\n", millis(), imagePath.c_str());
|
||||
|
||||
RenderConfig config;
|
||||
config.x = x;
|
||||
config.y = y;
|
||||
config.maxWidth = width;
|
||||
config.maxHeight = height;
|
||||
config.useGrayscale = true;
|
||||
config.useDithering = true;
|
||||
config.performanceMode = false;
|
||||
config.useExactDimensions = true; // Use pre-calculated dimensions to avoid rounding mismatches
|
||||
config.cachePath = cachePath; // Enable caching during decode
|
||||
|
||||
ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(imagePath);
|
||||
if (!decoder) {
|
||||
Serial.printf("[%lu] [IMG] No decoder found for image: %s\n", millis(), imagePath.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [IMG] Using %s decoder\n", millis(), decoder->getFormatName());
|
||||
|
||||
bool success = decoder->decodeToFramebuffer(imagePath, renderer, config);
|
||||
if (!success) {
|
||||
Serial.printf("[%lu] [IMG] Failed to decode image: %s\n", millis(), imagePath.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [IMG] Decode successful\n", millis());
|
||||
}
|
||||
|
||||
bool ImageBlock::serialize(FsFile& file) {
|
||||
serialization::writeString(file, imagePath);
|
||||
serialization::writePod(file, width);
|
||||
serialization::writePod(file, height);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::unique_ptr<ImageBlock> ImageBlock::deserialize(FsFile& file) {
|
||||
std::string path;
|
||||
serialization::readString(file, path);
|
||||
int16_t w, h;
|
||||
serialization::readPod(file, w);
|
||||
serialization::readPod(file, h);
|
||||
return std::unique_ptr<ImageBlock>(new ImageBlock(path, w, h));
|
||||
}
|
||||
31
lib/Epub/Epub/blocks/ImageBlock.h
Normal file
31
lib/Epub/Epub/blocks/ImageBlock.h
Normal file
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
#include <SdFat.h>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "Block.h"
|
||||
|
||||
class ImageBlock final : public Block {
|
||||
public:
|
||||
ImageBlock(const std::string& imagePath, int16_t width, int16_t height);
|
||||
~ImageBlock() override = default;
|
||||
|
||||
const std::string& getImagePath() const { return imagePath; }
|
||||
int16_t getWidth() const { return width; }
|
||||
int16_t getHeight() const { return height; }
|
||||
|
||||
bool imageExists() const;
|
||||
|
||||
BlockType getType() override { return IMAGE_BLOCK; }
|
||||
bool isEmpty() override { return false; }
|
||||
|
||||
void render(GfxRenderer& renderer, const int x, const int y);
|
||||
bool serialize(FsFile& file);
|
||||
static std::unique_ptr<ImageBlock> deserialize(FsFile& file);
|
||||
|
||||
private:
|
||||
std::string imagePath;
|
||||
int16_t width;
|
||||
int16_t height;
|
||||
};
|
||||
@@ -31,7 +31,6 @@ class TextBlock final : public Block {
|
||||
const std::vector<uint16_t>& getWordXpos() const { return wordXpos; }
|
||||
const std::vector<EpdFontFamily::Style>& getWordStyles() const { return wordStyles; }
|
||||
bool isEmpty() override { return words.empty(); }
|
||||
void layout(GfxRenderer& renderer) override {};
|
||||
// given a renderer works out where to break the words into lines
|
||||
void render(const GfxRenderer& renderer, int fontId, int x, int y) const;
|
||||
BlockType getType() override { return TEXT_BLOCK; }
|
||||
|
||||
40
lib/Epub/Epub/converters/DitherUtils.h
Normal file
40
lib/Epub/Epub/converters/DitherUtils.h
Normal file
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <stdint.h>
|
||||
|
||||
// 4x4 Bayer matrix for ordered dithering
|
||||
inline const uint8_t bayer4x4[4][4] = {
|
||||
{0, 8, 2, 10},
|
||||
{12, 4, 14, 6},
|
||||
{3, 11, 1, 9},
|
||||
{15, 7, 13, 5},
|
||||
};
|
||||
|
||||
// Apply Bayer dithering and quantize to 4 levels (0-3)
|
||||
// Stateless - works correctly with any pixel processing order
|
||||
inline uint8_t applyBayerDither4Level(uint8_t gray, int x, int y) {
|
||||
int bayer = bayer4x4[y & 3][x & 3];
|
||||
int dither = (bayer - 8) * 5; // Scale to +/-40 (half of quantization step 85)
|
||||
|
||||
int adjusted = gray + dither;
|
||||
if (adjusted < 0) adjusted = 0;
|
||||
if (adjusted > 255) adjusted = 255;
|
||||
|
||||
if (adjusted < 64) return 0;
|
||||
if (adjusted < 128) return 1;
|
||||
if (adjusted < 192) return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
// Draw a pixel respecting the current render mode for grayscale support
|
||||
inline void drawPixelWithRenderMode(GfxRenderer& renderer, int x, int y, uint8_t pixelValue) {
|
||||
GfxRenderer::RenderMode renderMode = renderer.getRenderMode();
|
||||
if (renderMode == GfxRenderer::BW && pixelValue < 3) {
|
||||
renderer.drawPixel(x, y, true);
|
||||
} else if (renderMode == GfxRenderer::GRAYSCALE_MSB && (pixelValue == 1 || pixelValue == 2)) {
|
||||
renderer.drawPixel(x, y, false);
|
||||
} else if (renderMode == GfxRenderer::GRAYSCALE_LSB && pixelValue == 1) {
|
||||
renderer.drawPixel(x, y, false);
|
||||
}
|
||||
}
|
||||
42
lib/Epub/Epub/converters/ImageDecoderFactory.cpp
Normal file
42
lib/Epub/Epub/converters/ImageDecoderFactory.cpp
Normal file
@@ -0,0 +1,42 @@
|
||||
#include "ImageDecoderFactory.h"
|
||||
|
||||
#include <HardwareSerial.h>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "JpegToFramebufferConverter.h"
|
||||
#include "PngToFramebufferConverter.h"
|
||||
|
||||
std::unique_ptr<JpegToFramebufferConverter> ImageDecoderFactory::jpegDecoder = nullptr;
|
||||
std::unique_ptr<PngToFramebufferConverter> ImageDecoderFactory::pngDecoder = nullptr;
|
||||
|
||||
ImageToFramebufferDecoder* ImageDecoderFactory::getDecoder(const std::string& imagePath) {
|
||||
std::string ext = imagePath;
|
||||
size_t dotPos = ext.rfind('.');
|
||||
if (dotPos != std::string::npos) {
|
||||
ext = ext.substr(dotPos);
|
||||
for (auto& c : ext) {
|
||||
c = tolower(c);
|
||||
}
|
||||
} else {
|
||||
ext = "";
|
||||
}
|
||||
|
||||
if (JpegToFramebufferConverter::supportsFormat(ext)) {
|
||||
if (!jpegDecoder) {
|
||||
jpegDecoder.reset(new JpegToFramebufferConverter());
|
||||
}
|
||||
return jpegDecoder.get();
|
||||
} else if (PngToFramebufferConverter::supportsFormat(ext)) {
|
||||
if (!pngDecoder) {
|
||||
pngDecoder.reset(new PngToFramebufferConverter());
|
||||
}
|
||||
return pngDecoder.get();
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [DEC] No decoder found for image: %s\n", millis(), imagePath.c_str());
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool ImageDecoderFactory::isFormatSupported(const std::string& imagePath) { return getDecoder(imagePath) != nullptr; }
|
||||
20
lib/Epub/Epub/converters/ImageDecoderFactory.h
Normal file
20
lib/Epub/Epub/converters/ImageDecoderFactory.h
Normal file
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "ImageToFramebufferDecoder.h"
|
||||
|
||||
class JpegToFramebufferConverter;
|
||||
class PngToFramebufferConverter;
|
||||
|
||||
class ImageDecoderFactory {
|
||||
public:
|
||||
// Returns non-owning pointer - factory owns the decoder lifetime
|
||||
static ImageToFramebufferDecoder* getDecoder(const std::string& imagePath);
|
||||
static bool isFormatSupported(const std::string& imagePath);
|
||||
|
||||
private:
|
||||
static std::unique_ptr<JpegToFramebufferConverter> jpegDecoder;
|
||||
static std::unique_ptr<PngToFramebufferConverter> pngDecoder;
|
||||
};
|
||||
18
lib/Epub/Epub/converters/ImageToFramebufferDecoder.cpp
Normal file
18
lib/Epub/Epub/converters/ImageToFramebufferDecoder.cpp
Normal file
@@ -0,0 +1,18 @@
|
||||
#include "ImageToFramebufferDecoder.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <HardwareSerial.h>
|
||||
|
||||
bool ImageToFramebufferDecoder::validateImageDimensions(int width, int height, const std::string& format) {
|
||||
if (width * height > MAX_SOURCE_PIXELS) {
|
||||
Serial.printf("[%lu] [IMG] Image too large (%dx%d = %d pixels %s), max supported: %d pixels\n", millis(), width,
|
||||
height, width * height, format.c_str(), MAX_SOURCE_PIXELS);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void ImageToFramebufferDecoder::warnUnsupportedFeature(const std::string& feature, const std::string& imagePath) {
|
||||
Serial.printf("[%lu] [IMG] Warning: Unsupported feature '%s' in image '%s'. Image may not display correctly.\n",
|
||||
millis(), feature.c_str(), imagePath.c_str());
|
||||
}
|
||||
40
lib/Epub/Epub/converters/ImageToFramebufferDecoder.h
Normal file
40
lib/Epub/Epub/converters/ImageToFramebufferDecoder.h
Normal file
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
#include <SdFat.h>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
class GfxRenderer;
|
||||
|
||||
struct ImageDimensions {
|
||||
int16_t width;
|
||||
int16_t height;
|
||||
};
|
||||
|
||||
struct RenderConfig {
|
||||
int x, y;
|
||||
int maxWidth, maxHeight;
|
||||
bool useGrayscale = true;
|
||||
bool useDithering = true;
|
||||
bool performanceMode = false;
|
||||
bool useExactDimensions = false; // If true, use maxWidth/maxHeight as exact output size (no recalculation)
|
||||
std::string cachePath; // If non-empty, decoder will write pixel cache to this path
|
||||
};
|
||||
|
||||
class ImageToFramebufferDecoder {
|
||||
public:
|
||||
virtual ~ImageToFramebufferDecoder() = default;
|
||||
|
||||
virtual bool decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, const RenderConfig& config) = 0;
|
||||
|
||||
virtual bool getDimensions(const std::string& imagePath, ImageDimensions& dims) const = 0;
|
||||
|
||||
virtual const char* getFormatName() const = 0;
|
||||
|
||||
protected:
|
||||
// Size validation helpers
|
||||
static constexpr int MAX_SOURCE_PIXELS = 3145728; // 2048 * 1536
|
||||
|
||||
bool validateImageDimensions(int width, int height, const std::string& format);
|
||||
void warnUnsupportedFeature(const std::string& feature, const std::string& imagePath);
|
||||
};
|
||||
298
lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp
Normal file
298
lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp
Normal file
@@ -0,0 +1,298 @@
|
||||
#include "JpegToFramebufferConverter.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <HardwareSerial.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <SdFat.h>
|
||||
#include <picojpeg.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
#include "DitherUtils.h"
|
||||
#include "PixelCache.h"
|
||||
|
||||
struct JpegContext {
|
||||
FsFile& file;
|
||||
uint8_t buffer[512];
|
||||
size_t bufferPos;
|
||||
size_t bufferFilled;
|
||||
JpegContext(FsFile& f) : file(f), bufferPos(0), bufferFilled(0) {}
|
||||
};
|
||||
|
||||
bool JpegToFramebufferConverter::getDimensionsStatic(const std::string& imagePath, ImageDimensions& out) {
|
||||
FsFile file;
|
||||
if (!Storage.openFileForRead("JPG", imagePath, file)) {
|
||||
Serial.printf("[%lu] [JPG] Failed to open file for dimensions: %s\n", millis(), imagePath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
JpegContext context(file);
|
||||
pjpeg_image_info_t imageInfo;
|
||||
|
||||
int status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0);
|
||||
file.close();
|
||||
|
||||
if (status != 0) {
|
||||
Serial.printf("[%lu] [JPG] Failed to init JPEG for dimensions: %d\n", millis(), status);
|
||||
return false;
|
||||
}
|
||||
|
||||
out.width = imageInfo.m_width;
|
||||
out.height = imageInfo.m_height;
|
||||
Serial.printf("[%lu] [JPG] Image dimensions: %dx%d\n", millis(), out.width, out.height);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer,
|
||||
const RenderConfig& config) {
|
||||
Serial.printf("[%lu] [JPG] Decoding JPEG: %s\n", millis(), imagePath.c_str());
|
||||
|
||||
FsFile file;
|
||||
if (!Storage.openFileForRead("JPG", imagePath, file)) {
|
||||
Serial.printf("[%lu] [JPG] Failed to open file: %s\n", millis(), imagePath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
JpegContext context(file);
|
||||
pjpeg_image_info_t imageInfo;
|
||||
|
||||
int status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0);
|
||||
if (status != 0) {
|
||||
Serial.printf("[%lu] [JPG] picojpeg init failed: %d\n", millis(), status);
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!validateImageDimensions(imageInfo.m_width, imageInfo.m_height, "JPEG")) {
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate output dimensions
|
||||
int destWidth, destHeight;
|
||||
float scale;
|
||||
|
||||
if (config.useExactDimensions && config.maxWidth > 0 && config.maxHeight > 0) {
|
||||
// Use exact dimensions as specified (avoids rounding mismatches with pre-calculated sizes)
|
||||
destWidth = config.maxWidth;
|
||||
destHeight = config.maxHeight;
|
||||
scale = (float)destWidth / imageInfo.m_width;
|
||||
} else {
|
||||
// Calculate scale factor to fit within maxWidth/maxHeight
|
||||
float scaleX = (config.maxWidth > 0 && imageInfo.m_width > config.maxWidth)
|
||||
? (float)config.maxWidth / imageInfo.m_width
|
||||
: 1.0f;
|
||||
float scaleY = (config.maxHeight > 0 && imageInfo.m_height > config.maxHeight)
|
||||
? (float)config.maxHeight / imageInfo.m_height
|
||||
: 1.0f;
|
||||
scale = (scaleX < scaleY) ? scaleX : scaleY;
|
||||
if (scale > 1.0f) scale = 1.0f;
|
||||
|
||||
destWidth = (int)(imageInfo.m_width * scale);
|
||||
destHeight = (int)(imageInfo.m_height * scale);
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [JPG] JPEG %dx%d -> %dx%d (scale %.2f), scan type: %d, MCU: %dx%d\n", millis(),
|
||||
imageInfo.m_width, imageInfo.m_height, destWidth, destHeight, scale, imageInfo.m_scanType,
|
||||
imageInfo.m_MCUWidth, imageInfo.m_MCUHeight);
|
||||
|
||||
if (!imageInfo.m_pMCUBufR || !imageInfo.m_pMCUBufG || !imageInfo.m_pMCUBufB) {
|
||||
Serial.printf("[%lu] [JPG] Null buffer pointers in imageInfo\n", millis());
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
const int screenWidth = renderer.getScreenWidth();
|
||||
const int screenHeight = renderer.getScreenHeight();
|
||||
|
||||
// Allocate pixel cache if cachePath is provided
|
||||
PixelCache cache;
|
||||
bool caching = !config.cachePath.empty();
|
||||
if (caching) {
|
||||
if (!cache.allocate(destWidth, destHeight, config.x, config.y)) {
|
||||
Serial.printf("[%lu] [JPG] Failed to allocate cache buffer, continuing without caching\n", millis());
|
||||
caching = false;
|
||||
}
|
||||
}
|
||||
|
||||
int mcuX = 0;
|
||||
int mcuY = 0;
|
||||
|
||||
while (mcuY < imageInfo.m_MCUSPerCol) {
|
||||
status = pjpeg_decode_mcu();
|
||||
if (status == PJPG_NO_MORE_BLOCKS) {
|
||||
break;
|
||||
}
|
||||
if (status != 0) {
|
||||
Serial.printf("[%lu] [JPG] MCU decode failed: %d\n", millis(), status);
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Source position in image coordinates
|
||||
int srcStartX = mcuX * imageInfo.m_MCUWidth;
|
||||
int srcStartY = mcuY * imageInfo.m_MCUHeight;
|
||||
|
||||
switch (imageInfo.m_scanType) {
|
||||
case PJPG_GRAYSCALE:
|
||||
for (int row = 0; row < 8; row++) {
|
||||
int srcY = srcStartY + row;
|
||||
int destY = config.y + (int)(srcY * scale);
|
||||
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
|
||||
for (int col = 0; col < 8; col++) {
|
||||
int srcX = srcStartX + col;
|
||||
int destX = config.x + (int)(srcX * scale);
|
||||
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
|
||||
uint8_t gray = imageInfo.m_pMCUBufR[row * 8 + col];
|
||||
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
|
||||
if (dithered > 3) dithered = 3;
|
||||
drawPixelWithRenderMode(renderer, destX, destY, dithered);
|
||||
if (caching) cache.setPixel(destX, destY, dithered);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case PJPG_YH1V1:
|
||||
for (int row = 0; row < 8; row++) {
|
||||
int srcY = srcStartY + row;
|
||||
int destY = config.y + (int)(srcY * scale);
|
||||
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
|
||||
for (int col = 0; col < 8; col++) {
|
||||
int srcX = srcStartX + col;
|
||||
int destX = config.x + (int)(srcX * scale);
|
||||
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
|
||||
uint8_t r = imageInfo.m_pMCUBufR[row * 8 + col];
|
||||
uint8_t g = imageInfo.m_pMCUBufG[row * 8 + col];
|
||||
uint8_t b = imageInfo.m_pMCUBufB[row * 8 + col];
|
||||
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
|
||||
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
|
||||
if (dithered > 3) dithered = 3;
|
||||
drawPixelWithRenderMode(renderer, destX, destY, dithered);
|
||||
if (caching) cache.setPixel(destX, destY, dithered);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case PJPG_YH2V1:
|
||||
for (int row = 0; row < 8; row++) {
|
||||
int srcY = srcStartY + row;
|
||||
int destY = config.y + (int)(srcY * scale);
|
||||
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
|
||||
for (int col = 0; col < 16; col++) {
|
||||
int srcX = srcStartX + col;
|
||||
int destX = config.x + (int)(srcX * scale);
|
||||
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
|
||||
int blockIndex = (col < 8) ? 0 : 1;
|
||||
int pixelIndex = row * 8 + (col % 8);
|
||||
uint8_t r = imageInfo.m_pMCUBufR[blockIndex * 64 + pixelIndex];
|
||||
uint8_t g = imageInfo.m_pMCUBufG[blockIndex * 64 + pixelIndex];
|
||||
uint8_t b = imageInfo.m_pMCUBufB[blockIndex * 64 + pixelIndex];
|
||||
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
|
||||
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
|
||||
if (dithered > 3) dithered = 3;
|
||||
drawPixelWithRenderMode(renderer, destX, destY, dithered);
|
||||
if (caching) cache.setPixel(destX, destY, dithered);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case PJPG_YH1V2:
|
||||
for (int row = 0; row < 16; row++) {
|
||||
int srcY = srcStartY + row;
|
||||
int destY = config.y + (int)(srcY * scale);
|
||||
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
|
||||
for (int col = 0; col < 8; col++) {
|
||||
int srcX = srcStartX + col;
|
||||
int destX = config.x + (int)(srcX * scale);
|
||||
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
|
||||
int blockIndex = (row < 8) ? 0 : 1;
|
||||
int pixelIndex = (row % 8) * 8 + col;
|
||||
uint8_t r = imageInfo.m_pMCUBufR[blockIndex * 128 + pixelIndex];
|
||||
uint8_t g = imageInfo.m_pMCUBufG[blockIndex * 128 + pixelIndex];
|
||||
uint8_t b = imageInfo.m_pMCUBufB[blockIndex * 128 + pixelIndex];
|
||||
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
|
||||
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
|
||||
if (dithered > 3) dithered = 3;
|
||||
drawPixelWithRenderMode(renderer, destX, destY, dithered);
|
||||
if (caching) cache.setPixel(destX, destY, dithered);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case PJPG_YH2V2:
|
||||
for (int row = 0; row < 16; row++) {
|
||||
int srcY = srcStartY + row;
|
||||
int destY = config.y + (int)(srcY * scale);
|
||||
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
|
||||
for (int col = 0; col < 16; col++) {
|
||||
int srcX = srcStartX + col;
|
||||
int destX = config.x + (int)(srcX * scale);
|
||||
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
|
||||
int blockX = (col < 8) ? 0 : 1;
|
||||
int blockY = (row < 8) ? 0 : 1;
|
||||
int blockIndex = blockY * 2 + blockX;
|
||||
int pixelIndex = (row % 8) * 8 + (col % 8);
|
||||
int blockOffset = blockIndex * 64;
|
||||
uint8_t r = imageInfo.m_pMCUBufR[blockOffset + pixelIndex];
|
||||
uint8_t g = imageInfo.m_pMCUBufG[blockOffset + pixelIndex];
|
||||
uint8_t b = imageInfo.m_pMCUBufB[blockOffset + pixelIndex];
|
||||
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
|
||||
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
|
||||
if (dithered > 3) dithered = 3;
|
||||
drawPixelWithRenderMode(renderer, destX, destY, dithered);
|
||||
if (caching) cache.setPixel(destX, destY, dithered);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
mcuX++;
|
||||
if (mcuX >= imageInfo.m_MCUSPerRow) {
|
||||
mcuX = 0;
|
||||
mcuY++;
|
||||
}
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [JPG] Decoding complete\n", millis());
|
||||
file.close();
|
||||
|
||||
// Write cache file if caching was enabled
|
||||
if (caching) {
|
||||
cache.writeToFile(config.cachePath);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
unsigned char JpegToFramebufferConverter::jpegReadCallback(unsigned char* pBuf, unsigned char buf_size,
|
||||
unsigned char* pBytes_actually_read, void* pCallback_data) {
|
||||
JpegContext* context = reinterpret_cast<JpegContext*>(pCallback_data);
|
||||
|
||||
if (context->bufferPos >= context->bufferFilled) {
|
||||
int readCount = context->file.read(context->buffer, sizeof(context->buffer));
|
||||
if (readCount <= 0) {
|
||||
*pBytes_actually_read = 0;
|
||||
return 0;
|
||||
}
|
||||
context->bufferFilled = readCount;
|
||||
context->bufferPos = 0;
|
||||
}
|
||||
|
||||
unsigned int bytesAvailable = context->bufferFilled - context->bufferPos;
|
||||
unsigned int bytesToCopy = (bytesAvailable < buf_size) ? bytesAvailable : buf_size;
|
||||
|
||||
memcpy(pBuf, &context->buffer[context->bufferPos], bytesToCopy);
|
||||
context->bufferPos += bytesToCopy;
|
||||
*pBytes_actually_read = bytesToCopy;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool JpegToFramebufferConverter::supportsFormat(const std::string& extension) {
|
||||
std::string ext = extension;
|
||||
for (auto& c : ext) {
|
||||
c = tolower(c);
|
||||
}
|
||||
return (ext == ".jpg" || ext == ".jpeg");
|
||||
}
|
||||
24
lib/Epub/Epub/converters/JpegToFramebufferConverter.h
Normal file
24
lib/Epub/Epub/converters/JpegToFramebufferConverter.h
Normal file
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "ImageToFramebufferDecoder.h"
|
||||
|
||||
class JpegToFramebufferConverter final : public ImageToFramebufferDecoder {
|
||||
public:
|
||||
static bool getDimensionsStatic(const std::string& imagePath, ImageDimensions& out);
|
||||
|
||||
bool decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, const RenderConfig& config) override;
|
||||
|
||||
bool getDimensions(const std::string& imagePath, ImageDimensions& dims) const override {
|
||||
return getDimensionsStatic(imagePath, dims);
|
||||
}
|
||||
|
||||
static bool supportsFormat(const std::string& extension);
|
||||
const char* getFormatName() const override { return "JPEG"; }
|
||||
|
||||
private:
|
||||
static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size,
|
||||
unsigned char* pBytes_actually_read, void* pCallback_data);
|
||||
};
|
||||
85
lib/Epub/Epub/converters/PixelCache.h
Normal file
85
lib/Epub/Epub/converters/PixelCache.h
Normal file
@@ -0,0 +1,85 @@
|
||||
#pragma once
|
||||
|
||||
#include <HardwareSerial.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <SdFat.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
// Cache buffer for storing 2-bit pixels (4 levels) during decode.
|
||||
// Packs 4 pixels per byte, MSB first.
|
||||
struct PixelCache {
|
||||
uint8_t* buffer;
|
||||
int width;
|
||||
int height;
|
||||
int bytesPerRow;
|
||||
int originX; // config.x - to convert screen coords to cache coords
|
||||
int originY; // config.y
|
||||
|
||||
PixelCache() : buffer(nullptr), width(0), height(0), bytesPerRow(0), originX(0), originY(0) {}
|
||||
PixelCache(const PixelCache&) = delete;
|
||||
PixelCache& operator=(const PixelCache&) = delete;
|
||||
|
||||
static constexpr size_t MAX_CACHE_BYTES = 256 * 1024; // 256KB limit for embedded targets
|
||||
|
||||
bool allocate(int w, int h, int ox, int oy) {
|
||||
width = w;
|
||||
height = h;
|
||||
originX = ox;
|
||||
originY = oy;
|
||||
bytesPerRow = (w + 3) / 4; // 2 bits per pixel, 4 pixels per byte
|
||||
size_t bufferSize = (size_t)bytesPerRow * h;
|
||||
if (bufferSize > MAX_CACHE_BYTES) {
|
||||
Serial.printf("[%lu] [IMG] Cache buffer too large: %d bytes for %dx%d (limit %d)\n", millis(), bufferSize, w, h,
|
||||
MAX_CACHE_BYTES);
|
||||
return false;
|
||||
}
|
||||
buffer = (uint8_t*)malloc(bufferSize);
|
||||
if (buffer) {
|
||||
memset(buffer, 0, bufferSize);
|
||||
Serial.printf("[%lu] [IMG] Allocated cache buffer: %d bytes for %dx%d\n", millis(), bufferSize, w, h);
|
||||
}
|
||||
return buffer != nullptr;
|
||||
}
|
||||
|
||||
void setPixel(int screenX, int screenY, uint8_t value) {
|
||||
if (!buffer) return;
|
||||
int localX = screenX - originX;
|
||||
int localY = screenY - originY;
|
||||
if (localX < 0 || localX >= width || localY < 0 || localY >= height) return;
|
||||
|
||||
int byteIdx = localY * bytesPerRow + localX / 4;
|
||||
int bitShift = 6 - (localX % 4) * 2; // MSB first: pixel 0 at bits 6-7
|
||||
buffer[byteIdx] = (buffer[byteIdx] & ~(0x03 << bitShift)) | ((value & 0x03) << bitShift);
|
||||
}
|
||||
|
||||
bool writeToFile(const std::string& cachePath) {
|
||||
if (!buffer) return false;
|
||||
|
||||
FsFile cacheFile;
|
||||
if (!Storage.openFileForWrite("IMG", cachePath, cacheFile)) {
|
||||
Serial.printf("[%lu] [IMG] Failed to open cache file for writing: %s\n", millis(), cachePath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
uint16_t w = width;
|
||||
uint16_t h = height;
|
||||
cacheFile.write(&w, 2);
|
||||
cacheFile.write(&h, 2);
|
||||
cacheFile.write(buffer, bytesPerRow * height);
|
||||
cacheFile.close();
|
||||
|
||||
Serial.printf("[%lu] [IMG] Cache written: %s (%dx%d, %d bytes)\n", millis(), cachePath.c_str(), width, height,
|
||||
4 + bytesPerRow * height);
|
||||
return true;
|
||||
}
|
||||
|
||||
~PixelCache() {
|
||||
if (buffer) {
|
||||
free(buffer);
|
||||
buffer = nullptr;
|
||||
}
|
||||
}
|
||||
};
|
||||
364
lib/Epub/Epub/converters/PngToFramebufferConverter.cpp
Normal file
364
lib/Epub/Epub/converters/PngToFramebufferConverter.cpp
Normal file
@@ -0,0 +1,364 @@
|
||||
#include "PngToFramebufferConverter.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <HardwareSerial.h>
|
||||
#include <PNGdec.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <SdFat.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <new>
|
||||
|
||||
#include "DitherUtils.h"
|
||||
#include "PixelCache.h"
|
||||
|
||||
namespace {
|
||||
|
||||
// Context struct passed through PNGdec callbacks to avoid global mutable state.
|
||||
// The draw callback receives this via pDraw->pUser (set by png.decode()).
|
||||
// The file I/O callbacks receive the FsFile* via pFile->fHandle (set by pngOpen()).
|
||||
struct PngContext {
|
||||
GfxRenderer* renderer;
|
||||
const RenderConfig* config;
|
||||
int screenWidth;
|
||||
int screenHeight;
|
||||
|
||||
// Scaling state
|
||||
float scale;
|
||||
int srcWidth;
|
||||
int srcHeight;
|
||||
int dstWidth;
|
||||
int dstHeight;
|
||||
int lastDstY; // Track last rendered destination Y to avoid duplicates
|
||||
|
||||
PixelCache cache;
|
||||
bool caching;
|
||||
|
||||
uint8_t* grayLineBuffer;
|
||||
|
||||
PngContext()
|
||||
: renderer(nullptr),
|
||||
config(nullptr),
|
||||
screenWidth(0),
|
||||
screenHeight(0),
|
||||
scale(1.0f),
|
||||
srcWidth(0),
|
||||
srcHeight(0),
|
||||
dstWidth(0),
|
||||
dstHeight(0),
|
||||
lastDstY(-1),
|
||||
caching(false),
|
||||
grayLineBuffer(nullptr) {}
|
||||
};
|
||||
|
||||
// File I/O callbacks use pFile->fHandle to access the FsFile*,
|
||||
// avoiding the need for global file state.
|
||||
void* pngOpenWithHandle(const char* filename, int32_t* size) {
|
||||
FsFile* f = new FsFile();
|
||||
if (!Storage.openFileForRead("PNG", std::string(filename), *f)) {
|
||||
delete f;
|
||||
return nullptr;
|
||||
}
|
||||
*size = f->size();
|
||||
return f;
|
||||
}
|
||||
|
||||
void pngCloseWithHandle(void* handle) {
|
||||
FsFile* f = reinterpret_cast<FsFile*>(handle);
|
||||
if (f) {
|
||||
f->close();
|
||||
delete f;
|
||||
}
|
||||
}
|
||||
|
||||
int32_t pngReadWithHandle(PNGFILE* pFile, uint8_t* pBuf, int32_t len) {
|
||||
FsFile* f = reinterpret_cast<FsFile*>(pFile->fHandle);
|
||||
if (!f) return 0;
|
||||
return f->read(pBuf, len);
|
||||
}
|
||||
|
||||
int32_t pngSeekWithHandle(PNGFILE* pFile, int32_t pos) {
|
||||
FsFile* f = reinterpret_cast<FsFile*>(pFile->fHandle);
|
||||
if (!f) return -1;
|
||||
return f->seek(pos);
|
||||
}
|
||||
|
||||
// The PNG decoder (PNGdec) is ~42 KB due to internal zlib decompression buffers.
|
||||
// We heap-allocate it on demand rather than using a static instance, so this memory
|
||||
// is only consumed while actually decoding/querying PNG images. This is critical on
|
||||
// the ESP32-C3 where total RAM is ~320 KB.
|
||||
constexpr size_t PNG_DECODER_APPROX_SIZE = 44 * 1024; // ~42 KB + overhead
|
||||
constexpr size_t MIN_FREE_HEAP_FOR_PNG = PNG_DECODER_APPROX_SIZE + 16 * 1024; // decoder + 16 KB headroom
|
||||
|
||||
// Convert entire source line to grayscale with alpha blending to white background.
|
||||
// For indexed PNGs with tRNS chunk, alpha values are stored at palette[768] onwards.
|
||||
// Processing the whole line at once improves cache locality and reduces per-pixel overhead.
|
||||
void convertLineToGray(uint8_t* pPixels, uint8_t* grayLine, int width, int pixelType, uint8_t* palette, int hasAlpha) {
|
||||
switch (pixelType) {
|
||||
case PNG_PIXEL_GRAYSCALE:
|
||||
memcpy(grayLine, pPixels, width);
|
||||
break;
|
||||
|
||||
case PNG_PIXEL_TRUECOLOR:
|
||||
for (int x = 0; x < width; x++) {
|
||||
uint8_t* p = &pPixels[x * 3];
|
||||
grayLine[x] = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
|
||||
}
|
||||
break;
|
||||
|
||||
case PNG_PIXEL_INDEXED:
|
||||
if (palette) {
|
||||
if (hasAlpha) {
|
||||
for (int x = 0; x < width; x++) {
|
||||
uint8_t idx = pPixels[x];
|
||||
uint8_t* p = &palette[idx * 3];
|
||||
uint8_t gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
|
||||
uint8_t alpha = palette[768 + idx];
|
||||
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
|
||||
}
|
||||
} else {
|
||||
for (int x = 0; x < width; x++) {
|
||||
uint8_t* p = &palette[pPixels[x] * 3];
|
||||
grayLine[x] = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
memcpy(grayLine, pPixels, width);
|
||||
}
|
||||
break;
|
||||
|
||||
case PNG_PIXEL_GRAY_ALPHA:
|
||||
for (int x = 0; x < width; x++) {
|
||||
uint8_t gray = pPixels[x * 2];
|
||||
uint8_t alpha = pPixels[x * 2 + 1];
|
||||
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
|
||||
}
|
||||
break;
|
||||
|
||||
case PNG_PIXEL_TRUECOLOR_ALPHA:
|
||||
for (int x = 0; x < width; x++) {
|
||||
uint8_t* p = &pPixels[x * 4];
|
||||
uint8_t gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
|
||||
uint8_t alpha = p[3];
|
||||
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
memset(grayLine, 128, width);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
int pngDrawCallback(PNGDRAW* pDraw) {
|
||||
PngContext* ctx = reinterpret_cast<PngContext*>(pDraw->pUser);
|
||||
if (!ctx || !ctx->config || !ctx->renderer || !ctx->grayLineBuffer) return 0;
|
||||
|
||||
int srcY = pDraw->y;
|
||||
int srcWidth = ctx->srcWidth;
|
||||
|
||||
// Calculate destination Y with scaling
|
||||
int dstY = (int)(srcY * ctx->scale);
|
||||
|
||||
// Skip if we already rendered this destination row (multiple source rows map to same dest)
|
||||
if (dstY == ctx->lastDstY) return 1;
|
||||
ctx->lastDstY = dstY;
|
||||
|
||||
// Check bounds
|
||||
if (dstY >= ctx->dstHeight) return 1;
|
||||
|
||||
int outY = ctx->config->y + dstY;
|
||||
if (outY >= ctx->screenHeight) return 1;
|
||||
|
||||
// Convert entire source line to grayscale (improves cache locality)
|
||||
convertLineToGray(pDraw->pPixels, ctx->grayLineBuffer, srcWidth, pDraw->iPixelType, pDraw->pPalette,
|
||||
pDraw->iHasAlpha);
|
||||
|
||||
// Render scaled row using Bresenham-style integer stepping (no floating-point division)
|
||||
int dstWidth = ctx->dstWidth;
|
||||
int outXBase = ctx->config->x;
|
||||
int screenWidth = ctx->screenWidth;
|
||||
bool useDithering = ctx->config->useDithering;
|
||||
bool caching = ctx->caching;
|
||||
|
||||
int srcX = 0;
|
||||
int error = 0;
|
||||
|
||||
for (int dstX = 0; dstX < dstWidth; dstX++) {
|
||||
int outX = outXBase + dstX;
|
||||
if (outX < screenWidth) {
|
||||
uint8_t gray = ctx->grayLineBuffer[srcX];
|
||||
|
||||
uint8_t ditheredGray;
|
||||
if (useDithering) {
|
||||
ditheredGray = applyBayerDither4Level(gray, outX, outY);
|
||||
} else {
|
||||
ditheredGray = gray / 85;
|
||||
if (ditheredGray > 3) ditheredGray = 3;
|
||||
}
|
||||
drawPixelWithRenderMode(*ctx->renderer, outX, outY, ditheredGray);
|
||||
if (caching) ctx->cache.setPixel(outX, outY, ditheredGray);
|
||||
}
|
||||
|
||||
// Bresenham-style stepping: advance srcX based on ratio srcWidth/dstWidth
|
||||
error += srcWidth;
|
||||
while (error >= dstWidth) {
|
||||
error -= dstWidth;
|
||||
srcX++;
|
||||
}
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool PngToFramebufferConverter::getDimensionsStatic(const std::string& imagePath, ImageDimensions& out) {
|
||||
size_t freeHeap = ESP.getFreeHeap();
|
||||
if (freeHeap < MIN_FREE_HEAP_FOR_PNG) {
|
||||
Serial.printf("[%lu] [PNG] Not enough heap for PNG decoder (%u free, need %u)\n", millis(), freeHeap,
|
||||
MIN_FREE_HEAP_FOR_PNG);
|
||||
return false;
|
||||
}
|
||||
|
||||
PNG* png = new (std::nothrow) PNG();
|
||||
if (!png) {
|
||||
Serial.printf("[%lu] [PNG] Failed to allocate PNG decoder for dimensions\n", millis());
|
||||
return false;
|
||||
}
|
||||
|
||||
int rc = png->open(imagePath.c_str(), pngOpenWithHandle, pngCloseWithHandle, pngReadWithHandle, pngSeekWithHandle,
|
||||
nullptr);
|
||||
|
||||
if (rc != 0) {
|
||||
Serial.printf("[%lu] [PNG] Failed to open PNG for dimensions: %d\n", millis(), rc);
|
||||
delete png;
|
||||
return false;
|
||||
}
|
||||
|
||||
out.width = png->getWidth();
|
||||
out.height = png->getHeight();
|
||||
|
||||
png->close();
|
||||
delete png;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer,
|
||||
const RenderConfig& config) {
|
||||
Serial.printf("[%lu] [PNG] Decoding PNG: %s\n", millis(), imagePath.c_str());
|
||||
|
||||
size_t freeHeap = ESP.getFreeHeap();
|
||||
if (freeHeap < MIN_FREE_HEAP_FOR_PNG) {
|
||||
Serial.printf("[%lu] [PNG] Not enough heap for PNG decoder (%u free, need %u)\n", millis(), freeHeap,
|
||||
MIN_FREE_HEAP_FOR_PNG);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Heap-allocate PNG decoder (~42 KB) - freed at end of function
|
||||
PNG* png = new (std::nothrow) PNG();
|
||||
if (!png) {
|
||||
Serial.printf("[%lu] [PNG] Failed to allocate PNG decoder\n", millis());
|
||||
return false;
|
||||
}
|
||||
|
||||
PngContext ctx;
|
||||
ctx.renderer = &renderer;
|
||||
ctx.config = &config;
|
||||
ctx.screenWidth = renderer.getScreenWidth();
|
||||
ctx.screenHeight = renderer.getScreenHeight();
|
||||
|
||||
int rc = png->open(imagePath.c_str(), pngOpenWithHandle, pngCloseWithHandle, pngReadWithHandle, pngSeekWithHandle,
|
||||
pngDrawCallback);
|
||||
if (rc != PNG_SUCCESS) {
|
||||
Serial.printf("[%lu] [PNG] Failed to open PNG: %d\n", millis(), rc);
|
||||
delete png;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!validateImageDimensions(png->getWidth(), png->getHeight(), "PNG")) {
|
||||
png->close();
|
||||
delete png;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate output dimensions
|
||||
ctx.srcWidth = png->getWidth();
|
||||
ctx.srcHeight = png->getHeight();
|
||||
|
||||
if (config.useExactDimensions && config.maxWidth > 0 && config.maxHeight > 0) {
|
||||
// Use exact dimensions as specified (avoids rounding mismatches with pre-calculated sizes)
|
||||
ctx.dstWidth = config.maxWidth;
|
||||
ctx.dstHeight = config.maxHeight;
|
||||
ctx.scale = (float)ctx.dstWidth / ctx.srcWidth;
|
||||
} else {
|
||||
// Calculate scale factor to fit within maxWidth/maxHeight
|
||||
float scaleX = (float)config.maxWidth / ctx.srcWidth;
|
||||
float scaleY = (float)config.maxHeight / ctx.srcHeight;
|
||||
ctx.scale = (scaleX < scaleY) ? scaleX : scaleY;
|
||||
if (ctx.scale > 1.0f) ctx.scale = 1.0f; // Don't upscale
|
||||
|
||||
ctx.dstWidth = (int)(ctx.srcWidth * ctx.scale);
|
||||
ctx.dstHeight = (int)(ctx.srcHeight * ctx.scale);
|
||||
}
|
||||
ctx.lastDstY = -1; // Reset row tracking
|
||||
|
||||
Serial.printf("[%lu] [PNG] PNG %dx%d -> %dx%d (scale %.2f), bpp: %d\n", millis(), ctx.srcWidth, ctx.srcHeight,
|
||||
ctx.dstWidth, ctx.dstHeight, ctx.scale, png->getBpp());
|
||||
|
||||
if (png->getBpp() != 8) {
|
||||
warnUnsupportedFeature("bit depth (" + std::to_string(png->getBpp()) + "bpp)", imagePath);
|
||||
}
|
||||
|
||||
// Allocate grayscale line buffer on demand (~3.2 KB) - freed after decode
|
||||
const size_t grayBufSize = PNG_MAX_BUFFERED_PIXELS / 2;
|
||||
ctx.grayLineBuffer = static_cast<uint8_t*>(malloc(grayBufSize));
|
||||
if (!ctx.grayLineBuffer) {
|
||||
Serial.printf("[%lu] [PNG] Failed to allocate gray line buffer\n", millis());
|
||||
png->close();
|
||||
delete png;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allocate cache buffer using SCALED dimensions
|
||||
ctx.caching = !config.cachePath.empty();
|
||||
if (ctx.caching) {
|
||||
if (!ctx.cache.allocate(ctx.dstWidth, ctx.dstHeight, config.x, config.y)) {
|
||||
Serial.printf("[%lu] [PNG] Failed to allocate cache buffer, continuing without caching\n", millis());
|
||||
ctx.caching = false;
|
||||
}
|
||||
}
|
||||
|
||||
unsigned long decodeStart = millis();
|
||||
rc = png->decode(&ctx, 0);
|
||||
unsigned long decodeTime = millis() - decodeStart;
|
||||
|
||||
free(ctx.grayLineBuffer);
|
||||
ctx.grayLineBuffer = nullptr;
|
||||
|
||||
if (rc != PNG_SUCCESS) {
|
||||
Serial.printf("[%lu] [PNG] Decode failed: %d\n", millis(), rc);
|
||||
png->close();
|
||||
delete png;
|
||||
return false;
|
||||
}
|
||||
|
||||
png->close();
|
||||
delete png;
|
||||
Serial.printf("[%lu] [PNG] PNG decoding complete - render time: %lu ms\n", millis(), decodeTime);
|
||||
|
||||
// Write cache file if caching was enabled and buffer was allocated
|
||||
if (ctx.caching) {
|
||||
ctx.cache.writeToFile(config.cachePath);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PngToFramebufferConverter::supportsFormat(const std::string& extension) {
|
||||
std::string ext = extension;
|
||||
for (auto& c : ext) {
|
||||
c = tolower(c);
|
||||
}
|
||||
return (ext == ".png");
|
||||
}
|
||||
17
lib/Epub/Epub/converters/PngToFramebufferConverter.h
Normal file
17
lib/Epub/Epub/converters/PngToFramebufferConverter.h
Normal file
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
#include "ImageToFramebufferDecoder.h"
|
||||
|
||||
class PngToFramebufferConverter final : public ImageToFramebufferDecoder {
|
||||
public:
|
||||
static bool getDimensionsStatic(const std::string& imagePath, ImageDimensions& out);
|
||||
|
||||
bool decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, const RenderConfig& config) override;
|
||||
|
||||
bool getDimensions(const std::string& imagePath, ImageDimensions& dims) const override {
|
||||
return getDimensionsStatic(imagePath, dims);
|
||||
}
|
||||
|
||||
static bool supportsFormat(const std::string& extension);
|
||||
const char* getFormatName() const override { return "PNG"; }
|
||||
};
|
||||
@@ -1,144 +1,57 @@
|
||||
#include "CssParser.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <string_view>
|
||||
|
||||
namespace {
|
||||
|
||||
// Stack-allocated string buffer to avoid heap reallocations during parsing
|
||||
// Provides string-like interface with fixed capacity
|
||||
struct StackBuffer {
|
||||
static constexpr size_t CAPACITY = 1024;
|
||||
char data[CAPACITY];
|
||||
size_t len = 0;
|
||||
|
||||
void push_back(char c) {
|
||||
if (len < CAPACITY - 1) {
|
||||
data[len++] = c;
|
||||
}
|
||||
}
|
||||
|
||||
void clear() { len = 0; }
|
||||
bool empty() const { return len == 0; }
|
||||
size_t size() const { return len; }
|
||||
|
||||
// Get string view of current content (zero-copy)
|
||||
std::string_view view() const { return std::string_view(data, len); }
|
||||
|
||||
// Convert to string for passing to functions (single allocation)
|
||||
std::string str() const { return std::string(data, len); }
|
||||
};
|
||||
|
||||
// Buffer size for reading CSS files
|
||||
constexpr size_t READ_BUFFER_SIZE = 512;
|
||||
|
||||
// Maximum CSS file size we'll process (prevent memory issues)
|
||||
constexpr size_t MAX_CSS_SIZE = 64 * 1024;
|
||||
// Maximum number of CSS rules to store in the selector map
|
||||
// Prevents unbounded memory growth from pathological CSS files
|
||||
constexpr size_t MAX_RULES = 1500;
|
||||
|
||||
// Minimum free heap required to apply CSS during rendering
|
||||
// If below this threshold, we skip CSS to avoid display artifacts.
|
||||
constexpr size_t MIN_FREE_HEAP_FOR_CSS = 48 * 1024;
|
||||
|
||||
// Maximum length for a single selector string
|
||||
// Prevents parsing of extremely long or malformed selectors
|
||||
constexpr size_t MAX_SELECTOR_LENGTH = 256;
|
||||
|
||||
// Check if character is CSS whitespace
|
||||
bool isCssWhitespace(const char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f'; }
|
||||
|
||||
// Read entire file into string (with size limit)
|
||||
std::string readFileContent(FsFile& file) {
|
||||
std::string content;
|
||||
content.reserve(std::min(static_cast<size_t>(file.size()), MAX_CSS_SIZE));
|
||||
|
||||
char buffer[READ_BUFFER_SIZE];
|
||||
while (file.available() && content.size() < MAX_CSS_SIZE) {
|
||||
const int bytesRead = file.read(buffer, sizeof(buffer));
|
||||
if (bytesRead <= 0) break;
|
||||
content.append(buffer, bytesRead);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
// Remove CSS comments (/* ... */) from content
|
||||
std::string stripComments(const std::string& css) {
|
||||
std::string result;
|
||||
result.reserve(css.size());
|
||||
|
||||
size_t pos = 0;
|
||||
while (pos < css.size()) {
|
||||
// Look for start of comment
|
||||
if (pos + 1 < css.size() && css[pos] == '/' && css[pos + 1] == '*') {
|
||||
// Find end of comment
|
||||
const size_t endPos = css.find("*/", pos + 2);
|
||||
if (endPos == std::string::npos) {
|
||||
// Unterminated comment - skip rest of file
|
||||
break;
|
||||
}
|
||||
pos = endPos + 2;
|
||||
} else {
|
||||
result.push_back(css[pos]);
|
||||
++pos;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Skip @-rules (like @media, @import, @font-face)
|
||||
// Returns position after the @-rule
|
||||
size_t skipAtRule(const std::string& css, const size_t start) {
|
||||
// Find the end - either semicolon (simple @-rule) or matching brace
|
||||
size_t pos = start + 1; // Skip the '@'
|
||||
|
||||
// Skip identifier
|
||||
while (pos < css.size() && (std::isalnum(css[pos]) || css[pos] == '-')) {
|
||||
++pos;
|
||||
}
|
||||
|
||||
// Look for { or ;
|
||||
int braceDepth = 0;
|
||||
while (pos < css.size()) {
|
||||
const char c = css[pos];
|
||||
if (c == '{') {
|
||||
++braceDepth;
|
||||
} else if (c == '}') {
|
||||
--braceDepth;
|
||||
if (braceDepth == 0) {
|
||||
return pos + 1;
|
||||
}
|
||||
} else if (c == ';' && braceDepth == 0) {
|
||||
return pos + 1;
|
||||
}
|
||||
++pos;
|
||||
}
|
||||
return css.size();
|
||||
}
|
||||
|
||||
// Extract next rule from CSS content
|
||||
// Returns true if a rule was found, with selector and body filled
|
||||
bool extractNextRule(const std::string& css, size_t& pos, std::string& selector, std::string& body) {
|
||||
selector.clear();
|
||||
body.clear();
|
||||
|
||||
// Skip whitespace and @-rules until we find a regular rule
|
||||
while (pos < css.size()) {
|
||||
// Skip whitespace
|
||||
while (pos < css.size() && isCssWhitespace(css[pos])) {
|
||||
++pos;
|
||||
}
|
||||
|
||||
if (pos >= css.size()) return false;
|
||||
|
||||
// Handle @-rules iteratively (avoids recursion/stack overflow)
|
||||
if (css[pos] == '@') {
|
||||
pos = skipAtRule(css, pos);
|
||||
continue; // Try again after skipping the @-rule
|
||||
}
|
||||
|
||||
break; // Found start of a regular rule
|
||||
}
|
||||
|
||||
if (pos >= css.size()) return false;
|
||||
|
||||
// Find opening brace
|
||||
const size_t bracePos = css.find('{', pos);
|
||||
if (bracePos == std::string::npos) return false;
|
||||
|
||||
// Extract selector (everything before the brace)
|
||||
selector = css.substr(pos, bracePos - pos);
|
||||
|
||||
// Find matching closing brace
|
||||
int depth = 1;
|
||||
const size_t bodyStart = bracePos + 1;
|
||||
size_t bodyEnd = bodyStart;
|
||||
|
||||
while (bodyEnd < css.size() && depth > 0) {
|
||||
if (css[bodyEnd] == '{')
|
||||
++depth;
|
||||
else if (css[bodyEnd] == '}')
|
||||
--depth;
|
||||
++bodyEnd;
|
||||
}
|
||||
|
||||
// Extract body (between braces)
|
||||
if (bodyEnd > bodyStart) {
|
||||
body = css.substr(bodyStart, bodyEnd - bodyStart - 1);
|
||||
}
|
||||
|
||||
pos = bodyEnd;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
// String utilities implementation
|
||||
@@ -167,6 +80,28 @@ std::string CssParser::normalized(const std::string& s) {
|
||||
return result;
|
||||
}
|
||||
|
||||
void CssParser::normalizedInto(const std::string& s, std::string& out) {
|
||||
out.clear();
|
||||
out.reserve(s.size());
|
||||
|
||||
bool inSpace = true; // Start true to skip leading space
|
||||
for (const char c : s) {
|
||||
if (isCssWhitespace(c)) {
|
||||
if (!inSpace) {
|
||||
out.push_back(' ');
|
||||
inSpace = true;
|
||||
}
|
||||
} else {
|
||||
out.push_back(static_cast<char>(std::tolower(static_cast<unsigned char>(c))));
|
||||
inSpace = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!out.empty() && out.back() == ' ') {
|
||||
out.pop_back();
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<std::string> CssParser::splitOnChar(const std::string& s, const char delimiter) {
|
||||
std::vector<std::string> parts;
|
||||
size_t start = 0;
|
||||
@@ -290,129 +225,98 @@ CssLength CssParser::interpretLength(const std::string& val) {
|
||||
|
||||
return CssLength{numericValue, unit};
|
||||
}
|
||||
|
||||
int8_t CssParser::interpretSpacing(const std::string& val) {
|
||||
const std::string v = normalized(val);
|
||||
if (v.empty()) return 0;
|
||||
|
||||
// For spacing, we convert to "lines" (discrete units for e-ink)
|
||||
// 1em ≈ 1 line, percentages based on ~30 lines per page
|
||||
|
||||
float multiplier = 0.0f;
|
||||
size_t unitStart = v.size();
|
||||
|
||||
for (size_t i = 0; i < v.size(); ++i) {
|
||||
const char c = v[i];
|
||||
if (!std::isdigit(c) && c != '.' && c != '-' && c != '+') {
|
||||
unitStart = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const std::string numPart = v.substr(0, unitStart);
|
||||
const std::string unitPart = v.substr(unitStart);
|
||||
|
||||
if (unitPart == "em" || unitPart == "rem") {
|
||||
multiplier = 1.0f; // 1em = 1 line
|
||||
} else if (unitPart == "%") {
|
||||
multiplier = 0.3f; // ~30 lines per page, so 10% = 3 lines
|
||||
} else {
|
||||
return 0; // Unsupported unit for spacing
|
||||
}
|
||||
|
||||
char* endPtr = nullptr;
|
||||
const float numericValue = std::strtof(numPart.c_str(), &endPtr);
|
||||
|
||||
if (endPtr == numPart.c_str()) return 0;
|
||||
|
||||
int lines = static_cast<int>(numericValue * multiplier);
|
||||
|
||||
// Clamp to reasonable range (0-2 lines)
|
||||
if (lines < 0) lines = 0;
|
||||
if (lines > 2) lines = 2;
|
||||
|
||||
return static_cast<int8_t>(lines);
|
||||
}
|
||||
|
||||
// Declaration parsing
|
||||
|
||||
void CssParser::parseDeclarationIntoStyle(const std::string& decl, CssStyle& style, std::string& propNameBuf,
|
||||
std::string& propValueBuf) {
|
||||
const size_t colonPos = decl.find(':');
|
||||
if (colonPos == std::string::npos || colonPos == 0) return;
|
||||
|
||||
normalizedInto(decl.substr(0, colonPos), propNameBuf);
|
||||
normalizedInto(decl.substr(colonPos + 1), propValueBuf);
|
||||
|
||||
if (propNameBuf.empty() || propValueBuf.empty()) return;
|
||||
|
||||
if (propNameBuf == "text-align") {
|
||||
style.textAlign = interpretAlignment(propValueBuf);
|
||||
style.defined.textAlign = 1;
|
||||
} else if (propNameBuf == "font-style") {
|
||||
style.fontStyle = interpretFontStyle(propValueBuf);
|
||||
style.defined.fontStyle = 1;
|
||||
} else if (propNameBuf == "font-weight") {
|
||||
style.fontWeight = interpretFontWeight(propValueBuf);
|
||||
style.defined.fontWeight = 1;
|
||||
} else if (propNameBuf == "text-decoration" || propNameBuf == "text-decoration-line") {
|
||||
style.textDecoration = interpretDecoration(propValueBuf);
|
||||
style.defined.textDecoration = 1;
|
||||
} else if (propNameBuf == "text-indent") {
|
||||
style.textIndent = interpretLength(propValueBuf);
|
||||
style.defined.textIndent = 1;
|
||||
} else if (propNameBuf == "margin-top") {
|
||||
style.marginTop = interpretLength(propValueBuf);
|
||||
style.defined.marginTop = 1;
|
||||
} else if (propNameBuf == "margin-bottom") {
|
||||
style.marginBottom = interpretLength(propValueBuf);
|
||||
style.defined.marginBottom = 1;
|
||||
} else if (propNameBuf == "margin-left") {
|
||||
style.marginLeft = interpretLength(propValueBuf);
|
||||
style.defined.marginLeft = 1;
|
||||
} else if (propNameBuf == "margin-right") {
|
||||
style.marginRight = interpretLength(propValueBuf);
|
||||
style.defined.marginRight = 1;
|
||||
} else if (propNameBuf == "margin") {
|
||||
const auto values = splitWhitespace(propValueBuf);
|
||||
if (!values.empty()) {
|
||||
style.marginTop = interpretLength(values[0]);
|
||||
style.marginRight = values.size() >= 2 ? interpretLength(values[1]) : style.marginTop;
|
||||
style.marginBottom = values.size() >= 3 ? interpretLength(values[2]) : style.marginTop;
|
||||
style.marginLeft = values.size() >= 4 ? interpretLength(values[3]) : style.marginRight;
|
||||
style.defined.marginTop = style.defined.marginRight = style.defined.marginBottom = style.defined.marginLeft = 1;
|
||||
}
|
||||
} else if (propNameBuf == "padding-top") {
|
||||
style.paddingTop = interpretLength(propValueBuf);
|
||||
style.defined.paddingTop = 1;
|
||||
} else if (propNameBuf == "padding-bottom") {
|
||||
style.paddingBottom = interpretLength(propValueBuf);
|
||||
style.defined.paddingBottom = 1;
|
||||
} else if (propNameBuf == "padding-left") {
|
||||
style.paddingLeft = interpretLength(propValueBuf);
|
||||
style.defined.paddingLeft = 1;
|
||||
} else if (propNameBuf == "padding-right") {
|
||||
style.paddingRight = interpretLength(propValueBuf);
|
||||
style.defined.paddingRight = 1;
|
||||
} else if (propNameBuf == "padding") {
|
||||
const auto values = splitWhitespace(propValueBuf);
|
||||
if (!values.empty()) {
|
||||
style.paddingTop = interpretLength(values[0]);
|
||||
style.paddingRight = values.size() >= 2 ? interpretLength(values[1]) : style.paddingTop;
|
||||
style.paddingBottom = values.size() >= 3 ? interpretLength(values[2]) : style.paddingTop;
|
||||
style.paddingLeft = values.size() >= 4 ? interpretLength(values[3]) : style.paddingRight;
|
||||
style.defined.paddingTop = style.defined.paddingRight = style.defined.paddingBottom = style.defined.paddingLeft =
|
||||
1;
|
||||
}
|
||||
} else if (propNameBuf == "width") {
|
||||
style.width = interpretLength(propValueBuf);
|
||||
style.defined.width = 1;
|
||||
}
|
||||
}
|
||||
|
||||
CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
|
||||
CssStyle style;
|
||||
std::string propNameBuf;
|
||||
std::string propValueBuf;
|
||||
|
||||
// Split declarations by semicolon
|
||||
const auto declarations = splitOnChar(declBlock, ';');
|
||||
|
||||
for (const auto& decl : declarations) {
|
||||
// Find colon separator
|
||||
const size_t colonPos = decl.find(':');
|
||||
if (colonPos == std::string::npos || colonPos == 0) continue;
|
||||
|
||||
std::string propName = normalized(decl.substr(0, colonPos));
|
||||
std::string propValue = normalized(decl.substr(colonPos + 1));
|
||||
|
||||
if (propName.empty() || propValue.empty()) continue;
|
||||
|
||||
// Match property and set value
|
||||
if (propName == "text-align") {
|
||||
style.textAlign = interpretAlignment(propValue);
|
||||
style.defined.textAlign = 1;
|
||||
} else if (propName == "font-style") {
|
||||
style.fontStyle = interpretFontStyle(propValue);
|
||||
style.defined.fontStyle = 1;
|
||||
} else if (propName == "font-weight") {
|
||||
style.fontWeight = interpretFontWeight(propValue);
|
||||
style.defined.fontWeight = 1;
|
||||
} else if (propName == "text-decoration" || propName == "text-decoration-line") {
|
||||
style.textDecoration = interpretDecoration(propValue);
|
||||
style.defined.textDecoration = 1;
|
||||
} else if (propName == "text-indent") {
|
||||
style.textIndent = interpretLength(propValue);
|
||||
style.defined.textIndent = 1;
|
||||
} else if (propName == "margin-top") {
|
||||
style.marginTop = interpretLength(propValue);
|
||||
style.defined.marginTop = 1;
|
||||
} else if (propName == "margin-bottom") {
|
||||
style.marginBottom = interpretLength(propValue);
|
||||
style.defined.marginBottom = 1;
|
||||
} else if (propName == "margin-left") {
|
||||
style.marginLeft = interpretLength(propValue);
|
||||
style.defined.marginLeft = 1;
|
||||
} else if (propName == "margin-right") {
|
||||
style.marginRight = interpretLength(propValue);
|
||||
style.defined.marginRight = 1;
|
||||
} else if (propName == "margin") {
|
||||
// Shorthand: 1-4 values for top, right, bottom, left
|
||||
const auto values = splitWhitespace(propValue);
|
||||
if (!values.empty()) {
|
||||
style.marginTop = interpretLength(values[0]);
|
||||
style.marginRight = values.size() >= 2 ? interpretLength(values[1]) : style.marginTop;
|
||||
style.marginBottom = values.size() >= 3 ? interpretLength(values[2]) : style.marginTop;
|
||||
style.marginLeft = values.size() >= 4 ? interpretLength(values[3]) : style.marginRight;
|
||||
style.defined.marginTop = style.defined.marginRight = style.defined.marginBottom = style.defined.marginLeft = 1;
|
||||
}
|
||||
} else if (propName == "padding-top") {
|
||||
style.paddingTop = interpretLength(propValue);
|
||||
style.defined.paddingTop = 1;
|
||||
} else if (propName == "padding-bottom") {
|
||||
style.paddingBottom = interpretLength(propValue);
|
||||
style.defined.paddingBottom = 1;
|
||||
} else if (propName == "padding-left") {
|
||||
style.paddingLeft = interpretLength(propValue);
|
||||
style.defined.paddingLeft = 1;
|
||||
} else if (propName == "padding-right") {
|
||||
style.paddingRight = interpretLength(propValue);
|
||||
style.defined.paddingRight = 1;
|
||||
} else if (propName == "padding") {
|
||||
// Shorthand: 1-4 values for top, right, bottom, left
|
||||
const auto values = splitWhitespace(propValue);
|
||||
if (!values.empty()) {
|
||||
style.paddingTop = interpretLength(values[0]);
|
||||
style.paddingRight = values.size() >= 2 ? interpretLength(values[1]) : style.paddingTop;
|
||||
style.paddingBottom = values.size() >= 3 ? interpretLength(values[2]) : style.paddingTop;
|
||||
style.paddingLeft = values.size() >= 4 ? interpretLength(values[3]) : style.paddingRight;
|
||||
style.defined.paddingTop = style.defined.paddingRight = style.defined.paddingBottom =
|
||||
style.defined.paddingLeft = 1;
|
||||
size_t start = 0;
|
||||
for (size_t i = 0; i <= declBlock.size(); ++i) {
|
||||
if (i == declBlock.size() || declBlock[i] == ';') {
|
||||
if (i > start) {
|
||||
const size_t len = i - start;
|
||||
std::string decl = declBlock.substr(start, len);
|
||||
if (!decl.empty()) {
|
||||
parseDeclarationIntoStyle(decl, style, propNameBuf, propValueBuf);
|
||||
}
|
||||
}
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,20 +325,33 @@ CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
|
||||
|
||||
// Rule processing
|
||||
|
||||
void CssParser::processRuleBlock(const std::string& selectorGroup, const std::string& declarations) {
|
||||
const CssStyle style = parseDeclarations(declarations);
|
||||
|
||||
// Only store if any properties were set
|
||||
if (!style.defined.anySet()) return;
|
||||
void CssParser::processRuleBlockWithStyle(const std::string& selectorGroup, const CssStyle& style) {
|
||||
// Check if we've reached the rule limit before processing
|
||||
if (rulesBySelector_.size() >= MAX_RULES) {
|
||||
LOG_DBG("CSS", "Reached max rules limit (%zu), stopping CSS parsing", MAX_RULES);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle comma-separated selectors
|
||||
const auto selectors = splitOnChar(selectorGroup, ',');
|
||||
|
||||
for (const auto& sel : selectors) {
|
||||
// Validate selector length before processing
|
||||
if (sel.size() > MAX_SELECTOR_LENGTH) {
|
||||
LOG_DBG("CSS", "Selector too long (%zu > %zu), skipping", sel.size(), MAX_SELECTOR_LENGTH);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normalize the selector
|
||||
std::string key = normalized(sel);
|
||||
if (key.empty()) continue;
|
||||
|
||||
// Skip if this would exceed the rule limit
|
||||
if (rulesBySelector_.size() >= MAX_RULES) {
|
||||
LOG_DBG("CSS", "Reached max rules limit, stopping selector processing");
|
||||
return;
|
||||
}
|
||||
|
||||
// Store or merge with existing
|
||||
auto it = rulesBySelector_.find(key);
|
||||
if (it != rulesBySelector_.end()) {
|
||||
@@ -453,30 +370,158 @@ bool CssParser::loadFromStream(FsFile& source) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read file content
|
||||
const std::string content = readFileContent(source);
|
||||
if (content.empty()) {
|
||||
return true; // Empty file is valid
|
||||
size_t totalRead = 0;
|
||||
|
||||
// Use stack-allocated buffers for parsing to avoid heap reallocations
|
||||
StackBuffer selector;
|
||||
StackBuffer declBuffer;
|
||||
// Keep these as std::string since they're passed by reference to parseDeclarationIntoStyle
|
||||
std::string propNameBuf;
|
||||
std::string propValueBuf;
|
||||
|
||||
bool inComment = false;
|
||||
bool maybeSlash = false;
|
||||
bool prevStar = false;
|
||||
|
||||
bool inAtRule = false;
|
||||
int atDepth = 0;
|
||||
|
||||
int bodyDepth = 0;
|
||||
bool skippingRule = false;
|
||||
CssStyle currentStyle;
|
||||
|
||||
auto handleChar = [&](const char c) {
|
||||
if (inAtRule) {
|
||||
if (c == '{') {
|
||||
++atDepth;
|
||||
} else if (c == '}') {
|
||||
if (atDepth > 0) --atDepth;
|
||||
if (atDepth == 0) inAtRule = false;
|
||||
} else if (c == ';' && atDepth == 0) {
|
||||
inAtRule = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (bodyDepth == 0) {
|
||||
if (selector.empty() && isCssWhitespace(c)) {
|
||||
return;
|
||||
}
|
||||
if (c == '@' && selector.empty()) {
|
||||
inAtRule = true;
|
||||
atDepth = 0;
|
||||
return;
|
||||
}
|
||||
if (c == '{') {
|
||||
bodyDepth = 1;
|
||||
currentStyle = CssStyle{};
|
||||
declBuffer.clear();
|
||||
if (selector.size() > MAX_SELECTOR_LENGTH * 4) {
|
||||
skippingRule = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
selector.push_back(c);
|
||||
return;
|
||||
}
|
||||
|
||||
// bodyDepth > 0
|
||||
if (c == '{') {
|
||||
++bodyDepth;
|
||||
return;
|
||||
}
|
||||
if (c == '}') {
|
||||
--bodyDepth;
|
||||
if (bodyDepth == 0) {
|
||||
if (!skippingRule && !declBuffer.empty()) {
|
||||
parseDeclarationIntoStyle(declBuffer.str(), currentStyle, propNameBuf, propValueBuf);
|
||||
}
|
||||
if (!skippingRule) {
|
||||
processRuleBlockWithStyle(selector.str(), currentStyle);
|
||||
}
|
||||
selector.clear();
|
||||
declBuffer.clear();
|
||||
skippingRule = false;
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (bodyDepth > 1) {
|
||||
return;
|
||||
}
|
||||
if (!skippingRule) {
|
||||
if (c == ';') {
|
||||
if (!declBuffer.empty()) {
|
||||
parseDeclarationIntoStyle(declBuffer.str(), currentStyle, propNameBuf, propValueBuf);
|
||||
declBuffer.clear();
|
||||
}
|
||||
} else {
|
||||
declBuffer.push_back(c);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
char buffer[READ_BUFFER_SIZE];
|
||||
while (source.available()) {
|
||||
int bytesRead = source.read(buffer, sizeof(buffer));
|
||||
if (bytesRead <= 0) break;
|
||||
|
||||
totalRead += static_cast<size_t>(bytesRead);
|
||||
|
||||
for (int i = 0; i < bytesRead; ++i) {
|
||||
const char c = buffer[i];
|
||||
|
||||
if (inComment) {
|
||||
if (prevStar && c == '/') {
|
||||
inComment = false;
|
||||
prevStar = false;
|
||||
continue;
|
||||
}
|
||||
prevStar = c == '*';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (maybeSlash) {
|
||||
if (c == '*') {
|
||||
inComment = true;
|
||||
maybeSlash = false;
|
||||
prevStar = false;
|
||||
continue;
|
||||
}
|
||||
handleChar('/');
|
||||
maybeSlash = false;
|
||||
// fall through to process current char
|
||||
}
|
||||
|
||||
if (c == '/') {
|
||||
maybeSlash = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
handleChar(c);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove comments
|
||||
const std::string cleaned = stripComments(content);
|
||||
|
||||
// Parse rules
|
||||
size_t pos = 0;
|
||||
std::string selector, body;
|
||||
|
||||
while (extractNextRule(cleaned, pos, selector, body)) {
|
||||
processRuleBlock(selector, body);
|
||||
if (maybeSlash) {
|
||||
handleChar('/');
|
||||
}
|
||||
|
||||
LOG_DBG("CSS", "Parsed %zu rules", rulesBySelector_.size());
|
||||
LOG_DBG("CSS", "Parsed %zu rules from %zu bytes", rulesBySelector_.size(), totalRead);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Style resolution
|
||||
|
||||
CssStyle CssParser::resolveStyle(const std::string& tagName, const std::string& classAttr) const {
|
||||
static bool lowHeapWarningLogged = false;
|
||||
if (ESP.getFreeHeap() < MIN_FREE_HEAP_FOR_CSS) {
|
||||
if (!lowHeapWarningLogged) {
|
||||
lowHeapWarningLogged = true;
|
||||
LOG_DBG("CSS", "Warning: low heap (%u bytes) below MIN_FREE_HEAP_FOR_CSS (%u), returning empty style",
|
||||
ESP.getFreeHeap(), static_cast<unsigned>(MIN_FREE_HEAP_FOR_CSS));
|
||||
}
|
||||
return CssStyle{};
|
||||
}
|
||||
CssStyle result;
|
||||
const std::string tag = normalized(tagName);
|
||||
|
||||
@@ -521,9 +566,17 @@ CssStyle CssParser::parseInlineStyle(const std::string& styleValue) { return par
|
||||
|
||||
// Cache format version - increment when format changes
|
||||
constexpr uint8_t CSS_CACHE_VERSION = 2;
|
||||
constexpr char rulesCache[] = "/css_rules.cache";
|
||||
|
||||
bool CssParser::saveToCache(FsFile& file) const {
|
||||
if (!file) {
|
||||
bool CssParser::hasCache() const { return Storage.exists((cachePath + rulesCache).c_str()); }
|
||||
|
||||
bool CssParser::saveToCache() const {
|
||||
if (cachePath.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
FsFile file;
|
||||
if (!Storage.openFileForWrite("CSS", cachePath + rulesCache, file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -583,11 +636,17 @@ bool CssParser::saveToCache(FsFile& file) const {
|
||||
}
|
||||
|
||||
LOG_DBG("CSS", "Saved %u rules to cache", ruleCount);
|
||||
file.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool CssParser::loadFromCache(FsFile& file) {
|
||||
if (!file) {
|
||||
bool CssParser::loadFromCache() {
|
||||
if (cachePath.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
FsFile file;
|
||||
if (!Storage.openFileForRead("CSS", cachePath + rulesCache, file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -598,12 +657,14 @@ bool CssParser::loadFromCache(FsFile& file) {
|
||||
uint8_t version = 0;
|
||||
if (file.read(&version, 1) != 1 || version != CSS_CACHE_VERSION) {
|
||||
LOG_DBG("CSS", "Cache version mismatch (got %u, expected %u)", version, CSS_CACHE_VERSION);
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read rule count
|
||||
uint16_t ruleCount = 0;
|
||||
if (file.read(&ruleCount, sizeof(ruleCount)) != sizeof(ruleCount)) {
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -613,6 +674,7 @@ bool CssParser::loadFromCache(FsFile& file) {
|
||||
uint16_t selectorLen = 0;
|
||||
if (file.read(&selectorLen, sizeof(selectorLen)) != sizeof(selectorLen)) {
|
||||
rulesBySelector_.clear();
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -620,6 +682,7 @@ bool CssParser::loadFromCache(FsFile& file) {
|
||||
selector.resize(selectorLen);
|
||||
if (file.read(&selector[0], selectorLen) != selectorLen) {
|
||||
rulesBySelector_.clear();
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -629,24 +692,28 @@ bool CssParser::loadFromCache(FsFile& file) {
|
||||
|
||||
if (file.read(&enumVal, 1) != 1) {
|
||||
rulesBySelector_.clear();
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
style.textAlign = static_cast<CssTextAlign>(enumVal);
|
||||
|
||||
if (file.read(&enumVal, 1) != 1) {
|
||||
rulesBySelector_.clear();
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
style.fontStyle = static_cast<CssFontStyle>(enumVal);
|
||||
|
||||
if (file.read(&enumVal, 1) != 1) {
|
||||
rulesBySelector_.clear();
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
style.fontWeight = static_cast<CssFontWeight>(enumVal);
|
||||
|
||||
if (file.read(&enumVal, 1) != 1) {
|
||||
rulesBySelector_.clear();
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
style.textDecoration = static_cast<CssTextDecoration>(enumVal);
|
||||
@@ -668,6 +735,7 @@ bool CssParser::loadFromCache(FsFile& file) {
|
||||
!readLength(style.marginLeft) || !readLength(style.marginRight) || !readLength(style.paddingTop) ||
|
||||
!readLength(style.paddingBottom) || !readLength(style.paddingLeft) || !readLength(style.paddingRight)) {
|
||||
rulesBySelector_.clear();
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -675,6 +743,7 @@ bool CssParser::loadFromCache(FsFile& file) {
|
||||
uint16_t definedBits = 0;
|
||||
if (file.read(&definedBits, sizeof(definedBits)) != sizeof(definedBits)) {
|
||||
rulesBySelector_.clear();
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
style.defined.textAlign = (definedBits & 1 << 0) != 0;
|
||||
@@ -695,5 +764,6 @@ bool CssParser::loadFromCache(FsFile& file) {
|
||||
}
|
||||
|
||||
LOG_DBG("CSS", "Loaded %u rules from cache", ruleCount);
|
||||
file.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "CssStyle.h"
|
||||
@@ -29,7 +30,7 @@
|
||||
*/
|
||||
class CssParser {
|
||||
public:
|
||||
CssParser() = default;
|
||||
explicit CssParser(std::string cachePath) : cachePath(std::move(cachePath)) {}
|
||||
~CssParser() = default;
|
||||
|
||||
// Non-copyable
|
||||
@@ -76,28 +77,35 @@ class CssParser {
|
||||
*/
|
||||
void clear() { rulesBySelector_.clear(); }
|
||||
|
||||
/**
|
||||
* Check if CSS rules cache file exists
|
||||
*/
|
||||
bool hasCache() const;
|
||||
|
||||
/**
|
||||
* Save parsed CSS rules to a cache file.
|
||||
* @param file Open file handle to write to
|
||||
* @return true if cache was written successfully
|
||||
*/
|
||||
bool saveToCache(FsFile& file) const;
|
||||
bool saveToCache() const;
|
||||
|
||||
/**
|
||||
* Load CSS rules from a cache file.
|
||||
* Clears any existing rules before loading.
|
||||
* @param file Open file handle to read from
|
||||
* @return true if cache was loaded successfully
|
||||
*/
|
||||
bool loadFromCache(FsFile& file);
|
||||
bool loadFromCache();
|
||||
|
||||
private:
|
||||
// Storage: maps normalized selector -> style properties
|
||||
std::unordered_map<std::string, CssStyle> rulesBySelector_;
|
||||
|
||||
std::string cachePath;
|
||||
|
||||
// Internal parsing helpers
|
||||
void processRuleBlock(const std::string& selectorGroup, const std::string& declarations);
|
||||
void processRuleBlockWithStyle(const std::string& selectorGroup, const CssStyle& style);
|
||||
static CssStyle parseDeclarations(const std::string& declBlock);
|
||||
static void parseDeclarationIntoStyle(const std::string& decl, CssStyle& style, std::string& propNameBuf,
|
||||
std::string& propValueBuf);
|
||||
|
||||
// Individual property value parsers
|
||||
static CssTextAlign interpretAlignment(const std::string& val);
|
||||
@@ -105,10 +113,10 @@ class CssParser {
|
||||
static CssFontWeight interpretFontWeight(const std::string& val);
|
||||
static CssTextDecoration interpretDecoration(const std::string& val);
|
||||
static CssLength interpretLength(const std::string& val);
|
||||
static int8_t interpretSpacing(const std::string& val);
|
||||
|
||||
// String utilities
|
||||
static std::string normalized(const std::string& s);
|
||||
static void normalizedInto(const std::string& s, std::string& out);
|
||||
static std::vector<std::string> splitOnChar(const std::string& s, char delimiter);
|
||||
static std::vector<std::string> splitWhitespace(const std::string& s);
|
||||
};
|
||||
|
||||
@@ -69,6 +69,7 @@ struct CssPropertyFlags {
|
||||
uint16_t paddingBottom : 1;
|
||||
uint16_t paddingLeft : 1;
|
||||
uint16_t paddingRight : 1;
|
||||
uint16_t width : 1;
|
||||
|
||||
CssPropertyFlags()
|
||||
: textAlign(0),
|
||||
@@ -83,17 +84,19 @@ struct CssPropertyFlags {
|
||||
paddingTop(0),
|
||||
paddingBottom(0),
|
||||
paddingLeft(0),
|
||||
paddingRight(0) {}
|
||||
paddingRight(0),
|
||||
width(0) {}
|
||||
|
||||
[[nodiscard]] bool anySet() const {
|
||||
return textAlign || fontStyle || fontWeight || textDecoration || textIndent || marginTop || marginBottom ||
|
||||
marginLeft || marginRight || paddingTop || paddingBottom || paddingLeft || paddingRight;
|
||||
marginLeft || marginRight || paddingTop || paddingBottom || paddingLeft || paddingRight || width;
|
||||
}
|
||||
|
||||
void clearAll() {
|
||||
textAlign = fontStyle = fontWeight = textDecoration = textIndent = 0;
|
||||
marginTop = marginBottom = marginLeft = marginRight = 0;
|
||||
paddingTop = paddingBottom = paddingLeft = paddingRight = 0;
|
||||
width = 0;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -115,6 +118,7 @@ struct CssStyle {
|
||||
CssLength paddingBottom; // Padding after
|
||||
CssLength paddingLeft; // Padding left
|
||||
CssLength paddingRight; // Padding right
|
||||
CssLength width; // Element width (used for table columns/cells)
|
||||
|
||||
CssPropertyFlags defined; // Tracks which properties were explicitly set
|
||||
|
||||
@@ -173,6 +177,10 @@ struct CssStyle {
|
||||
paddingRight = base.paddingRight;
|
||||
defined.paddingRight = 1;
|
||||
}
|
||||
if (base.hasWidth()) {
|
||||
width = base.width;
|
||||
defined.width = 1;
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] bool hasTextAlign() const { return defined.textAlign; }
|
||||
@@ -188,6 +196,7 @@ struct CssStyle {
|
||||
[[nodiscard]] bool hasPaddingBottom() const { return defined.paddingBottom; }
|
||||
[[nodiscard]] bool hasPaddingLeft() const { return defined.paddingLeft; }
|
||||
[[nodiscard]] bool hasPaddingRight() const { return defined.paddingRight; }
|
||||
[[nodiscard]] bool hasWidth() const { return defined.width; }
|
||||
|
||||
void reset() {
|
||||
textAlign = CssTextAlign::Left;
|
||||
@@ -197,6 +206,7 @@ struct CssStyle {
|
||||
textIndent = CssLength{};
|
||||
marginTop = marginBottom = marginLeft = marginRight = CssLength{};
|
||||
paddingTop = paddingBottom = paddingLeft = paddingRight = CssLength{};
|
||||
width = CssLength{};
|
||||
defined.clearAll();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,48 +1,84 @@
|
||||
#include "LanguageRegistry.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <vector>
|
||||
|
||||
#include "HyphenationCommon.h"
|
||||
#ifndef OMIT_HYPH_DE
|
||||
#include "generated/hyph-de.trie.h"
|
||||
#endif
|
||||
#ifndef OMIT_HYPH_EN
|
||||
#include "generated/hyph-en.trie.h"
|
||||
#endif
|
||||
#ifndef OMIT_HYPH_ES
|
||||
#include "generated/hyph-es.trie.h"
|
||||
#endif
|
||||
#ifndef OMIT_HYPH_FR
|
||||
#include "generated/hyph-fr.trie.h"
|
||||
#endif
|
||||
#ifndef OMIT_HYPH_IT
|
||||
#include "generated/hyph-it.trie.h"
|
||||
#endif
|
||||
#ifndef OMIT_HYPH_RU
|
||||
#include "generated/hyph-ru.trie.h"
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
#ifndef OMIT_HYPH_EN
|
||||
// English hyphenation patterns (3/3 minimum prefix/suffix length)
|
||||
LanguageHyphenator englishHyphenator(en_us_patterns, isLatinLetter, toLowerLatin, 3, 3);
|
||||
#endif
|
||||
#ifndef OMIT_HYPH_FR
|
||||
LanguageHyphenator frenchHyphenator(fr_patterns, isLatinLetter, toLowerLatin);
|
||||
#endif
|
||||
#ifndef OMIT_HYPH_DE
|
||||
LanguageHyphenator germanHyphenator(de_patterns, isLatinLetter, toLowerLatin);
|
||||
#endif
|
||||
#ifndef OMIT_HYPH_RU
|
||||
LanguageHyphenator russianHyphenator(ru_ru_patterns, isCyrillicLetter, toLowerCyrillic);
|
||||
#endif
|
||||
#ifndef OMIT_HYPH_ES
|
||||
LanguageHyphenator spanishHyphenator(es_patterns, isLatinLetter, toLowerLatin);
|
||||
#endif
|
||||
#ifndef OMIT_HYPH_IT
|
||||
LanguageHyphenator italianHyphenator(it_patterns, isLatinLetter, toLowerLatin);
|
||||
#endif
|
||||
|
||||
using EntryArray = std::array<LanguageEntry, 6>;
|
||||
|
||||
const EntryArray& entries() {
|
||||
static const EntryArray kEntries = {{{"english", "en", &englishHyphenator},
|
||||
{"french", "fr", &frenchHyphenator},
|
||||
{"german", "de", &germanHyphenator},
|
||||
{"russian", "ru", &russianHyphenator},
|
||||
{"spanish", "es", &spanishHyphenator},
|
||||
{"italian", "it", &italianHyphenator}}};
|
||||
return kEntries;
|
||||
const LanguageEntryView entries() {
|
||||
static const std::vector<LanguageEntry> kEntries = {
|
||||
#ifndef OMIT_HYPH_EN
|
||||
{"english", "en", &englishHyphenator},
|
||||
#endif
|
||||
#ifndef OMIT_HYPH_FR
|
||||
{"french", "fr", &frenchHyphenator},
|
||||
#endif
|
||||
#ifndef OMIT_HYPH_DE
|
||||
{"german", "de", &germanHyphenator},
|
||||
#endif
|
||||
#ifndef OMIT_HYPH_RU
|
||||
{"russian", "ru", &russianHyphenator},
|
||||
#endif
|
||||
#ifndef OMIT_HYPH_ES
|
||||
{"spanish", "es", &spanishHyphenator},
|
||||
#endif
|
||||
#ifndef OMIT_HYPH_IT
|
||||
{"italian", "it", &italianHyphenator},
|
||||
#endif
|
||||
};
|
||||
static const LanguageEntryView view{kEntries.data(), kEntries.size()};
|
||||
return view;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const LanguageHyphenator* getLanguageHyphenatorForPrimaryTag(const std::string& primaryTag) {
|
||||
const auto& allEntries = entries();
|
||||
const auto allEntries = entries();
|
||||
const auto it = std::find_if(allEntries.begin(), allEntries.end(),
|
||||
[&primaryTag](const LanguageEntry& entry) { return primaryTag == entry.primaryTag; });
|
||||
return (it != allEntries.end()) ? it->hyphenator : nullptr;
|
||||
}
|
||||
|
||||
LanguageEntryView getLanguageEntries() {
|
||||
const auto& allEntries = entries();
|
||||
return LanguageEntryView{allEntries.data(), allEntries.size()};
|
||||
return entries();
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
#include "ChapterHtmlSlimParser.h"
|
||||
|
||||
#include <FsHelpers.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <expat.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "../../Epub.h"
|
||||
#include "../Page.h"
|
||||
#include "../converters/ImageDecoderFactory.h"
|
||||
#include "../converters/ImageToFramebufferDecoder.h"
|
||||
#include "../htmlEntities.h"
|
||||
|
||||
const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"};
|
||||
@@ -32,8 +38,30 @@ constexpr int NUM_IMAGE_TAGS = sizeof(IMAGE_TAGS) / sizeof(IMAGE_TAGS[0]);
|
||||
const char* SKIP_TAGS[] = {"head"};
|
||||
constexpr int NUM_SKIP_TAGS = sizeof(SKIP_TAGS) / sizeof(SKIP_TAGS[0]);
|
||||
|
||||
// Table tags that are transparent containers (just depth tracking, no special handling)
|
||||
const char* TABLE_TRANSPARENT_TAGS[] = {"thead", "tbody", "tfoot", "colgroup"};
|
||||
constexpr int NUM_TABLE_TRANSPARENT_TAGS = sizeof(TABLE_TRANSPARENT_TAGS) / sizeof(TABLE_TRANSPARENT_TAGS[0]);
|
||||
|
||||
// Table tags to skip entirely (their children produce no useful output)
|
||||
const char* TABLE_SKIP_TAGS[] = {"caption"};
|
||||
constexpr int NUM_TABLE_SKIP_TAGS = sizeof(TABLE_SKIP_TAGS) / sizeof(TABLE_SKIP_TAGS[0]);
|
||||
|
||||
bool isWhitespace(const char c) { return c == ' ' || c == '\r' || c == '\n' || c == '\t'; }
|
||||
|
||||
// Parse an HTML width attribute value into a CssLength.
|
||||
// "200" -> 200px, "50%" -> 50 percent. Returns false if the value can't be parsed.
|
||||
static bool parseHtmlWidthAttr(const char* value, CssLength& out) {
|
||||
char* end = nullptr;
|
||||
const float num = strtof(value, &end);
|
||||
if (end == value || num < 0) return false;
|
||||
if (*end == '%') {
|
||||
out = CssLength(num, CssUnit::Percent);
|
||||
} else {
|
||||
out = CssLength(num, CssUnit::Pixels);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// given the start and end of a tag, check to see if it matches a known tag
|
||||
bool matches(const char* tag_name, const char* possible_tags[], const int possible_tag_count) {
|
||||
for (int i = 0; i < possible_tag_count; i++) {
|
||||
@@ -91,13 +119,37 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() {
|
||||
|
||||
// flush the buffer
|
||||
partWordBuffer[partWordBufferIndex] = '\0';
|
||||
currentTextBlock->addWord(partWordBuffer, fontStyle, false, nextWordContinues);
|
||||
|
||||
// Handle double-encoded entities (e.g. &nbsp; in source -> literal " " after
|
||||
// XML parsing). Common in Wikipedia and other generated EPUBs. Replace with a space so the text
|
||||
// renders cleanly. The space stays within the word, preserving non-breaking behavior.
|
||||
std::string flushedWord(partWordBuffer);
|
||||
size_t entityPos = 0;
|
||||
while ((entityPos = flushedWord.find(" ", entityPos)) != std::string::npos) {
|
||||
flushedWord.replace(entityPos, 6, " ");
|
||||
entityPos += 1;
|
||||
}
|
||||
|
||||
currentTextBlock->addWord(flushedWord, fontStyle, false, nextWordContinues);
|
||||
partWordBufferIndex = 0;
|
||||
nextWordContinues = false;
|
||||
}
|
||||
|
||||
// start a new text block if needed
|
||||
void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) {
|
||||
// When inside a table cell, don't lay out to the page -- insert a forced line break
|
||||
// within the cell's ParsedText so that block elements (p, div, br) create visual breaks.
|
||||
if (inTable) {
|
||||
if (partWordBufferIndex > 0) {
|
||||
flushPartWordBuffer();
|
||||
}
|
||||
if (currentTextBlock && !currentTextBlock->isEmpty()) {
|
||||
currentTextBlock->addLineBreak();
|
||||
}
|
||||
nextWordContinues = false;
|
||||
return;
|
||||
}
|
||||
|
||||
nextWordContinues = false; // New block = new paragraph, no continuation
|
||||
if (currentTextBlock) {
|
||||
// already have a text block running and it is empty - just reuse it
|
||||
@@ -140,46 +192,304 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
centeredBlockStyle.textAlignDefined = true;
|
||||
centeredBlockStyle.alignment = CssTextAlign::Center;
|
||||
|
||||
// Special handling for tables - show placeholder text instead of dropping silently
|
||||
// --- Table handling ---
|
||||
if (strcmp(name, "table") == 0) {
|
||||
// Add placeholder text
|
||||
self->startNewTextBlock(centeredBlockStyle);
|
||||
if (self->inTable) {
|
||||
// Nested table: skip it entirely for v1
|
||||
self->skipUntilDepth = self->depth;
|
||||
self->depth += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Flush any pending content before the table
|
||||
if (self->currentTextBlock && !self->currentTextBlock->isEmpty()) {
|
||||
self->makePages();
|
||||
}
|
||||
|
||||
self->inTable = true;
|
||||
self->tableData.reset(new TableData());
|
||||
|
||||
// Create a safe empty currentTextBlock so character data outside cells
|
||||
// (e.g. whitespace between tags) doesn't crash
|
||||
auto tableBlockStyle = BlockStyle();
|
||||
tableBlockStyle.alignment = CssTextAlign::Left;
|
||||
self->currentTextBlock.reset(new ParsedText(self->extraParagraphSpacing, self->hyphenationEnabled, tableBlockStyle));
|
||||
|
||||
self->italicUntilDepth = min(self->italicUntilDepth, self->depth);
|
||||
// Advance depth before processing character data (like you would for an element with text)
|
||||
self->depth += 1;
|
||||
self->characterData(userData, "[Table omitted]", strlen("[Table omitted]"));
|
||||
|
||||
// Skip table contents (skip until parent as we pre-advanced depth above)
|
||||
self->skipUntilDepth = self->depth - 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS)) {
|
||||
// TODO: Start processing image tags
|
||||
std::string alt = "[Image]";
|
||||
if (atts != nullptr) {
|
||||
for (int i = 0; atts[i]; i += 2) {
|
||||
if (strcmp(atts[i], "alt") == 0) {
|
||||
if (strlen(atts[i + 1]) > 0) {
|
||||
alt = "[Image: " + std::string(atts[i + 1]) + "]";
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Table structure tags (only when inside a table)
|
||||
if (self->inTable) {
|
||||
if (strcmp(name, "tr") == 0) {
|
||||
self->tableData->rows.push_back(TableRow());
|
||||
self->depth += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_DBG("EHP", "Image alt: %s", alt.c_str());
|
||||
// <col> — capture width hint for column sizing
|
||||
if (strcmp(name, "col") == 0) {
|
||||
CssLength widthHint;
|
||||
bool hasHint = false;
|
||||
|
||||
self->startNewTextBlock(centeredBlockStyle);
|
||||
self->italicUntilDepth = min(self->italicUntilDepth, self->depth);
|
||||
// Advance depth before processing character data (like you would for an element with text)
|
||||
self->depth += 1;
|
||||
self->characterData(userData, alt.c_str(), alt.length());
|
||||
// Parse HTML width attribute
|
||||
if (atts != nullptr) {
|
||||
for (int i = 0; atts[i]; i += 2) {
|
||||
if (strcmp(atts[i], "width") == 0) {
|
||||
hasHint = parseHtmlWidthAttr(atts[i + 1], widthHint);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip table contents (skip until parent as we pre-advanced depth above)
|
||||
self->skipUntilDepth = self->depth - 1;
|
||||
return;
|
||||
// CSS width (inline style) overrides HTML attribute
|
||||
if (self->cssParser) {
|
||||
std::string styleAttr;
|
||||
if (atts != nullptr) {
|
||||
for (int i = 0; atts[i]; i += 2) {
|
||||
if (strcmp(atts[i], "style") == 0) {
|
||||
styleAttr = atts[i + 1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!styleAttr.empty()) {
|
||||
CssStyle inlineStyle = CssParser::parseInlineStyle(styleAttr);
|
||||
if (inlineStyle.hasWidth()) {
|
||||
widthHint = inlineStyle.width;
|
||||
hasHint = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasHint) {
|
||||
self->tableData->colWidthHints.push_back(widthHint);
|
||||
} else {
|
||||
// Push a zero-value placeholder to maintain index alignment
|
||||
self->tableData->colWidthHints.push_back(CssLength());
|
||||
}
|
||||
|
||||
self->depth += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (strcmp(name, "td") == 0 || strcmp(name, "th") == 0) {
|
||||
const bool isHeader = strcmp(name, "th") == 0;
|
||||
|
||||
// Parse colspan and width attributes
|
||||
int colspan = 1;
|
||||
CssLength cellWidthHint;
|
||||
bool hasCellWidthHint = false;
|
||||
std::string cellStyleAttr;
|
||||
|
||||
if (atts != nullptr) {
|
||||
for (int i = 0; atts[i]; i += 2) {
|
||||
if (strcmp(atts[i], "colspan") == 0) {
|
||||
colspan = atoi(atts[i + 1]);
|
||||
if (colspan < 1) colspan = 1;
|
||||
} else if (strcmp(atts[i], "width") == 0) {
|
||||
hasCellWidthHint = parseHtmlWidthAttr(atts[i + 1], cellWidthHint);
|
||||
} else if (strcmp(atts[i], "style") == 0) {
|
||||
cellStyleAttr = atts[i + 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CSS width (inline style or stylesheet) overrides HTML attribute
|
||||
if (self->cssParser) {
|
||||
std::string classAttr;
|
||||
if (atts != nullptr) {
|
||||
for (int i = 0; atts[i]; i += 2) {
|
||||
if (strcmp(atts[i], "class") == 0) {
|
||||
classAttr = atts[i + 1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
CssStyle cellCssStyle = self->cssParser->resolveStyle(name, classAttr);
|
||||
if (!cellStyleAttr.empty()) {
|
||||
CssStyle inlineStyle = CssParser::parseInlineStyle(cellStyleAttr);
|
||||
cellCssStyle.applyOver(inlineStyle);
|
||||
}
|
||||
if (cellCssStyle.hasWidth()) {
|
||||
cellWidthHint = cellCssStyle.width;
|
||||
hasCellWidthHint = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure there's a row to add cells to
|
||||
if (self->tableData->rows.empty()) {
|
||||
self->tableData->rows.push_back(TableRow());
|
||||
}
|
||||
|
||||
// Create a new ParsedText for this cell (characterData will flow into it)
|
||||
auto cellBlockStyle = BlockStyle();
|
||||
cellBlockStyle.alignment = CssTextAlign::Left;
|
||||
cellBlockStyle.textAlignDefined = true;
|
||||
// Explicitly disable paragraph indent for table cells
|
||||
cellBlockStyle.textIndent = 0;
|
||||
cellBlockStyle.textIndentDefined = true;
|
||||
self->currentTextBlock.reset(
|
||||
new ParsedText(self->extraParagraphSpacing, self->hyphenationEnabled, cellBlockStyle));
|
||||
self->nextWordContinues = false;
|
||||
|
||||
// Track the cell
|
||||
auto& currentRow = self->tableData->rows.back();
|
||||
currentRow.cells.push_back(TableCell());
|
||||
currentRow.cells.back().isHeader = isHeader;
|
||||
currentRow.cells.back().colspan = colspan;
|
||||
if (hasCellWidthHint) {
|
||||
currentRow.cells.back().widthHint = cellWidthHint;
|
||||
currentRow.cells.back().hasWidthHint = true;
|
||||
}
|
||||
|
||||
// Apply bold for header cells
|
||||
if (isHeader) {
|
||||
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
|
||||
self->updateEffectiveInlineStyle();
|
||||
}
|
||||
|
||||
self->depth += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Transparent table container tags
|
||||
if (matches(name, TABLE_TRANSPARENT_TAGS, NUM_TABLE_TRANSPARENT_TAGS)) {
|
||||
self->depth += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip colgroup, col, caption
|
||||
if (matches(name, TABLE_SKIP_TAGS, NUM_TABLE_SKIP_TAGS)) {
|
||||
self->skipUntilDepth = self->depth;
|
||||
self->depth += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Other tags inside table cells (p, div, span, b, i, etc.) fall through
|
||||
// to the normal handling below. startNewTextBlock is a no-op when inTable.
|
||||
}
|
||||
|
||||
if (matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS)) {
|
||||
std::string src;
|
||||
std::string alt;
|
||||
if (atts != nullptr) {
|
||||
for (int i = 0; atts[i]; i += 2) {
|
||||
if (strcmp(atts[i], "src") == 0) {
|
||||
src = atts[i + 1];
|
||||
} else if (strcmp(atts[i], "alt") == 0) {
|
||||
alt = atts[i + 1];
|
||||
}
|
||||
}
|
||||
|
||||
if (!src.empty()) {
|
||||
LOG_DBG("EHP", "Found image: src=%s", src.c_str());
|
||||
|
||||
{
|
||||
// Resolve the image path relative to the HTML file
|
||||
std::string resolvedPath = FsHelpers::normalisePath(self->contentBase + src);
|
||||
|
||||
// Create a unique filename for the cached image
|
||||
std::string ext;
|
||||
size_t extPos = resolvedPath.rfind('.');
|
||||
if (extPos != std::string::npos) {
|
||||
ext = resolvedPath.substr(extPos);
|
||||
}
|
||||
std::string cachedImagePath = self->imageBasePath + std::to_string(self->imageCounter++) + ext;
|
||||
|
||||
// Extract image to cache file
|
||||
FsFile cachedImageFile;
|
||||
bool extractSuccess = false;
|
||||
if (Storage.openFileForWrite("EHP", cachedImagePath, cachedImageFile)) {
|
||||
extractSuccess = self->epub->readItemContentsToStream(resolvedPath, cachedImageFile, 4096);
|
||||
cachedImageFile.flush();
|
||||
cachedImageFile.close();
|
||||
delay(50); // Give SD card time to sync
|
||||
}
|
||||
|
||||
if (extractSuccess) {
|
||||
// Get image dimensions
|
||||
ImageDimensions dims = {0, 0};
|
||||
ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(cachedImagePath);
|
||||
if (decoder && decoder->getDimensions(cachedImagePath, dims)) {
|
||||
LOG_DBG("EHP", "Image dimensions: %dx%d", dims.width, dims.height);
|
||||
|
||||
// Scale to fit viewport while maintaining aspect ratio
|
||||
int maxWidth = self->viewportWidth;
|
||||
int maxHeight = self->viewportHeight;
|
||||
float scaleX = (dims.width > maxWidth) ? (float)maxWidth / dims.width : 1.0f;
|
||||
float scaleY = (dims.height > maxHeight) ? (float)maxHeight / dims.height : 1.0f;
|
||||
float scale = (scaleX < scaleY) ? scaleX : scaleY;
|
||||
if (scale > 1.0f) scale = 1.0f;
|
||||
|
||||
int displayWidth = (int)(dims.width * scale);
|
||||
int displayHeight = (int)(dims.height * scale);
|
||||
|
||||
LOG_DBG("EHP", "Display size: %dx%d (scale %.2f)", displayWidth, displayHeight, scale);
|
||||
|
||||
// Create page for image - only break if image won't fit remaining space
|
||||
if (self->currentPage && !self->currentPage->elements.empty() &&
|
||||
(self->currentPageNextY + displayHeight > self->viewportHeight)) {
|
||||
self->completePageFn(std::move(self->currentPage));
|
||||
self->currentPage.reset(new Page());
|
||||
if (!self->currentPage) {
|
||||
LOG_ERR("EHP", "Failed to create new page");
|
||||
return;
|
||||
}
|
||||
self->currentPageNextY = 0;
|
||||
} else if (!self->currentPage) {
|
||||
self->currentPage.reset(new Page());
|
||||
if (!self->currentPage) {
|
||||
LOG_ERR("EHP", "Failed to create initial page");
|
||||
return;
|
||||
}
|
||||
self->currentPageNextY = 0;
|
||||
}
|
||||
|
||||
// Create ImageBlock and add to page
|
||||
auto imageBlock = std::make_shared<ImageBlock>(cachedImagePath, displayWidth, displayHeight);
|
||||
if (!imageBlock) {
|
||||
LOG_ERR("EHP", "Failed to create ImageBlock");
|
||||
return;
|
||||
}
|
||||
int xPos = (self->viewportWidth - displayWidth) / 2;
|
||||
auto pageImage = std::make_shared<PageImage>(imageBlock, xPos, self->currentPageNextY);
|
||||
if (!pageImage) {
|
||||
LOG_ERR("EHP", "Failed to create PageImage");
|
||||
return;
|
||||
}
|
||||
self->currentPage->elements.push_back(pageImage);
|
||||
self->currentPageNextY += displayHeight;
|
||||
|
||||
self->depth += 1;
|
||||
return;
|
||||
} else {
|
||||
LOG_ERR("EHP", "Failed to get image dimensions");
|
||||
Storage.remove(cachedImagePath.c_str());
|
||||
}
|
||||
} else {
|
||||
LOG_ERR("EHP", "Failed to extract image");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to alt text if image processing fails
|
||||
if (!alt.empty()) {
|
||||
alt = "[Image: " + alt + "]";
|
||||
self->startNewTextBlock(centeredBlockStyle);
|
||||
self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth);
|
||||
self->depth += 1;
|
||||
self->characterData(userData, alt.c_str(), alt.length());
|
||||
// Skip any child content (skip until parent as we pre-advanced depth above)
|
||||
self->skipUntilDepth = self->depth - 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// No alt text, skip
|
||||
self->skipUntilDepth = self->depth;
|
||||
self->depth += 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (matches(name, SKIP_TAGS, NUM_SKIP_TAGS)) {
|
||||
@@ -408,7 +718,8 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
|
||||
// There should be enough here to build out 1-2 full pages and doing this will free up a lot of
|
||||
// memory.
|
||||
// Spotted when reading Intermezzo, there are some really long text blocks in there.
|
||||
if (self->currentTextBlock->size() > 750) {
|
||||
// Skip this when inside a table - cell content is buffered for later layout.
|
||||
if (!self->inTable && self->currentTextBlock->size() > 750) {
|
||||
LOG_DBG("EHP", "Text block too long, splitting into multiple pages");
|
||||
self->currentTextBlock->layoutAndExtractLines(
|
||||
self->renderer, self->fontId, self->viewportWidth,
|
||||
@@ -446,15 +757,17 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
||||
|
||||
const bool styleWillChange = willPopStyleStack || willClearBold || willClearItalic || willClearUnderline;
|
||||
const bool headerOrBlockTag = isHeaderOrBlock(name);
|
||||
const bool isTableCellTag = strcmp(name, "td") == 0 || strcmp(name, "th") == 0;
|
||||
const bool isTableTag = strcmp(name, "table") == 0;
|
||||
|
||||
// Flush buffer with current style BEFORE any style changes
|
||||
if (self->partWordBufferIndex > 0) {
|
||||
// Flush if style will change OR if we're closing a block/structural element
|
||||
const bool isInlineTag = !headerOrBlockTag && strcmp(name, "table") != 0 &&
|
||||
const bool isInlineTag = !headerOrBlockTag && !isTableTag && !isTableCellTag &&
|
||||
!matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS) && self->depth != 1;
|
||||
const bool shouldFlush = styleWillChange || headerOrBlockTag || matches(name, BOLD_TAGS, NUM_BOLD_TAGS) ||
|
||||
matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS) ||
|
||||
matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS) || strcmp(name, "table") == 0 ||
|
||||
matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS) || isTableTag || isTableCellTag ||
|
||||
matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS) || self->depth == 1;
|
||||
|
||||
if (shouldFlush) {
|
||||
@@ -466,6 +779,57 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
||||
}
|
||||
}
|
||||
|
||||
// --- Table cell/row/table close handling ---
|
||||
if (self->inTable) {
|
||||
if (isTableCellTag) {
|
||||
// Save the current cell content into the table data
|
||||
if (self->tableData && !self->tableData->rows.empty()) {
|
||||
auto& currentRow = self->tableData->rows.back();
|
||||
if (!currentRow.cells.empty()) {
|
||||
currentRow.cells.back().content = std::move(self->currentTextBlock);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a safe empty ParsedText so character data between cells doesn't crash
|
||||
auto safeBlockStyle = BlockStyle();
|
||||
safeBlockStyle.alignment = CssTextAlign::Left;
|
||||
self->currentTextBlock.reset(
|
||||
new ParsedText(self->extraParagraphSpacing, self->hyphenationEnabled, safeBlockStyle));
|
||||
self->nextWordContinues = false;
|
||||
}
|
||||
|
||||
if (isTableTag) {
|
||||
// Process the entire buffered table
|
||||
self->depth -= 1;
|
||||
|
||||
// Clean up style state for this depth
|
||||
if (self->skipUntilDepth == self->depth) self->skipUntilDepth = INT_MAX;
|
||||
if (self->boldUntilDepth == self->depth) self->boldUntilDepth = INT_MAX;
|
||||
if (self->italicUntilDepth == self->depth) self->italicUntilDepth = INT_MAX;
|
||||
if (self->underlineUntilDepth == self->depth) self->underlineUntilDepth = INT_MAX;
|
||||
if (!self->inlineStyleStack.empty() && self->inlineStyleStack.back().depth == self->depth) {
|
||||
self->inlineStyleStack.pop_back();
|
||||
self->updateEffectiveInlineStyle();
|
||||
}
|
||||
|
||||
self->processTable();
|
||||
|
||||
self->inTable = false;
|
||||
self->tableData.reset();
|
||||
|
||||
// Restore a fresh text block for content after the table
|
||||
auto paragraphAlignmentBlockStyle = BlockStyle();
|
||||
paragraphAlignmentBlockStyle.textAlignDefined = true;
|
||||
const auto align = (self->paragraphAlignment == static_cast<uint8_t>(CssTextAlign::None))
|
||||
? CssTextAlign::Justify
|
||||
: static_cast<CssTextAlign>(self->paragraphAlignment);
|
||||
paragraphAlignmentBlockStyle.alignment = align;
|
||||
self->currentTextBlock.reset(
|
||||
new ParsedText(self->extraParagraphSpacing, self->hyphenationEnabled, paragraphAlignmentBlockStyle));
|
||||
return; // depth already decremented, skip the normal endElement cleanup
|
||||
}
|
||||
}
|
||||
|
||||
self->depth -= 1;
|
||||
|
||||
// Leaving skip
|
||||
@@ -653,3 +1017,335 @@ void ChapterHtmlSlimParser::makePages() {
|
||||
currentPageNextY += lineHeight / 2;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Table processing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Cell padding in pixels (horizontal space between grid line and cell text)
|
||||
static constexpr int TABLE_CELL_PAD_X = 4;
|
||||
// Vertical cell padding — asymmetric because font metrics include internal leading (whitespace
|
||||
// above glyphs), so the top already has built-in visual space. Less explicit padding on top,
|
||||
// more on bottom, produces visually balanced results.
|
||||
static constexpr int TABLE_CELL_PAD_TOP = 1;
|
||||
static constexpr int TABLE_CELL_PAD_BOTTOM = 3;
|
||||
// Minimum usable column width in pixels (below this text is unreadable)
|
||||
static constexpr int TABLE_MIN_COL_WIDTH = 30;
|
||||
// Grid line width in pixels
|
||||
static constexpr int TABLE_GRID_LINE_PX = 1;
|
||||
|
||||
void ChapterHtmlSlimParser::addTableRowToPage(std::shared_ptr<PageTableRow> row) {
|
||||
if (!currentPage) {
|
||||
currentPage.reset(new Page());
|
||||
currentPageNextY = 0;
|
||||
}
|
||||
|
||||
const int16_t rowH = row->getHeight();
|
||||
|
||||
// If this row doesn't fit on the current page, start a new one
|
||||
if (currentPageNextY + rowH > viewportHeight) {
|
||||
completePageFn(std::move(currentPage));
|
||||
currentPage.reset(new Page());
|
||||
currentPageNextY = 0;
|
||||
}
|
||||
|
||||
row->xPos = 0;
|
||||
row->yPos = currentPageNextY;
|
||||
currentPage->elements.push_back(std::move(row));
|
||||
currentPageNextY += rowH;
|
||||
}
|
||||
|
||||
void ChapterHtmlSlimParser::processTable() {
|
||||
if (!tableData || tableData->rows.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentPage) {
|
||||
currentPage.reset(new Page());
|
||||
currentPageNextY = 0;
|
||||
}
|
||||
|
||||
const int lh = static_cast<int>(renderer.getLineHeight(fontId) * lineCompression);
|
||||
|
||||
// 1. Determine logical column count using colspan.
|
||||
// Each cell occupies cell.colspan logical columns. The total for a row is the sum of colspans.
|
||||
size_t numCols = 0;
|
||||
for (const auto& row : tableData->rows) {
|
||||
size_t rowLogicalCols = 0;
|
||||
for (const auto& cell : row.cells) {
|
||||
rowLogicalCols += static_cast<size_t>(cell.colspan);
|
||||
}
|
||||
numCols = std::max(numCols, rowLogicalCols);
|
||||
}
|
||||
|
||||
if (numCols == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Measure natural width of each cell and compute per-column max natural width.
|
||||
// Only non-spanning cells (colspan==1) contribute to individual column widths.
|
||||
// Spanning cells use the combined width of their spanned columns.
|
||||
std::vector<uint16_t> colNaturalWidth(numCols, 0);
|
||||
|
||||
for (const auto& row : tableData->rows) {
|
||||
size_t logicalCol = 0;
|
||||
for (const auto& cell : row.cells) {
|
||||
if (cell.colspan == 1 && cell.content && !cell.content->isEmpty()) {
|
||||
if (logicalCol < numCols) {
|
||||
const uint16_t w = cell.content->getNaturalWidth(renderer, fontId);
|
||||
if (w > colNaturalWidth[logicalCol]) {
|
||||
colNaturalWidth[logicalCol] = w;
|
||||
}
|
||||
}
|
||||
}
|
||||
logicalCol += static_cast<size_t>(cell.colspan);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Calculate column widths to fit viewport.
|
||||
// Available width = viewport - outer borders - internal column borders - cell padding
|
||||
const int totalGridLines = static_cast<int>(numCols) + 1; // left + between columns + right
|
||||
const int totalPadding = static_cast<int>(numCols) * TABLE_CELL_PAD_X * 2;
|
||||
const int availableForContent = viewportWidth - totalGridLines * TABLE_GRID_LINE_PX - totalPadding;
|
||||
|
||||
// 3a. Resolve width hints per column.
|
||||
// Priority: <col> hints > max cell hint (colspan=1 only).
|
||||
// Percentages are relative to availableForContent.
|
||||
const float emSize = static_cast<float>(lh);
|
||||
const float containerW = static_cast<float>(std::max(availableForContent, 0));
|
||||
|
||||
std::vector<int> colHintedWidth(numCols, -1); // -1 = no hint
|
||||
|
||||
// From <col> tags
|
||||
for (size_t c = 0; c < numCols && c < tableData->colWidthHints.size(); ++c) {
|
||||
const auto& hint = tableData->colWidthHints[c];
|
||||
if (hint.value > 0) {
|
||||
int px = static_cast<int>(hint.toPixels(emSize, containerW));
|
||||
if (px > 0) {
|
||||
colHintedWidth[c] = std::max(px, TABLE_MIN_COL_WIDTH);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// From <td>/<th> cell width hints (only override if no <col> hint exists for this column)
|
||||
for (const auto& row : tableData->rows) {
|
||||
size_t logicalCol = 0;
|
||||
for (const auto& cell : row.cells) {
|
||||
if (cell.colspan == 1 && cell.hasWidthHint && logicalCol < numCols) {
|
||||
if (colHintedWidth[logicalCol] < 0) { // no <col> hint yet
|
||||
int px = static_cast<int>(cell.widthHint.toPixels(emSize, containerW));
|
||||
if (px > colHintedWidth[logicalCol]) {
|
||||
colHintedWidth[logicalCol] = std::max(px, TABLE_MIN_COL_WIDTH);
|
||||
}
|
||||
}
|
||||
}
|
||||
logicalCol += static_cast<size_t>(cell.colspan);
|
||||
}
|
||||
}
|
||||
|
||||
// 3b. Distribute column widths: hinted columns get their hint, unhinted use auto-sizing.
|
||||
std::vector<uint16_t> colWidths(numCols, 0);
|
||||
|
||||
if (availableForContent <= 0) {
|
||||
const uint16_t equalWidth = static_cast<uint16_t>(viewportWidth / numCols);
|
||||
for (size_t c = 0; c < numCols; ++c) {
|
||||
colWidths[c] = equalWidth;
|
||||
}
|
||||
} else {
|
||||
// First, assign hinted columns and track how much space they consume
|
||||
int hintedSpaceUsed = 0;
|
||||
size_t unhintedCount = 0;
|
||||
for (size_t c = 0; c < numCols; ++c) {
|
||||
if (colHintedWidth[c] > 0) {
|
||||
hintedSpaceUsed += colHintedWidth[c];
|
||||
} else {
|
||||
unhintedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// If hinted columns exceed available space, scale them down proportionally
|
||||
if (hintedSpaceUsed > availableForContent && hintedSpaceUsed > 0) {
|
||||
for (size_t c = 0; c < numCols; ++c) {
|
||||
if (colHintedWidth[c] > 0) {
|
||||
colHintedWidth[c] = colHintedWidth[c] * availableForContent / hintedSpaceUsed;
|
||||
colHintedWidth[c] = std::max(colHintedWidth[c], TABLE_MIN_COL_WIDTH);
|
||||
}
|
||||
}
|
||||
// Recalculate
|
||||
hintedSpaceUsed = 0;
|
||||
for (size_t c = 0; c < numCols; ++c) {
|
||||
if (colHintedWidth[c] > 0) {
|
||||
hintedSpaceUsed += colHintedWidth[c];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assign hinted columns
|
||||
for (size_t c = 0; c < numCols; ++c) {
|
||||
if (colHintedWidth[c] > 0) {
|
||||
colWidths[c] = static_cast<uint16_t>(colHintedWidth[c]);
|
||||
}
|
||||
}
|
||||
|
||||
// Distribute remaining space among unhinted columns using the existing algorithm
|
||||
const int remainingForUnhinted = std::max(availableForContent - hintedSpaceUsed, 0);
|
||||
|
||||
if (unhintedCount > 0 && remainingForUnhinted > 0) {
|
||||
// Compute total natural width of unhinted columns
|
||||
int totalNaturalUnhinted = 0;
|
||||
for (size_t c = 0; c < numCols; ++c) {
|
||||
if (colHintedWidth[c] <= 0) {
|
||||
totalNaturalUnhinted += colNaturalWidth[c];
|
||||
}
|
||||
}
|
||||
|
||||
if (totalNaturalUnhinted <= remainingForUnhinted) {
|
||||
// All unhinted content fits — distribute extra space equally among unhinted columns
|
||||
const int extraSpace = remainingForUnhinted - totalNaturalUnhinted;
|
||||
const int perColExtra = extraSpace / static_cast<int>(unhintedCount);
|
||||
for (size_t c = 0; c < numCols; ++c) {
|
||||
if (colHintedWidth[c] <= 0) {
|
||||
colWidths[c] = static_cast<uint16_t>(colNaturalWidth[c] + perColExtra);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unhinted content exceeds remaining space — two-pass fair-share among unhinted columns
|
||||
const int equalShare = remainingForUnhinted / static_cast<int>(unhintedCount);
|
||||
|
||||
int spaceUsedByFitting = 0;
|
||||
int naturalOfWide = 0;
|
||||
size_t wideCount = 0;
|
||||
|
||||
for (size_t c = 0; c < numCols; ++c) {
|
||||
if (colHintedWidth[c] <= 0) {
|
||||
if (static_cast<int>(colNaturalWidth[c]) <= equalShare) {
|
||||
colWidths[c] = colNaturalWidth[c];
|
||||
spaceUsedByFitting += colNaturalWidth[c];
|
||||
} else {
|
||||
naturalOfWide += colNaturalWidth[c];
|
||||
wideCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const int wideSpace = remainingForUnhinted - spaceUsedByFitting;
|
||||
for (size_t c = 0; c < numCols; ++c) {
|
||||
if (colHintedWidth[c] <= 0 && static_cast<int>(colNaturalWidth[c]) > equalShare) {
|
||||
if (naturalOfWide > 0 && wideCount > 1) {
|
||||
int proportional = static_cast<int>(colNaturalWidth[c]) * wideSpace / naturalOfWide;
|
||||
colWidths[c] = static_cast<uint16_t>(std::max(proportional, TABLE_MIN_COL_WIDTH));
|
||||
} else {
|
||||
colWidths[c] = static_cast<uint16_t>(std::max(wideSpace, TABLE_MIN_COL_WIDTH));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (unhintedCount > 0) {
|
||||
// No remaining space for unhinted columns — give them minimum width
|
||||
for (size_t c = 0; c < numCols; ++c) {
|
||||
if (colHintedWidth[c] <= 0) {
|
||||
colWidths[c] = static_cast<uint16_t>(TABLE_MIN_COL_WIDTH);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute column x-offsets (cumulative: border + padding + content width + padding + border ...)
|
||||
std::vector<uint16_t> colXOffsets(numCols, 0);
|
||||
int xAccum = TABLE_GRID_LINE_PX; // start after left border
|
||||
for (size_t c = 0; c < numCols; ++c) {
|
||||
colXOffsets[c] = static_cast<uint16_t>(xAccum);
|
||||
xAccum += TABLE_CELL_PAD_X + colWidths[c] + TABLE_CELL_PAD_X + TABLE_GRID_LINE_PX;
|
||||
}
|
||||
const int16_t totalTableWidth = static_cast<int16_t>(xAccum);
|
||||
|
||||
// Helper: compute the combined content width for a cell spanning multiple columns.
|
||||
// This includes the content widths plus the internal grid lines and padding between spanned columns.
|
||||
auto spanContentWidth = [&](size_t startCol, int colspan) -> uint16_t {
|
||||
int width = 0;
|
||||
for (int s = 0; s < colspan && startCol + s < numCols; ++s) {
|
||||
width += colWidths[startCol + s];
|
||||
if (s > 0) {
|
||||
// Add internal padding and grid line between spanned columns
|
||||
width += TABLE_CELL_PAD_X * 2 + TABLE_GRID_LINE_PX;
|
||||
}
|
||||
}
|
||||
return static_cast<uint16_t>(std::max(width, 0));
|
||||
};
|
||||
|
||||
// Helper: compute the full cell width (including padding on both sides) for a spanning cell.
|
||||
auto spanFullCellWidth = [&](size_t startCol, int colspan) -> uint16_t {
|
||||
if (colspan <= 0 || startCol >= numCols) return 0;
|
||||
const size_t endCol = std::min(startCol + static_cast<size_t>(colspan), numCols) - 1;
|
||||
// From the left edge of startCol's cell to the right edge of endCol's cell
|
||||
const int leftEdge = colXOffsets[startCol];
|
||||
const int rightEdge = colXOffsets[endCol] + TABLE_CELL_PAD_X + colWidths[endCol] + TABLE_CELL_PAD_X;
|
||||
return static_cast<uint16_t>(rightEdge - leftEdge);
|
||||
};
|
||||
|
||||
// 4. Lay out each row: map cells to logical columns, create PageTableRow
|
||||
for (auto& row : tableData->rows) {
|
||||
// Build cell data for this row, one entry per CELL (not per logical column).
|
||||
// Each PageTableCellData gets the correct x-offset and combined column width.
|
||||
std::vector<PageTableCellData> cellDataVec;
|
||||
size_t maxLinesInRow = 1;
|
||||
size_t logicalCol = 0;
|
||||
|
||||
for (size_t ci = 0; ci < row.cells.size() && logicalCol < numCols; ++ci) {
|
||||
auto& cell = row.cells[ci];
|
||||
const int cs = cell.colspan;
|
||||
|
||||
PageTableCellData cellData;
|
||||
cellData.xOffset = colXOffsets[logicalCol];
|
||||
cellData.columnWidth = spanFullCellWidth(logicalCol, cs);
|
||||
|
||||
if (cell.content && !cell.content->isEmpty()) {
|
||||
// Center-align cells that span the full table width (common for section headers/titles)
|
||||
if (cs >= static_cast<int>(numCols)) {
|
||||
BlockStyle centeredStyle = cell.content->getBlockStyle();
|
||||
centeredStyle.alignment = CssTextAlign::Center;
|
||||
centeredStyle.textAlignDefined = true;
|
||||
cell.content->setBlockStyle(centeredStyle);
|
||||
}
|
||||
|
||||
const uint16_t contentWidth = spanContentWidth(logicalCol, cs);
|
||||
std::vector<std::shared_ptr<TextBlock>> cellLines;
|
||||
|
||||
cell.content->layoutAndExtractLines(
|
||||
renderer, fontId, contentWidth,
|
||||
[&cellLines](const std::shared_ptr<TextBlock>& textBlock) { cellLines.push_back(textBlock); });
|
||||
|
||||
if (cellLines.size() > maxLinesInRow) {
|
||||
maxLinesInRow = cellLines.size();
|
||||
}
|
||||
cellData.lines = std::move(cellLines);
|
||||
}
|
||||
|
||||
cellDataVec.push_back(std::move(cellData));
|
||||
logicalCol += static_cast<size_t>(cs);
|
||||
}
|
||||
|
||||
// Fill remaining logical columns with empty cells (rows shorter than numCols)
|
||||
while (logicalCol < numCols) {
|
||||
PageTableCellData emptyCell;
|
||||
emptyCell.xOffset = colXOffsets[logicalCol];
|
||||
emptyCell.columnWidth = static_cast<uint16_t>(TABLE_CELL_PAD_X + colWidths[logicalCol] + TABLE_CELL_PAD_X);
|
||||
cellDataVec.push_back(std::move(emptyCell));
|
||||
logicalCol++;
|
||||
}
|
||||
|
||||
// Row height = max lines * lineHeight + top/bottom border + asymmetric vertical padding
|
||||
const int16_t rowHeight = static_cast<int16_t>(
|
||||
static_cast<int>(maxLinesInRow) * lh + 2 + TABLE_CELL_PAD_TOP + TABLE_CELL_PAD_BOTTOM);
|
||||
|
||||
auto pageTableRow = std::make_shared<PageTableRow>(
|
||||
std::move(cellDataVec), rowHeight, totalTableWidth, static_cast<int16_t>(lh), 0, 0);
|
||||
|
||||
addTableRowToPage(std::move(pageTableRow));
|
||||
}
|
||||
|
||||
// Add a small gap after the table
|
||||
if (extraParagraphSpacing) {
|
||||
currentPageNextY += lh / 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,16 +7,21 @@
|
||||
#include <memory>
|
||||
|
||||
#include "../ParsedText.h"
|
||||
#include "../TableData.h"
|
||||
#include "../blocks/ImageBlock.h"
|
||||
#include "../blocks/TextBlock.h"
|
||||
#include "../css/CssParser.h"
|
||||
#include "../css/CssStyle.h"
|
||||
|
||||
class Page;
|
||||
class PageTableRow;
|
||||
class GfxRenderer;
|
||||
class Epub;
|
||||
|
||||
#define MAX_WORD_SIZE 200
|
||||
|
||||
class ChapterHtmlSlimParser {
|
||||
std::shared_ptr<Epub> epub;
|
||||
const std::string& filepath;
|
||||
GfxRenderer& renderer;
|
||||
std::function<void(std::unique_ptr<Page>)> completePageFn;
|
||||
@@ -43,6 +48,9 @@ class ChapterHtmlSlimParser {
|
||||
bool hyphenationEnabled;
|
||||
const CssParser* cssParser;
|
||||
bool embeddedStyle;
|
||||
std::string contentBase;
|
||||
std::string imageBasePath;
|
||||
int imageCounter = 0;
|
||||
|
||||
// Style tracking (replaces depth-based approach)
|
||||
struct StyleStackEntry {
|
||||
@@ -57,10 +65,16 @@ class ChapterHtmlSlimParser {
|
||||
bool effectiveItalic = false;
|
||||
bool effectiveUnderline = false;
|
||||
|
||||
// Table buffering state
|
||||
bool inTable = false;
|
||||
std::unique_ptr<TableData> tableData;
|
||||
|
||||
void updateEffectiveInlineStyle();
|
||||
void startNewTextBlock(const BlockStyle& blockStyle);
|
||||
void flushPartWordBuffer();
|
||||
void makePages();
|
||||
void processTable();
|
||||
void addTableRowToPage(std::shared_ptr<PageTableRow> row);
|
||||
// XML callbacks
|
||||
static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts);
|
||||
static void XMLCALL characterData(void* userData, const XML_Char* s, int len);
|
||||
@@ -68,15 +82,17 @@ class ChapterHtmlSlimParser {
|
||||
static void XMLCALL endElement(void* userData, const XML_Char* name);
|
||||
|
||||
public:
|
||||
explicit ChapterHtmlSlimParser(const std::string& filepath, GfxRenderer& renderer, const int fontId,
|
||||
const float lineCompression, const bool extraParagraphSpacing,
|
||||
explicit ChapterHtmlSlimParser(std::shared_ptr<Epub> epub, const std::string& filepath, GfxRenderer& renderer,
|
||||
const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
||||
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
||||
const uint16_t viewportHeight, const bool hyphenationEnabled,
|
||||
const std::function<void(std::unique_ptr<Page>)>& completePageFn,
|
||||
const bool embeddedStyle, const std::function<void()>& popupFn = nullptr,
|
||||
const bool embeddedStyle, const std::string& contentBase,
|
||||
const std::string& imageBasePath, const std::function<void()>& popupFn = nullptr,
|
||||
const CssParser* cssParser = nullptr)
|
||||
|
||||
: filepath(filepath),
|
||||
: epub(epub),
|
||||
filepath(filepath),
|
||||
renderer(renderer),
|
||||
fontId(fontId),
|
||||
lineCompression(lineCompression),
|
||||
@@ -88,7 +104,9 @@ class ChapterHtmlSlimParser {
|
||||
completePageFn(completePageFn),
|
||||
popupFn(popupFn),
|
||||
cssParser(cssParser),
|
||||
embeddedStyle(embeddedStyle) {}
|
||||
embeddedStyle(embeddedStyle),
|
||||
contentBase(contentBase),
|
||||
imageBasePath(imageBasePath) {}
|
||||
|
||||
~ChapterHtmlSlimParser() = default;
|
||||
bool parseAndBuildPages();
|
||||
|
||||
@@ -726,6 +726,23 @@ void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const
|
||||
display.displayBuffer(refreshMode, fadingFix);
|
||||
}
|
||||
|
||||
// EXPERIMENTAL: Display only a rectangular region with specified refresh mode
|
||||
void GfxRenderer::displayWindow(int x, int y, int width, int height,
|
||||
HalDisplay::RefreshMode mode) const {
|
||||
LOG_DBG("GFX", "Displaying window at (%d,%d) size (%dx%d) with mode %d", x, y, width, height,
|
||||
static_cast<int>(mode));
|
||||
|
||||
// Validate bounds
|
||||
if (x < 0 || y < 0 || x + width > getScreenWidth() || y + height > getScreenHeight()) {
|
||||
LOG_ERR("GFX", "Window bounds exceed display dimensions!");
|
||||
return;
|
||||
}
|
||||
|
||||
display.displayWindow(static_cast<uint16_t>(x), static_cast<uint16_t>(y),
|
||||
static_cast<uint16_t>(width), static_cast<uint16_t>(height), mode,
|
||||
fadingFix);
|
||||
}
|
||||
|
||||
std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth,
|
||||
const EpdFontFamily::Style style) const {
|
||||
if (!text || maxWidth <= 0) return "";
|
||||
|
||||
@@ -70,7 +70,8 @@ class GfxRenderer {
|
||||
int getScreenHeight() const;
|
||||
void displayBuffer(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const;
|
||||
// EXPERIMENTAL: Windowed update - display only a rectangular region
|
||||
// void displayWindow(int x, int y, int width, int height) const;
|
||||
void displayWindow(int x, int y, int width, int height,
|
||||
HalDisplay::RefreshMode mode = HalDisplay::FAST_REFRESH) const;
|
||||
void invertScreen() const;
|
||||
void clearScreen(uint8_t color = 0xFF) const;
|
||||
void getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const;
|
||||
@@ -120,6 +121,7 @@ class GfxRenderer {
|
||||
|
||||
// Grayscale functions
|
||||
void setRenderMode(const RenderMode mode) { this->renderMode = mode; }
|
||||
RenderMode getRenderMode() const { return renderMode; }
|
||||
void copyGrayscaleLsbBuffers() const;
|
||||
void copyGrayscaleMsbBuffers() const;
|
||||
void displayGrayBuffer() const;
|
||||
|
||||
27
lib/PlaceholderCover/BookIcon.h
Normal file
27
lib/PlaceholderCover/BookIcon.h
Normal file
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
// Book icon: 48x48, 1-bit packed (MSB first)
|
||||
// 0 = black, 1 = white (same format as Logo120.h)
|
||||
static constexpr int BOOK_ICON_WIDTH = 48;
|
||||
static constexpr int BOOK_ICON_HEIGHT = 48;
|
||||
static const uint8_t BookIcon[] = {
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x00, 0x00, 0x00,
|
||||
0x00, 0x1f, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x1f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f,
|
||||
0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f,
|
||||
0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff,
|
||||
0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1c, 0x00, 0x00, 0x01, 0x9f, 0xfc, 0x1f,
|
||||
0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f,
|
||||
0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1c, 0x00, 0x01,
|
||||
0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f,
|
||||
0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f,
|
||||
0xfc, 0x1c, 0x00, 0x00, 0x1f, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff,
|
||||
0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f,
|
||||
0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f,
|
||||
0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff,
|
||||
0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f,
|
||||
0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x1f,
|
||||
0xfc, 0x00, 0x00, 0x00, 0x00, 0x1f, 0xff, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0x00, 0x00, 0x00,
|
||||
0x00, 0x3f, 0xff, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xff,
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
};
|
||||
480
lib/PlaceholderCover/PlaceholderCoverGenerator.cpp
Normal file
480
lib/PlaceholderCover/PlaceholderCoverGenerator.cpp
Normal file
@@ -0,0 +1,480 @@
|
||||
#include "PlaceholderCoverGenerator.h"
|
||||
|
||||
#include <EpdFont.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <Utf8.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
// Include the UI fonts directly for self-contained placeholder rendering.
|
||||
// These are 1-bit bitmap fonts compiled from Ubuntu TTF.
|
||||
#include "builtinFonts/ubuntu_10_regular.h"
|
||||
#include "builtinFonts/ubuntu_12_bold.h"
|
||||
|
||||
// Book icon bitmap (48x48 1-bit, generated by scripts/generate_book_icon.py)
|
||||
#include "BookIcon.h"
|
||||
|
||||
namespace {
|
||||
|
||||
// BMP writing helpers (same format as JpegToBmpConverter)
|
||||
inline void write16(Print& out, const uint16_t value) {
|
||||
out.write(value & 0xFF);
|
||||
out.write((value >> 8) & 0xFF);
|
||||
}
|
||||
|
||||
inline void write32(Print& out, const uint32_t value) {
|
||||
out.write(value & 0xFF);
|
||||
out.write((value >> 8) & 0xFF);
|
||||
out.write((value >> 16) & 0xFF);
|
||||
out.write((value >> 24) & 0xFF);
|
||||
}
|
||||
|
||||
inline void write32Signed(Print& out, const int32_t value) {
|
||||
out.write(value & 0xFF);
|
||||
out.write((value >> 8) & 0xFF);
|
||||
out.write((value >> 16) & 0xFF);
|
||||
out.write((value >> 24) & 0xFF);
|
||||
}
|
||||
|
||||
void writeBmpHeader1bit(Print& bmpOut, const int width, const int height) {
|
||||
const int bytesPerRow = (width + 31) / 32 * 4;
|
||||
const int imageSize = bytesPerRow * height;
|
||||
const uint32_t fileSize = 62 + imageSize;
|
||||
|
||||
// BMP File Header (14 bytes)
|
||||
bmpOut.write('B');
|
||||
bmpOut.write('M');
|
||||
write32(bmpOut, fileSize);
|
||||
write32(bmpOut, 0); // Reserved
|
||||
write32(bmpOut, 62); // Offset to pixel data
|
||||
|
||||
// DIB Header (BITMAPINFOHEADER - 40 bytes)
|
||||
write32(bmpOut, 40);
|
||||
write32Signed(bmpOut, width);
|
||||
write32Signed(bmpOut, -height); // Negative = top-down
|
||||
write16(bmpOut, 1); // Color planes
|
||||
write16(bmpOut, 1); // Bits per pixel
|
||||
write32(bmpOut, 0); // BI_RGB
|
||||
write32(bmpOut, imageSize);
|
||||
write32(bmpOut, 2835); // xPixelsPerMeter
|
||||
write32(bmpOut, 2835); // yPixelsPerMeter
|
||||
write32(bmpOut, 2); // colorsUsed
|
||||
write32(bmpOut, 2); // colorsImportant
|
||||
|
||||
// Palette: index 0 = black, index 1 = white
|
||||
const uint8_t palette[8] = {
|
||||
0x00, 0x00, 0x00, 0x00, // Black
|
||||
0xFF, 0xFF, 0xFF, 0x00 // White
|
||||
};
|
||||
for (const uint8_t b : palette) {
|
||||
bmpOut.write(b);
|
||||
}
|
||||
}
|
||||
|
||||
/// 1-bit pixel buffer that can render text, icons, and shapes, then write as BMP.
|
||||
class PixelBuffer {
|
||||
public:
|
||||
PixelBuffer(int width, int height) : width(width), height(height) {
|
||||
bytesPerRow = (width + 31) / 32 * 4;
|
||||
bufferSize = bytesPerRow * height;
|
||||
buffer = static_cast<uint8_t*>(malloc(bufferSize));
|
||||
if (buffer) {
|
||||
memset(buffer, 0xFF, bufferSize); // White background
|
||||
}
|
||||
}
|
||||
|
||||
~PixelBuffer() {
|
||||
if (buffer) {
|
||||
free(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
bool isValid() const { return buffer != nullptr; }
|
||||
|
||||
/// Set a pixel to black.
|
||||
void setBlack(int x, int y) {
|
||||
if (x < 0 || x >= width || y < 0 || y >= height) return;
|
||||
const int byteIndex = y * bytesPerRow + x / 8;
|
||||
const uint8_t bitMask = 0x80 >> (x % 8);
|
||||
buffer[byteIndex] &= ~bitMask;
|
||||
}
|
||||
|
||||
/// Set a scaled "pixel" (scale x scale block) to black.
|
||||
void setBlackScaled(int x, int y, int scale) {
|
||||
for (int dy = 0; dy < scale; dy++) {
|
||||
for (int dx = 0; dx < scale; dx++) {
|
||||
setBlack(x + dx, y + dy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw a filled rectangle in black.
|
||||
void fillRect(int x, int y, int w, int h) {
|
||||
for (int row = y; row < y + h && row < height; row++) {
|
||||
for (int col = x; col < x + w && col < width; col++) {
|
||||
setBlack(col, row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw a rectangular border in black.
|
||||
void drawBorder(int x, int y, int w, int h, int thickness) {
|
||||
fillRect(x, y, w, thickness); // Top
|
||||
fillRect(x, y + h - thickness, w, thickness); // Bottom
|
||||
fillRect(x, y, thickness, h); // Left
|
||||
fillRect(x + w - thickness, y, thickness, h); // Right
|
||||
}
|
||||
|
||||
/// Draw a horizontal line in black with configurable thickness.
|
||||
void drawHLine(int x, int y, int length, int thickness = 1) {
|
||||
fillRect(x, y, length, thickness);
|
||||
}
|
||||
|
||||
/// Render a single glyph at (cursorX, baselineY) with integer scaling. Returns advance in X (scaled).
|
||||
int renderGlyph(const EpdFontData* font, uint32_t codepoint, int cursorX, int baselineY, int scale = 1) {
|
||||
const EpdFont fontObj(font);
|
||||
const EpdGlyph* glyph = fontObj.getGlyph(codepoint);
|
||||
if (!glyph) {
|
||||
glyph = fontObj.getGlyph(REPLACEMENT_GLYPH);
|
||||
}
|
||||
if (!glyph) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const uint8_t* bitmap = &font->bitmap[glyph->dataOffset];
|
||||
const int glyphW = glyph->width;
|
||||
const int glyphH = glyph->height;
|
||||
|
||||
for (int gy = 0; gy < glyphH; gy++) {
|
||||
const int screenY = baselineY - glyph->top * scale + gy * scale;
|
||||
for (int gx = 0; gx < glyphW; gx++) {
|
||||
const int pixelPos = gy * glyphW + gx;
|
||||
const int screenX = cursorX + glyph->left * scale + gx * scale;
|
||||
|
||||
bool isSet = false;
|
||||
if (font->is2Bit) {
|
||||
const uint8_t byte = bitmap[pixelPos / 4];
|
||||
const uint8_t bitIndex = (3 - pixelPos % 4) * 2;
|
||||
const uint8_t val = 3 - ((byte >> bitIndex) & 0x3);
|
||||
isSet = (val < 3);
|
||||
} else {
|
||||
const uint8_t byte = bitmap[pixelPos / 8];
|
||||
const uint8_t bitIndex = 7 - (pixelPos % 8);
|
||||
isSet = ((byte >> bitIndex) & 1);
|
||||
}
|
||||
|
||||
if (isSet) {
|
||||
setBlackScaled(screenX, screenY, scale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return glyph->advanceX * scale;
|
||||
}
|
||||
|
||||
/// Render a UTF-8 string at (x, y) where y is the top of the text line, with integer scaling.
|
||||
void drawText(const EpdFontData* font, int x, int y, const char* text, int scale = 1) {
|
||||
const int baselineY = y + font->ascender * scale;
|
||||
int cursorX = x;
|
||||
uint32_t cp;
|
||||
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
|
||||
cursorX += renderGlyph(font, cp, cursorX, baselineY, scale);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw a 1-bit icon bitmap (MSB first, 0=black, 1=white) with integer scaling.
|
||||
void drawIcon(const uint8_t* icon, int iconW, int iconH, int x, int y, int scale = 1) {
|
||||
const int bytesPerIconRow = iconW / 8;
|
||||
for (int iy = 0; iy < iconH; iy++) {
|
||||
for (int ix = 0; ix < iconW; ix++) {
|
||||
const int byteIdx = iy * bytesPerIconRow + ix / 8;
|
||||
const uint8_t bitMask = 0x80 >> (ix % 8);
|
||||
// In the icon data: 0 = black (drawn), 1 = white (skip)
|
||||
if (!(icon[byteIdx] & bitMask)) {
|
||||
const int sx = x + ix * scale;
|
||||
const int sy = y + iy * scale;
|
||||
setBlackScaled(sx, sy, scale);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the pixel buffer to a file as a 1-bit BMP.
|
||||
bool writeBmp(Print& out) const {
|
||||
if (!buffer) return false;
|
||||
writeBmpHeader1bit(out, width, height);
|
||||
out.write(buffer, bufferSize);
|
||||
return true;
|
||||
}
|
||||
|
||||
int getWidth() const { return width; }
|
||||
int getHeight() const { return height; }
|
||||
|
||||
private:
|
||||
int width;
|
||||
int height;
|
||||
int bytesPerRow;
|
||||
size_t bufferSize;
|
||||
uint8_t* buffer;
|
||||
};
|
||||
|
||||
/// Measure the width of a UTF-8 string in pixels (at 1x scale).
|
||||
int measureTextWidth(const EpdFontData* font, const char* text) {
|
||||
const EpdFont fontObj(font);
|
||||
int w = 0, h = 0;
|
||||
fontObj.getTextDimensions(text, &w, &h);
|
||||
return w;
|
||||
}
|
||||
|
||||
/// Get the advance width of a single character.
|
||||
int getCharAdvance(const EpdFontData* font, uint32_t cp) {
|
||||
const EpdFont fontObj(font);
|
||||
const EpdGlyph* glyph = fontObj.getGlyph(cp);
|
||||
if (!glyph) return 0;
|
||||
return glyph->advanceX;
|
||||
}
|
||||
|
||||
/// Split a string into words (splitting on spaces).
|
||||
std::vector<std::string> splitWords(const std::string& text) {
|
||||
std::vector<std::string> words;
|
||||
std::string current;
|
||||
for (size_t i = 0; i < text.size(); i++) {
|
||||
if (text[i] == ' ') {
|
||||
if (!current.empty()) {
|
||||
words.push_back(current);
|
||||
current.clear();
|
||||
}
|
||||
} else {
|
||||
current += text[i];
|
||||
}
|
||||
}
|
||||
if (!current.empty()) {
|
||||
words.push_back(current);
|
||||
}
|
||||
return words;
|
||||
}
|
||||
|
||||
/// Word-wrap text into lines that fit within maxWidth pixels at the given scale.
|
||||
std::vector<std::string> wrapText(const EpdFontData* font, const std::string& text, int maxWidth, int scale = 1) {
|
||||
std::vector<std::string> lines;
|
||||
const auto words = splitWords(text);
|
||||
if (words.empty()) return lines;
|
||||
|
||||
const int spaceWidth = getCharAdvance(font, ' ') * scale;
|
||||
std::string currentLine;
|
||||
int currentWidth = 0;
|
||||
|
||||
for (const auto& word : words) {
|
||||
const int wordWidth = measureTextWidth(font, word.c_str()) * scale;
|
||||
|
||||
if (currentLine.empty()) {
|
||||
currentLine = word;
|
||||
currentWidth = wordWidth;
|
||||
} else if (currentWidth + spaceWidth + wordWidth <= maxWidth) {
|
||||
currentLine += " " + word;
|
||||
currentWidth += spaceWidth + wordWidth;
|
||||
} else {
|
||||
lines.push_back(currentLine);
|
||||
currentLine = word;
|
||||
currentWidth = wordWidth;
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentLine.empty()) {
|
||||
lines.push_back(currentLine);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/// Truncate a string with "..." if it exceeds maxWidth pixels at the given scale.
|
||||
std::string truncateText(const EpdFontData* font, const std::string& text, int maxWidth, int scale = 1) {
|
||||
if (measureTextWidth(font, text.c_str()) * scale <= maxWidth) {
|
||||
return text;
|
||||
}
|
||||
|
||||
std::string truncated = text;
|
||||
const char* ellipsis = "...";
|
||||
const int ellipsisWidth = measureTextWidth(font, ellipsis) * scale;
|
||||
|
||||
while (!truncated.empty()) {
|
||||
utf8RemoveLastChar(truncated);
|
||||
if (measureTextWidth(font, truncated.c_str()) * scale + ellipsisWidth <= maxWidth) {
|
||||
return truncated + ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
return ellipsis;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool PlaceholderCoverGenerator::generate(const std::string& outputPath, const std::string& title,
|
||||
const std::string& author, int width, int height) {
|
||||
LOG_DBG("PHC", "Generating placeholder cover %dx%d: \"%s\" by \"%s\"", width, height, title.c_str(), author.c_str());
|
||||
|
||||
const EpdFontData* titleFont = &ubuntu_12_bold;
|
||||
const EpdFontData* authorFont = &ubuntu_10_regular;
|
||||
|
||||
PixelBuffer buf(width, height);
|
||||
if (!buf.isValid()) {
|
||||
LOG_ERR("PHC", "Failed to allocate %dx%d pixel buffer (%d bytes)", width, height,
|
||||
(width + 31) / 32 * 4 * height);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Proportional layout constants based on cover dimensions.
|
||||
// The device bezel covers ~2-3px on each edge, so we pad inward from the edge.
|
||||
const int edgePadding = std::max(3, width / 48); // ~10px at 480w, ~3px at 136w
|
||||
const int borderWidth = std::max(2, width / 96); // ~5px at 480w, ~2px at 136w
|
||||
const int innerPadding = std::max(4, width / 32); // ~15px at 480w, ~4px at 136w
|
||||
|
||||
// Text scaling: 2x for full-size covers, 1x for thumbnails
|
||||
const int titleScale = (height >= 600) ? 2 : 1;
|
||||
const int authorScale = (height >= 600) ? 2 : 1; // Author also larger on full covers
|
||||
// Icon: 2x for full cover, 1x for medium thumb, skip for small
|
||||
const int iconScale = (height >= 600) ? 2 : (height >= 350 ? 1 : 0);
|
||||
|
||||
// Draw border inset from edge
|
||||
buf.drawBorder(edgePadding, edgePadding, width - 2 * edgePadding, height - 2 * edgePadding, borderWidth);
|
||||
|
||||
// Content area (inside border + inner padding)
|
||||
const int contentX = edgePadding + borderWidth + innerPadding;
|
||||
const int contentY = edgePadding + borderWidth + innerPadding;
|
||||
const int contentW = width - 2 * contentX;
|
||||
const int contentH = height - 2 * contentY;
|
||||
|
||||
if (contentW <= 0 || contentH <= 0) {
|
||||
LOG_ERR("PHC", "Cover too small for content (%dx%d)", width, height);
|
||||
FsFile file;
|
||||
if (!Storage.openFileForWrite("PHC", outputPath, file)) {
|
||||
return false;
|
||||
}
|
||||
buf.writeBmp(file);
|
||||
file.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Layout zones ---
|
||||
// Title zone: top 2/3 of content area (icon + title)
|
||||
// Author zone: bottom 1/3 of content area
|
||||
const int titleZoneH = contentH * 2 / 3;
|
||||
const int authorZoneH = contentH - titleZoneH;
|
||||
const int authorZoneY = contentY + titleZoneH;
|
||||
|
||||
// --- Separator line at the zone boundary ---
|
||||
const int separatorWidth = contentW / 3;
|
||||
const int separatorX = contentX + (contentW - separatorWidth) / 2;
|
||||
buf.drawHLine(separatorX, authorZoneY, separatorWidth);
|
||||
|
||||
// --- Icon dimensions (needed for title text wrapping) ---
|
||||
const int iconW = (iconScale > 0) ? BOOK_ICON_WIDTH * iconScale : 0;
|
||||
const int iconGap = (iconScale > 0) ? std::max(8, width / 40) : 0; // Gap between icon and title text
|
||||
const int titleTextW = contentW - iconW - iconGap; // Title wraps in narrower area beside icon
|
||||
|
||||
// --- Prepare title text (wraps within the area to the right of the icon) ---
|
||||
const std::string displayTitle = title.empty() ? "Untitled" : title;
|
||||
auto titleLines = wrapText(titleFont, displayTitle, titleTextW, titleScale);
|
||||
|
||||
constexpr int MAX_TITLE_LINES = 5;
|
||||
if (static_cast<int>(titleLines.size()) > MAX_TITLE_LINES) {
|
||||
titleLines.resize(MAX_TITLE_LINES);
|
||||
titleLines.back() = truncateText(titleFont, titleLines.back(), titleTextW, titleScale);
|
||||
}
|
||||
|
||||
// --- Prepare author text (multi-line, max 3 lines) ---
|
||||
std::vector<std::string> authorLines;
|
||||
if (!author.empty()) {
|
||||
authorLines = wrapText(authorFont, author, contentW, authorScale);
|
||||
constexpr int MAX_AUTHOR_LINES = 3;
|
||||
if (static_cast<int>(authorLines.size()) > MAX_AUTHOR_LINES) {
|
||||
authorLines.resize(MAX_AUTHOR_LINES);
|
||||
authorLines.back() = truncateText(authorFont, authorLines.back(), contentW, authorScale);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Calculate title zone layout (icon LEFT of title) ---
|
||||
// Tighter line spacing so 2-3 title lines fit within the icon height
|
||||
const int titleLineH = titleFont->advanceY * titleScale * 3 / 4;
|
||||
const int iconH = (iconScale > 0) ? BOOK_ICON_HEIGHT * iconScale : 0;
|
||||
const int numTitleLines = static_cast<int>(titleLines.size());
|
||||
// Visual height: distance from top of first line to bottom of last line's glyphs.
|
||||
// Use ascender (not full advanceY) for the last line since trailing line-gap isn't visible.
|
||||
const int titleVisualH = (numTitleLines > 0)
|
||||
? (numTitleLines - 1) * titleLineH + titleFont->ascender * titleScale
|
||||
: 0;
|
||||
const int titleBlockH = std::max(iconH, titleVisualH); // Taller of icon or text
|
||||
|
||||
int titleStartY = contentY + (titleZoneH - titleBlockH) / 2;
|
||||
if (titleStartY < contentY) {
|
||||
titleStartY = contentY;
|
||||
}
|
||||
|
||||
// If title fits within icon height, center it vertically against the icon.
|
||||
// Otherwise top-align so extra lines overflow below.
|
||||
const int iconY = titleStartY;
|
||||
const int titleTextY = (iconH > 0 && titleVisualH <= iconH)
|
||||
? titleStartY + (iconH - titleVisualH) / 2
|
||||
: titleStartY;
|
||||
|
||||
// --- Horizontal centering: measure the widest title line, then center icon+gap+text block ---
|
||||
int maxTitleLineW = 0;
|
||||
for (const auto& line : titleLines) {
|
||||
const int w = measureTextWidth(titleFont, line.c_str()) * titleScale;
|
||||
if (w > maxTitleLineW) maxTitleLineW = w;
|
||||
}
|
||||
const int titleBlockW = iconW + iconGap + maxTitleLineW;
|
||||
const int titleBlockX = contentX + (contentW - titleBlockW) / 2;
|
||||
|
||||
// --- Draw icon ---
|
||||
if (iconScale > 0) {
|
||||
buf.drawIcon(BookIcon, BOOK_ICON_WIDTH, BOOK_ICON_HEIGHT, titleBlockX, iconY, iconScale);
|
||||
}
|
||||
|
||||
// --- Draw title lines (to the right of the icon) ---
|
||||
const int titleTextX = titleBlockX + iconW + iconGap;
|
||||
int currentY = titleTextY;
|
||||
for (const auto& line : titleLines) {
|
||||
buf.drawText(titleFont, titleTextX, currentY, line.c_str(), titleScale);
|
||||
currentY += titleLineH;
|
||||
}
|
||||
|
||||
// --- Draw author lines (centered vertically in bottom 1/3, centered horizontally) ---
|
||||
if (!authorLines.empty()) {
|
||||
const int authorLineH = authorFont->advanceY * authorScale;
|
||||
const int authorBlockH = static_cast<int>(authorLines.size()) * authorLineH;
|
||||
int authorStartY = authorZoneY + (authorZoneH - authorBlockH) / 2;
|
||||
if (authorStartY < authorZoneY + 4) {
|
||||
authorStartY = authorZoneY + 4; // Small gap below separator
|
||||
}
|
||||
|
||||
for (const auto& line : authorLines) {
|
||||
const int lineWidth = measureTextWidth(authorFont, line.c_str()) * authorScale;
|
||||
const int lineX = contentX + (contentW - lineWidth) / 2;
|
||||
buf.drawText(authorFont, lineX, authorStartY, line.c_str(), authorScale);
|
||||
authorStartY += authorLineH;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Write to file ---
|
||||
FsFile file;
|
||||
if (!Storage.openFileForWrite("PHC", outputPath, file)) {
|
||||
LOG_ERR("PHC", "Failed to open output file: %s", outputPath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
const bool success = buf.writeBmp(file);
|
||||
file.close();
|
||||
|
||||
if (success) {
|
||||
LOG_DBG("PHC", "Placeholder cover written to %s", outputPath.c_str());
|
||||
} else {
|
||||
LOG_ERR("PHC", "Failed to write placeholder BMP");
|
||||
Storage.remove(outputPath.c_str());
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
14
lib/PlaceholderCover/PlaceholderCoverGenerator.h
Normal file
14
lib/PlaceholderCover/PlaceholderCoverGenerator.h
Normal file
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
/// Generates simple 1-bit BMP placeholder covers with title/author text
|
||||
/// for books that have no embedded cover image.
|
||||
class PlaceholderCoverGenerator {
|
||||
public:
|
||||
/// Generate a placeholder cover BMP with title and author text.
|
||||
/// The BMP is written to outputPath as a 1-bit black-and-white image.
|
||||
/// Returns true if the file was written successfully.
|
||||
static bool generate(const std::string& outputPath, const std::string& title, const std::string& author, int width,
|
||||
int height);
|
||||
};
|
||||
@@ -97,6 +97,9 @@ std::string Txt::findCoverImage() const {
|
||||
|
||||
std::string Txt::getCoverBmpPath() const { return cachePath + "/cover.bmp"; }
|
||||
|
||||
std::string Txt::getThumbBmpPath() const { return cachePath + "/thumb_[HEIGHT].bmp"; }
|
||||
std::string Txt::getThumbBmpPath(int height) const { return cachePath + "/thumb_" + std::to_string(height) + ".bmp"; }
|
||||
|
||||
bool Txt::generateCoverBmp() const {
|
||||
// Already generated, return true
|
||||
if (Storage.exists(getCoverBmpPath().c_str())) {
|
||||
|
||||
@@ -28,6 +28,10 @@ class Txt {
|
||||
[[nodiscard]] bool generateCoverBmp() const;
|
||||
[[nodiscard]] std::string findCoverImage() const;
|
||||
|
||||
// Thumbnail paths (matching Epub/Xtc pattern for home screen covers)
|
||||
[[nodiscard]] std::string getThumbBmpPath() const;
|
||||
[[nodiscard]] std::string getThumbBmpPath(int height) const;
|
||||
|
||||
// Read content from file
|
||||
[[nodiscard]] bool readContent(uint8_t* buffer, size_t offset, size_t length) const;
|
||||
};
|
||||
|
||||
@@ -32,6 +32,13 @@ void HalDisplay::displayBuffer(HalDisplay::RefreshMode mode, bool turnOffScreen)
|
||||
einkDisplay.displayBuffer(convertRefreshMode(mode), turnOffScreen);
|
||||
}
|
||||
|
||||
// EXPERIMENTAL: Display only a rectangular region
|
||||
void HalDisplay::displayWindow(uint16_t x, uint16_t y, uint16_t w, uint16_t h,
|
||||
HalDisplay::RefreshMode mode, bool turnOffScreen) {
|
||||
(void)mode; // EInkDisplay::displayWindow does not take mode yet
|
||||
einkDisplay.displayWindow(x, y, w, h, turnOffScreen);
|
||||
}
|
||||
|
||||
void HalDisplay::refreshDisplay(HalDisplay::RefreshMode mode, bool turnOffScreen) {
|
||||
einkDisplay.refreshDisplay(convertRefreshMode(mode), turnOffScreen);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,10 @@ class HalDisplay {
|
||||
void displayBuffer(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false);
|
||||
void refreshDisplay(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false);
|
||||
|
||||
// EXPERIMENTAL: Display only a rectangular region
|
||||
void displayWindow(uint16_t x, uint16_t y, uint16_t w, uint16_t h,
|
||||
RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false);
|
||||
|
||||
// Power management
|
||||
void deepSleep();
|
||||
|
||||
|
||||
@@ -17,14 +17,14 @@ void HalPowerManager::setPowerSaving(bool enabled) {
|
||||
if (enabled && !isLowPower) {
|
||||
LOG_DBG("PWR", "Going to low-power mode");
|
||||
if (!setCpuFrequencyMhz(LOW_POWER_FREQ)) {
|
||||
LOG_ERR("PWR", "Failed to set low-power CPU frequency");
|
||||
LOG_DBG("PWR", "Failed to set CPU frequency = %d MHz", LOW_POWER_FREQ);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!enabled && isLowPower) {
|
||||
LOG_DBG("PWR", "Restoring normal CPU frequency");
|
||||
if (!setCpuFrequencyMhz(normalFreq)) {
|
||||
LOG_ERR("PWR", "Failed to restore normal CPU frequency");
|
||||
LOG_DBG("PWR", "Failed to set CPU frequency = %d MHz", normalFreq);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,15 @@ build_flags =
|
||||
# https://libexpat.github.io/doc/api/latest/#XML_GE
|
||||
-DXML_GE=0
|
||||
-DXML_CONTEXT_BYTES=1024
|
||||
-std=c++2a
|
||||
-std=gnu++2a
|
||||
# Enable UTF-8 long file names in SdFat
|
||||
-DUSE_UTF8_LONG_NAMES=1
|
||||
# Increase PNG scanline buffer to support up to 800px wide images
|
||||
# Default is (320*4+1)*2=2562, we need more for larger images
|
||||
-DPNG_MAX_BUFFERED_PIXELS=6402
|
||||
|
||||
build_unflags =
|
||||
-std=gnu++11
|
||||
|
||||
; Board configuration
|
||||
board_build.flash_mode = dio
|
||||
@@ -47,6 +53,7 @@ lib_deps =
|
||||
SDCardManager=symlink://open-x4-sdk/libs/hardware/SDCardManager
|
||||
bblanchon/ArduinoJson @ 7.4.2
|
||||
ricmoo/QRCode @ 0.0.1
|
||||
bitbank2/PNGdec @ ^1.0.0
|
||||
links2004/WebSockets @ 2.7.3
|
||||
|
||||
[env:default]
|
||||
@@ -65,6 +72,12 @@ extra_scripts =
|
||||
pre:scripts/inject_mod_version.py
|
||||
build_flags =
|
||||
${base.build_flags}
|
||||
-DOMIT_OPENDYSLEXIC
|
||||
-DOMIT_HYPH_DE
|
||||
-DOMIT_HYPH_ES
|
||||
-DOMIT_HYPH_FR
|
||||
-DOMIT_HYPH_IT
|
||||
-DOMIT_HYPH_RU
|
||||
-DENABLE_SERIAL_LOG
|
||||
-DLOG_LEVEL=2 ; Set log level to debug for mod builds
|
||||
|
||||
|
||||
123
scripts/generate_book_icon.py
Normal file
123
scripts/generate_book_icon.py
Normal file
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a 1-bit book icon bitmap as a C header for PlaceholderCoverGenerator.
|
||||
|
||||
The icon is a simplified closed book with a spine on the left and 3 text lines.
|
||||
Output format matches Logo120.h: MSB-first packed 1-bit, 0=black, 1=white.
|
||||
"""
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
import sys
|
||||
|
||||
|
||||
def generate_book_icon(size=48):
|
||||
"""Create a book icon at the given size."""
|
||||
img = Image.new("1", (size, size), 1) # White background
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Scale helper
|
||||
s = size / 48.0
|
||||
|
||||
# Book body (main rectangle, leaving room for spine and pages)
|
||||
body_left = int(6 * s)
|
||||
body_top = int(2 * s)
|
||||
body_right = int(42 * s)
|
||||
body_bottom = int(40 * s)
|
||||
|
||||
# Draw book body outline (2px thick)
|
||||
for i in range(int(2 * s)):
|
||||
draw.rectangle(
|
||||
[body_left + i, body_top + i, body_right - i, body_bottom - i], outline=0
|
||||
)
|
||||
|
||||
# Spine (thicker left edge)
|
||||
spine_width = int(4 * s)
|
||||
draw.rectangle([body_left, body_top, body_left + spine_width, body_bottom], fill=0)
|
||||
|
||||
# Pages at the bottom (slight offset from body)
|
||||
pages_top = body_bottom
|
||||
pages_bottom = int(44 * s)
|
||||
draw.rectangle(
|
||||
[body_left + int(2 * s), pages_top, body_right - int(1 * s), pages_bottom],
|
||||
outline=0,
|
||||
)
|
||||
# Page edges (a few lines)
|
||||
for i in range(3):
|
||||
y = pages_top + int((i + 1) * 1 * s)
|
||||
if y < pages_bottom:
|
||||
draw.line(
|
||||
[body_left + int(3 * s), y, body_right - int(2 * s), y], fill=0
|
||||
)
|
||||
|
||||
# Text lines on the book cover
|
||||
text_left = body_left + spine_width + int(4 * s)
|
||||
text_right = body_right - int(4 * s)
|
||||
line_thickness = max(1, int(1.5 * s))
|
||||
|
||||
text_lines_y = [int(12 * s), int(18 * s), int(24 * s)]
|
||||
text_widths = [1.0, 0.7, 0.85] # Relative widths for visual interest
|
||||
|
||||
for y, w_ratio in zip(text_lines_y, text_widths):
|
||||
line_right = text_left + int((text_right - text_left) * w_ratio)
|
||||
for t in range(line_thickness):
|
||||
draw.line([text_left, y + t, line_right, y + t], fill=0)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def image_to_c_array(img, name="BookIcon"):
|
||||
"""Convert a 1-bit PIL image to a C header array."""
|
||||
width, height = img.size
|
||||
pixels = img.load()
|
||||
|
||||
bytes_per_row = width // 8
|
||||
data = []
|
||||
|
||||
for y in range(height):
|
||||
for bx in range(bytes_per_row):
|
||||
byte = 0
|
||||
for bit in range(8):
|
||||
x = bx * 8 + bit
|
||||
if x < width:
|
||||
# 1 = white, 0 = black (matching Logo120.h convention)
|
||||
if pixels[x, y]:
|
||||
byte |= 1 << (7 - bit)
|
||||
data.append(byte)
|
||||
|
||||
# Format as C header
|
||||
lines = []
|
||||
lines.append("#pragma once")
|
||||
lines.append("#include <cstdint>")
|
||||
lines.append("")
|
||||
lines.append(f"// Book icon: {width}x{height}, 1-bit packed (MSB first)")
|
||||
lines.append(f"// 0 = black, 1 = white (same format as Logo120.h)")
|
||||
lines.append(f"static constexpr int BOOK_ICON_WIDTH = {width};")
|
||||
lines.append(f"static constexpr int BOOK_ICON_HEIGHT = {height};")
|
||||
lines.append(f"static const uint8_t {name}[] = {{")
|
||||
|
||||
# Format data in rows of 16 bytes
|
||||
for i in range(0, len(data), 16):
|
||||
chunk = data[i : i + 16]
|
||||
hex_str = ", ".join(f"0x{b:02x}" for b in chunk)
|
||||
lines.append(f" {hex_str},")
|
||||
|
||||
lines.append("};")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
size = int(sys.argv[1]) if len(sys.argv) > 1 else 48
|
||||
img = generate_book_icon(size)
|
||||
|
||||
# Save preview PNG
|
||||
preview_path = f"mod/book_icon_{size}x{size}.png"
|
||||
img.resize((size * 4, size * 4), Image.NEAREST).save(preview_path)
|
||||
print(f"Preview saved to {preview_path}", file=sys.stderr)
|
||||
|
||||
# Generate C header
|
||||
header = image_to_c_array(img, "BookIcon")
|
||||
output_path = "lib/PlaceholderCover/BookIcon.h"
|
||||
with open(output_path, "w") as f:
|
||||
f.write(header)
|
||||
print(f"C header saved to {output_path}", file=sys.stderr)
|
||||
501
scripts/generate_test_epub.py
Normal file
501
scripts/generate_test_epub.py
Normal file
@@ -0,0 +1,501 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate test EPUBs for image rendering verification.
|
||||
|
||||
Creates EPUBs with annotated JPEG and PNG images to verify:
|
||||
- Grayscale rendering (4 levels)
|
||||
- Image scaling
|
||||
- Image centering
|
||||
- Cache performance
|
||||
- Page serialization
|
||||
"""
|
||||
|
||||
import os
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except ImportError:
|
||||
print("Please install Pillow: pip install Pillow")
|
||||
exit(1)
|
||||
|
||||
OUTPUT_DIR = Path(__file__).parent.parent / "test" / "epubs"
|
||||
SCREEN_WIDTH = 480
|
||||
SCREEN_HEIGHT = 800
|
||||
|
||||
def get_font(size=20):
|
||||
"""Get a font, falling back to default if needed."""
|
||||
try:
|
||||
return ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", size)
|
||||
except:
|
||||
try:
|
||||
return ImageFont.truetype("/usr/share/fonts/TTF/DejaVuSans.ttf", size)
|
||||
except:
|
||||
return ImageFont.load_default()
|
||||
|
||||
def draw_text_centered(draw, y, text, font, fill=0):
|
||||
"""Draw centered text at given y position."""
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
x = (draw.im.size[0] - text_width) // 2
|
||||
draw.text((x, y), text, font=font, fill=fill)
|
||||
|
||||
def draw_text_wrapped(draw, x, y, text, font, max_width, fill=0):
|
||||
"""Draw text with word wrapping."""
|
||||
words = text.split()
|
||||
lines = []
|
||||
current_line = []
|
||||
|
||||
for word in words:
|
||||
test_line = ' '.join(current_line + [word])
|
||||
bbox = draw.textbbox((0, 0), test_line, font=font)
|
||||
if bbox[2] - bbox[0] <= max_width:
|
||||
current_line.append(word)
|
||||
else:
|
||||
if current_line:
|
||||
lines.append(' '.join(current_line))
|
||||
current_line = [word]
|
||||
if current_line:
|
||||
lines.append(' '.join(current_line))
|
||||
|
||||
line_height = font.size + 4 if hasattr(font, 'size') else 20
|
||||
for i, line in enumerate(lines):
|
||||
draw.text((x, y + i * line_height), line, font=font, fill=fill)
|
||||
|
||||
return len(lines) * line_height
|
||||
|
||||
def create_grayscale_test_image(filename, is_png=True):
|
||||
"""
|
||||
Create image with 4 grayscale squares to verify 4-level rendering.
|
||||
"""
|
||||
width, height = 400, 600
|
||||
img = Image.new('L', (width, height), 255)
|
||||
draw = ImageDraw.Draw(img)
|
||||
font = get_font(16)
|
||||
font_small = get_font(14)
|
||||
|
||||
# Title
|
||||
draw_text_centered(draw, 10, "GRAYSCALE TEST", font, fill=0)
|
||||
draw_text_centered(draw, 35, "Verify 4 distinct gray levels", font_small, fill=64)
|
||||
|
||||
# Draw 4 grayscale squares
|
||||
square_size = 70
|
||||
start_y = 65
|
||||
gap = 10
|
||||
|
||||
levels = [
|
||||
(0, "Level 0: BLACK"),
|
||||
(96, "Level 1: DARK GRAY"),
|
||||
(160, "Level 2: LIGHT GRAY"),
|
||||
(255, "Level 3: WHITE"),
|
||||
]
|
||||
|
||||
for i, (gray_value, label) in enumerate(levels):
|
||||
y = start_y + i * (square_size + gap + 22)
|
||||
x = (width - square_size) // 2
|
||||
|
||||
# Draw square with border
|
||||
draw.rectangle([x-2, y-2, x + square_size + 2, y + square_size + 2], fill=0)
|
||||
draw.rectangle([x, y, x + square_size, y + square_size], fill=gray_value)
|
||||
|
||||
# Label below square
|
||||
bbox = draw.textbbox((0, 0), label, font=font_small)
|
||||
label_width = bbox[2] - bbox[0]
|
||||
draw.text(((width - label_width) // 2, y + square_size + 5), label, font=font_small, fill=0)
|
||||
|
||||
# Instructions at bottom
|
||||
y = height - 70
|
||||
draw_text_centered(draw, y, "PASS: 4 distinct shades visible", font_small, fill=0)
|
||||
draw_text_centered(draw, y + 20, "FAIL: Only black/white or", font_small, fill=64)
|
||||
draw_text_centered(draw, y + 38, "muddy/indistinct grays", font_small, fill=64)
|
||||
|
||||
if is_png:
|
||||
img.save(filename, 'PNG')
|
||||
else:
|
||||
img.save(filename, 'JPEG', quality=95)
|
||||
|
||||
def create_centering_test_image(filename, is_png=True):
|
||||
"""
|
||||
Create image with border markers to verify centering.
|
||||
"""
|
||||
width, height = 350, 400
|
||||
img = Image.new('L', (width, height), 255)
|
||||
draw = ImageDraw.Draw(img)
|
||||
font = get_font(16)
|
||||
font_small = get_font(14)
|
||||
|
||||
# Draw border
|
||||
draw.rectangle([0, 0, width-1, height-1], outline=0, width=3)
|
||||
|
||||
# Corner markers
|
||||
marker_size = 20
|
||||
for x, y in [(0, 0), (width-marker_size, 0), (0, height-marker_size), (width-marker_size, height-marker_size)]:
|
||||
draw.rectangle([x, y, x+marker_size, y+marker_size], fill=0)
|
||||
|
||||
# Center cross
|
||||
cx, cy = width // 2, height // 2
|
||||
draw.line([cx - 30, cy, cx + 30, cy], fill=0, width=2)
|
||||
draw.line([cx, cy - 30, cx, cy + 30], fill=0, width=2)
|
||||
|
||||
# Title
|
||||
draw_text_centered(draw, 40, "CENTERING TEST", font, fill=0)
|
||||
|
||||
# Instructions
|
||||
y = 80
|
||||
draw_text_centered(draw, y, "Image should be centered", font_small, fill=0)
|
||||
draw_text_centered(draw, y + 20, "horizontally on screen", font_small, fill=0)
|
||||
|
||||
y = 150
|
||||
draw_text_centered(draw, y, "Check:", font_small, fill=0)
|
||||
draw_text_centered(draw, y + 25, "- Equal margins left & right", font_small, fill=64)
|
||||
draw_text_centered(draw, y + 45, "- All 4 corners visible", font_small, fill=64)
|
||||
draw_text_centered(draw, y + 65, "- Border is complete rectangle", font_small, fill=64)
|
||||
|
||||
# Pass/fail
|
||||
y = height - 80
|
||||
draw_text_centered(draw, y, "PASS: Centered, all corners visible", font_small, fill=0)
|
||||
draw_text_centered(draw, y + 20, "FAIL: Off-center or cropped", font_small, fill=64)
|
||||
|
||||
if is_png:
|
||||
img.save(filename, 'PNG')
|
||||
else:
|
||||
img.save(filename, 'JPEG', quality=95)
|
||||
|
||||
def create_scaling_test_image(filename, is_png=True):
|
||||
"""
|
||||
Create large image to verify scaling works.
|
||||
"""
|
||||
width, height = 1200, 1500
|
||||
img = Image.new('L', (width, height), 240)
|
||||
draw = ImageDraw.Draw(img)
|
||||
font = get_font(48)
|
||||
font_medium = get_font(32)
|
||||
font_small = get_font(24)
|
||||
|
||||
# Border
|
||||
draw.rectangle([0, 0, width-1, height-1], outline=0, width=8)
|
||||
draw.rectangle([20, 20, width-21, height-21], outline=128, width=4)
|
||||
|
||||
# Title
|
||||
draw_text_centered(draw, 60, "SCALING TEST", font, fill=0)
|
||||
draw_text_centered(draw, 130, f"Original: {width}x{height} (larger than screen)", font_medium, fill=64)
|
||||
|
||||
# Grid pattern
|
||||
grid_start_y = 220
|
||||
grid_size = 400
|
||||
cell_size = 50
|
||||
|
||||
draw_text_centered(draw, grid_start_y - 40, "Grid pattern (check for artifacts):", font_small, fill=0)
|
||||
|
||||
grid_x = (width - grid_size) // 2
|
||||
for row in range(grid_size // cell_size):
|
||||
for col in range(grid_size // cell_size):
|
||||
x = grid_x + col * cell_size
|
||||
y = grid_start_y + row * cell_size
|
||||
if (row + col) % 2 == 0:
|
||||
draw.rectangle([x, y, x + cell_size, y + cell_size], fill=0)
|
||||
else:
|
||||
draw.rectangle([x, y, x + cell_size, y + cell_size], fill=200)
|
||||
|
||||
# Pass/fail
|
||||
y = height - 100
|
||||
draw_text_centered(draw, y, "PASS: Scaled down, readable, complete", font_small, fill=0)
|
||||
draw_text_centered(draw, y + 30, "FAIL: Cropped, distorted, or unreadable", font_small, fill=64)
|
||||
|
||||
if is_png:
|
||||
img.save(filename, 'PNG')
|
||||
else:
|
||||
img.save(filename, 'JPEG', quality=95)
|
||||
|
||||
def create_cache_test_image(filename, page_num, is_png=True):
|
||||
"""
|
||||
Create image for cache performance testing.
|
||||
"""
|
||||
width, height = 400, 300
|
||||
img = Image.new('L', (width, height), 255)
|
||||
draw = ImageDraw.Draw(img)
|
||||
font = get_font(18)
|
||||
font_small = get_font(14)
|
||||
font_large = get_font(36)
|
||||
|
||||
# Border
|
||||
draw.rectangle([0, 0, width-1, height-1], outline=0, width=2)
|
||||
|
||||
# Page number prominent
|
||||
draw_text_centered(draw, 30, f"CACHE TEST PAGE {page_num}", font, fill=0)
|
||||
draw_text_centered(draw, 80, f"#{page_num}", font_large, fill=0)
|
||||
|
||||
# Instructions
|
||||
y = 140
|
||||
draw_text_centered(draw, y, "Navigate away then return", font_small, fill=64)
|
||||
draw_text_centered(draw, y + 25, "Second load should be faster", font_small, fill=64)
|
||||
|
||||
y = 220
|
||||
draw_text_centered(draw, y, "PASS: Faster reload from cache", font_small, fill=0)
|
||||
draw_text_centered(draw, y + 20, "FAIL: Same slow decode each time", font_small, fill=64)
|
||||
|
||||
if is_png:
|
||||
img.save(filename, 'PNG')
|
||||
else:
|
||||
img.save(filename, 'JPEG', quality=95)
|
||||
|
||||
def create_format_test_image(filename, format_name, is_png=True):
|
||||
"""
|
||||
Create simple image to verify format support.
|
||||
"""
|
||||
width, height = 350, 250
|
||||
img = Image.new('L', (width, height), 255)
|
||||
draw = ImageDraw.Draw(img)
|
||||
font = get_font(20)
|
||||
font_large = get_font(36)
|
||||
font_small = get_font(14)
|
||||
|
||||
# Border
|
||||
draw.rectangle([0, 0, width-1, height-1], outline=0, width=3)
|
||||
|
||||
# Format name
|
||||
draw_text_centered(draw, 30, f"{format_name} FORMAT TEST", font, fill=0)
|
||||
draw_text_centered(draw, 80, format_name, font_large, fill=0)
|
||||
|
||||
# Checkmark area
|
||||
y = 140
|
||||
draw_text_centered(draw, y, "If you can read this,", font_small, fill=64)
|
||||
draw_text_centered(draw, y + 20, f"{format_name} decoding works!", font_small, fill=64)
|
||||
|
||||
y = height - 40
|
||||
draw_text_centered(draw, y, f"PASS: {format_name} image visible", font_small, fill=0)
|
||||
|
||||
if is_png:
|
||||
img.save(filename, 'PNG')
|
||||
else:
|
||||
img.save(filename, 'JPEG', quality=95)
|
||||
|
||||
def create_epub(epub_path, title, chapters):
|
||||
"""
|
||||
Create an EPUB file with the given chapters.
|
||||
|
||||
chapters: list of (chapter_title, html_content, images)
|
||||
images: list of (image_filename, image_data)
|
||||
"""
|
||||
with zipfile.ZipFile(epub_path, 'w', zipfile.ZIP_DEFLATED) as epub:
|
||||
# mimetype (must be first, uncompressed)
|
||||
epub.writestr('mimetype', 'application/epub+zip', compress_type=zipfile.ZIP_STORED)
|
||||
|
||||
# Container
|
||||
container_xml = '''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles>
|
||||
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
|
||||
</rootfiles>
|
||||
</container>'''
|
||||
epub.writestr('META-INF/container.xml', container_xml)
|
||||
|
||||
# Collect all images and chapters
|
||||
manifest_items = []
|
||||
spine_items = []
|
||||
|
||||
# Add chapters and images
|
||||
for i, (chapter_title, html_content, images) in enumerate(chapters):
|
||||
chapter_id = f'chapter{i+1}'
|
||||
chapter_file = f'chapter{i+1}.xhtml'
|
||||
|
||||
# Add images for this chapter
|
||||
for img_filename, img_data in images:
|
||||
media_type = 'image/png' if img_filename.endswith('.png') else 'image/jpeg'
|
||||
manifest_items.append(f' <item id="{img_filename.replace(".", "_")}" href="images/{img_filename}" media-type="{media_type}"/>')
|
||||
epub.writestr(f'OEBPS/images/{img_filename}', img_data)
|
||||
|
||||
# Add chapter
|
||||
manifest_items.append(f' <item id="{chapter_id}" href="{chapter_file}" media-type="application/xhtml+xml"/>')
|
||||
spine_items.append(f' <itemref idref="{chapter_id}"/>')
|
||||
epub.writestr(f'OEBPS/{chapter_file}', html_content)
|
||||
|
||||
# content.opf
|
||||
content_opf = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:identifier id="uid">test-epub-{title.lower().replace(" ", "-")}</dc:identifier>
|
||||
<dc:title>{title}</dc:title>
|
||||
<dc:language>en</dc:language>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
|
||||
{chr(10).join(manifest_items)}
|
||||
</manifest>
|
||||
<spine>
|
||||
{chr(10).join(spine_items)}
|
||||
</spine>
|
||||
</package>'''
|
||||
epub.writestr('OEBPS/content.opf', content_opf)
|
||||
|
||||
# Navigation document
|
||||
nav_items = '\n'.join([f' <li><a href="chapter{i+1}.xhtml">{chapters[i][0]}</a></li>'
|
||||
for i in range(len(chapters))])
|
||||
nav_xhtml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
||||
<head><title>Navigation</title></head>
|
||||
<body>
|
||||
<nav epub:type="toc">
|
||||
<h1>Contents</h1>
|
||||
<ol>
|
||||
{nav_items}
|
||||
</ol>
|
||||
</nav>
|
||||
</body>
|
||||
</html>'''
|
||||
epub.writestr('OEBPS/nav.xhtml', nav_xhtml)
|
||||
|
||||
def make_chapter(title, body_content):
|
||||
"""Create XHTML chapter content."""
|
||||
return f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head><title>{title}</title></head>
|
||||
<body>
|
||||
<h1>{title}</h1>
|
||||
{body_content}
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
def main():
|
||||
OUTPUT_DIR.mkdir(exist_ok=True)
|
||||
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir = Path(tmpdir)
|
||||
|
||||
print("Generating test images...")
|
||||
|
||||
images = {}
|
||||
|
||||
# JPEG tests
|
||||
create_grayscale_test_image(tmpdir / 'grayscale_test.jpg', is_png=False)
|
||||
create_centering_test_image(tmpdir / 'centering_test.jpg', is_png=False)
|
||||
create_scaling_test_image(tmpdir / 'scaling_test.jpg', is_png=False)
|
||||
create_format_test_image(tmpdir / 'jpeg_format.jpg', 'JPEG', is_png=False)
|
||||
create_cache_test_image(tmpdir / 'cache_test_1.jpg', 1, is_png=False)
|
||||
create_cache_test_image(tmpdir / 'cache_test_2.jpg', 2, is_png=False)
|
||||
|
||||
# PNG tests
|
||||
create_grayscale_test_image(tmpdir / 'grayscale_test.png', is_png=True)
|
||||
create_centering_test_image(tmpdir / 'centering_test.png', is_png=True)
|
||||
create_scaling_test_image(tmpdir / 'scaling_test.png', is_png=True)
|
||||
create_format_test_image(tmpdir / 'png_format.png', 'PNG', is_png=True)
|
||||
create_cache_test_image(tmpdir / 'cache_test_1.png', 1, is_png=True)
|
||||
create_cache_test_image(tmpdir / 'cache_test_2.png', 2, is_png=True)
|
||||
|
||||
# Read all images
|
||||
for img_file in tmpdir.glob('*.*'):
|
||||
images[img_file.name] = img_file.read_bytes()
|
||||
|
||||
print("Creating JPEG test EPUB...")
|
||||
jpeg_chapters = [
|
||||
("Introduction", make_chapter("JPEG Image Tests", """
|
||||
<p>This EPUB tests JPEG image rendering.</p>
|
||||
<p>Navigate through chapters to verify each test case.</p>
|
||||
"""), []),
|
||||
("1. JPEG Format", make_chapter("JPEG Format Test", """
|
||||
<p>Basic JPEG decoding test.</p>
|
||||
<img src="images/jpeg_format.jpg" alt="JPEG format test"/>
|
||||
<p>If the image above is visible, JPEG decoding works.</p>
|
||||
"""), [('jpeg_format.jpg', images['jpeg_format.jpg'])]),
|
||||
("2. Grayscale", make_chapter("Grayscale Test", """
|
||||
<p>Verify 4 distinct gray levels are visible.</p>
|
||||
<img src="images/grayscale_test.jpg" alt="Grayscale test"/>
|
||||
"""), [('grayscale_test.jpg', images['grayscale_test.jpg'])]),
|
||||
("3. Centering", make_chapter("Centering Test", """
|
||||
<p>Verify image is centered horizontally.</p>
|
||||
<img src="images/centering_test.jpg" alt="Centering test"/>
|
||||
"""), [('centering_test.jpg', images['centering_test.jpg'])]),
|
||||
("4. Scaling", make_chapter("Scaling Test", """
|
||||
<p>This image is 1200x1500 pixels - larger than the screen.</p>
|
||||
<p>It should be scaled down to fit.</p>
|
||||
<img src="images/scaling_test.jpg" alt="Scaling test"/>
|
||||
"""), [('scaling_test.jpg', images['scaling_test.jpg'])]),
|
||||
("5. Cache Test A", make_chapter("Cache Test - Page A", """
|
||||
<p>First cache test page. Note the load time.</p>
|
||||
<img src="images/cache_test_1.jpg" alt="Cache test 1"/>
|
||||
<p>Navigate to next page, then come back.</p>
|
||||
"""), [('cache_test_1.jpg', images['cache_test_1.jpg'])]),
|
||||
("6. Cache Test B", make_chapter("Cache Test - Page B", """
|
||||
<p>Second cache test page.</p>
|
||||
<img src="images/cache_test_2.jpg" alt="Cache test 2"/>
|
||||
<p>Navigate back to Page A - it should load faster from cache.</p>
|
||||
"""), [('cache_test_2.jpg', images['cache_test_2.jpg'])]),
|
||||
]
|
||||
|
||||
create_epub(OUTPUT_DIR / 'test_jpeg_images.epub', 'JPEG Image Tests', jpeg_chapters)
|
||||
|
||||
print("Creating PNG test EPUB...")
|
||||
png_chapters = [
|
||||
("Introduction", make_chapter("PNG Image Tests", """
|
||||
<p>This EPUB tests PNG image rendering.</p>
|
||||
<p>Navigate through chapters to verify each test case.</p>
|
||||
"""), []),
|
||||
("1. PNG Format", make_chapter("PNG Format Test", """
|
||||
<p>Basic PNG decoding test.</p>
|
||||
<img src="images/png_format.png" alt="PNG format test"/>
|
||||
<p>If the image above is visible and no crash occurred, PNG decoding works.</p>
|
||||
"""), [('png_format.png', images['png_format.png'])]),
|
||||
("2. Grayscale", make_chapter("Grayscale Test", """
|
||||
<p>Verify 4 distinct gray levels are visible.</p>
|
||||
<img src="images/grayscale_test.png" alt="Grayscale test"/>
|
||||
"""), [('grayscale_test.png', images['grayscale_test.png'])]),
|
||||
("3. Centering", make_chapter("Centering Test", """
|
||||
<p>Verify image is centered horizontally.</p>
|
||||
<img src="images/centering_test.png" alt="Centering test"/>
|
||||
"""), [('centering_test.png', images['centering_test.png'])]),
|
||||
("4. Scaling", make_chapter("Scaling Test", """
|
||||
<p>This image is 1200x1500 pixels - larger than the screen.</p>
|
||||
<p>It should be scaled down to fit.</p>
|
||||
<img src="images/scaling_test.png" alt="Scaling test"/>
|
||||
"""), [('scaling_test.png', images['scaling_test.png'])]),
|
||||
("5. Cache Test A", make_chapter("Cache Test - Page A", """
|
||||
<p>First cache test page. Note the load time.</p>
|
||||
<img src="images/cache_test_1.png" alt="Cache test 1"/>
|
||||
<p>Navigate to next page, then come back.</p>
|
||||
"""), [('cache_test_1.png', images['cache_test_1.png'])]),
|
||||
("6. Cache Test B", make_chapter("Cache Test - Page B", """
|
||||
<p>Second cache test page.</p>
|
||||
<img src="images/cache_test_2.png" alt="Cache test 2"/>
|
||||
<p>Navigate back to Page A - it should load faster from cache.</p>
|
||||
"""), [('cache_test_2.png', images['cache_test_2.png'])]),
|
||||
]
|
||||
|
||||
create_epub(OUTPUT_DIR / 'test_png_images.epub', 'PNG Image Tests', png_chapters)
|
||||
|
||||
print("Creating mixed format test EPUB...")
|
||||
mixed_chapters = [
|
||||
("Introduction", make_chapter("Mixed Image Format Tests", """
|
||||
<p>This EPUB contains both JPEG and PNG images.</p>
|
||||
<p>Tests format detection and mixed rendering.</p>
|
||||
"""), []),
|
||||
("1. JPEG Image", make_chapter("JPEG in Mixed EPUB", """
|
||||
<p>This is a JPEG image:</p>
|
||||
<img src="images/jpeg_format.jpg" alt="JPEG"/>
|
||||
"""), [('jpeg_format.jpg', images['jpeg_format.jpg'])]),
|
||||
("2. PNG Image", make_chapter("PNG in Mixed EPUB", """
|
||||
<p>This is a PNG image:</p>
|
||||
<img src="images/png_format.png" alt="PNG"/>
|
||||
"""), [('png_format.png', images['png_format.png'])]),
|
||||
("3. Both Formats", make_chapter("Both Formats on One Page", """
|
||||
<p>JPEG image:</p>
|
||||
<img src="images/grayscale_test.jpg" alt="JPEG grayscale"/>
|
||||
<p>PNG image:</p>
|
||||
<img src="images/grayscale_test.png" alt="PNG grayscale"/>
|
||||
<p>Both should render with proper grayscale.</p>
|
||||
"""), [('grayscale_test.jpg', images['grayscale_test.jpg']),
|
||||
('grayscale_test.png', images['grayscale_test.png'])]),
|
||||
]
|
||||
|
||||
create_epub(OUTPUT_DIR / 'test_mixed_images.epub', 'Mixed Format Tests', mixed_chapters)
|
||||
|
||||
print(f"\nTest EPUBs created in: {OUTPUT_DIR}")
|
||||
print("Files:")
|
||||
for f in OUTPUT_DIR.glob('*.epub'):
|
||||
print(f" - {f.name}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
179
scripts/preview_placeholder_cover.py
Normal file
179
scripts/preview_placeholder_cover.py
Normal file
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a preview of the placeholder cover layout at full cover size (480x800).
|
||||
This mirrors the C++ PlaceholderCoverGenerator layout logic for visual verification.
|
||||
"""
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Reuse the book icon generator
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from generate_book_icon import generate_book_icon
|
||||
|
||||
|
||||
def create_preview(width=480, height=800, title="The Great Gatsby", author="F. Scott Fitzgerald"):
|
||||
img = Image.new("1", (width, height), 1) # White
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Proportional layout constants
|
||||
edge_padding = max(3, width // 48) # ~10px at 480w
|
||||
border_width = max(2, width // 96) # ~5px at 480w
|
||||
inner_padding = max(4, width // 32) # ~15px at 480w
|
||||
|
||||
title_scale = 2 if height >= 600 else 1
|
||||
author_scale = 2 if height >= 600 else 1 # Author also larger on full covers
|
||||
icon_scale = 2 if height >= 600 else (1 if height >= 350 else 0)
|
||||
|
||||
# Draw border inset from edge
|
||||
bx = edge_padding
|
||||
by = edge_padding
|
||||
bw = width - 2 * edge_padding
|
||||
bh = height - 2 * edge_padding
|
||||
for i in range(border_width):
|
||||
draw.rectangle([bx + i, by + i, bx + bw - 1 - i, by + bh - 1 - i], outline=0)
|
||||
|
||||
# Content area
|
||||
content_x = edge_padding + border_width + inner_padding
|
||||
content_y = edge_padding + border_width + inner_padding
|
||||
content_w = width - 2 * content_x
|
||||
content_h = height - 2 * content_y
|
||||
|
||||
# Zones
|
||||
title_zone_h = content_h * 2 // 3
|
||||
author_zone_h = content_h - title_zone_h
|
||||
author_zone_y = content_y + title_zone_h
|
||||
|
||||
# Separator
|
||||
sep_w = content_w // 3
|
||||
sep_x = content_x + (content_w - sep_w) // 2
|
||||
draw.line([sep_x, author_zone_y, sep_x + sep_w, author_zone_y], fill=0)
|
||||
|
||||
# Use a basic font for the preview (won't match exact Ubuntu metrics, but shows layout)
|
||||
try:
|
||||
title_font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 12 * title_scale)
|
||||
author_font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 10 * author_scale)
|
||||
except (OSError, IOError):
|
||||
title_font = ImageFont.load_default()
|
||||
author_font = ImageFont.load_default()
|
||||
|
||||
# Icon dimensions (needed for title text wrapping)
|
||||
icon_w_px = 48 * icon_scale if icon_scale > 0 else 0
|
||||
icon_h_px = 48 * icon_scale if icon_scale > 0 else 0
|
||||
icon_gap = max(8, width // 40) if icon_scale > 0 else 0
|
||||
title_text_w = content_w - icon_w_px - icon_gap # Title wraps in narrower area beside icon
|
||||
|
||||
# Wrap title (within the narrower area to the right of the icon)
|
||||
title_lines = []
|
||||
words = title.split()
|
||||
current_line = ""
|
||||
for word in words:
|
||||
test = f"{current_line} {word}".strip()
|
||||
bbox = draw.textbbox((0, 0), test, font=title_font)
|
||||
if bbox[2] - bbox[0] <= title_text_w:
|
||||
current_line = test
|
||||
else:
|
||||
if current_line:
|
||||
title_lines.append(current_line)
|
||||
current_line = word
|
||||
if current_line:
|
||||
title_lines.append(current_line)
|
||||
title_lines = title_lines[:5]
|
||||
|
||||
# Line spacing: 75% of advanceY (tighter so 2-3 lines fit within icon height)
|
||||
title_line_h = 29 * title_scale * 3 // 4 # Based on C++ ubuntu_12_bold advanceY
|
||||
|
||||
# Measure actual single-line height from the PIL font for accurate centering
|
||||
sample_bbox = draw.textbbox((0, 0), "Ag", font=title_font) # Tall + descender chars
|
||||
single_line_visual_h = sample_bbox[3] - sample_bbox[1]
|
||||
|
||||
# Visual height: line spacing between lines + actual height of last line's glyphs
|
||||
num_title_lines = len(title_lines)
|
||||
title_visual_h = (num_title_lines - 1) * title_line_h + single_line_visual_h if num_title_lines > 0 else 0
|
||||
title_block_h = max(icon_h_px, title_visual_h)
|
||||
|
||||
title_start_y = content_y + (title_zone_h - title_block_h) // 2
|
||||
if title_start_y < content_y:
|
||||
title_start_y = content_y
|
||||
|
||||
# If title fits within icon height, center it vertically against the icon.
|
||||
# Otherwise top-align so extra lines overflow below.
|
||||
icon_y = title_start_y
|
||||
if icon_h_px > 0 and title_visual_h <= icon_h_px:
|
||||
title_text_y = title_start_y + (icon_h_px - title_visual_h) // 2
|
||||
else:
|
||||
title_text_y = title_start_y
|
||||
|
||||
# Horizontal centering: measure widest title line, center icon+gap+text block
|
||||
max_title_line_w = 0
|
||||
for line in title_lines:
|
||||
bbox = draw.textbbox((0, 0), line, font=title_font)
|
||||
w = bbox[2] - bbox[0]
|
||||
if w > max_title_line_w:
|
||||
max_title_line_w = w
|
||||
title_block_w = icon_w_px + icon_gap + max_title_line_w
|
||||
title_block_x = content_x + (content_w - title_block_w) // 2
|
||||
|
||||
# Draw icon
|
||||
if icon_scale > 0:
|
||||
icon_img = generate_book_icon(48)
|
||||
scaled_icon = icon_img.resize((icon_w_px, icon_h_px), Image.NEAREST)
|
||||
for iy in range(scaled_icon.height):
|
||||
for ix in range(scaled_icon.width):
|
||||
if not scaled_icon.getpixel((ix, iy)):
|
||||
img.putpixel((title_block_x + ix, icon_y + iy), 0)
|
||||
|
||||
# Draw title (to the right of the icon)
|
||||
title_text_x = title_block_x + icon_w_px + icon_gap
|
||||
current_y = title_text_y
|
||||
for line in title_lines:
|
||||
draw.text((title_text_x, current_y), line, fill=0, font=title_font)
|
||||
current_y += title_line_h
|
||||
|
||||
# Wrap author
|
||||
author_lines = []
|
||||
words = author.split()
|
||||
current_line = ""
|
||||
for word in words:
|
||||
test = f"{current_line} {word}".strip()
|
||||
bbox = draw.textbbox((0, 0), test, font=author_font)
|
||||
if bbox[2] - bbox[0] <= content_w:
|
||||
current_line = test
|
||||
else:
|
||||
if current_line:
|
||||
author_lines.append(current_line)
|
||||
current_line = word
|
||||
if current_line:
|
||||
author_lines.append(current_line)
|
||||
author_lines = author_lines[:3]
|
||||
|
||||
# Draw author centered in bottom 1/3
|
||||
author_line_h = 24 * author_scale # Ubuntu 10 regular advanceY ~24
|
||||
author_block_h = len(author_lines) * author_line_h
|
||||
author_start_y = author_zone_y + (author_zone_h - author_block_h) // 2
|
||||
|
||||
for line in author_lines:
|
||||
bbox = draw.textbbox((0, 0), line, font=author_font)
|
||||
line_w = bbox[2] - bbox[0]
|
||||
line_x = content_x + (content_w - line_w) // 2
|
||||
draw.text((line_x, author_start_y), line, fill=0, font=author_font)
|
||||
author_start_y += author_line_h
|
||||
|
||||
return img
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Full cover
|
||||
img = create_preview(480, 800, "A Really Long Book Title That Should Wrap", "Jane Doe")
|
||||
img.save("mod/preview_cover_480x800.png")
|
||||
print("Saved mod/preview_cover_480x800.png", file=sys.stderr)
|
||||
|
||||
# Medium thumbnail
|
||||
img2 = create_preview(240, 400, "A Really Long Book Title That Should Wrap", "Jane Doe")
|
||||
img2.save("mod/preview_thumb_240x400.png")
|
||||
print("Saved mod/preview_thumb_240x400.png", file=sys.stderr)
|
||||
|
||||
# Small thumbnail
|
||||
img3 = create_preview(136, 226, "A Really Long Book Title", "Jane Doe")
|
||||
img3.save("mod/preview_thumb_136x226.png")
|
||||
print("Saved mod/preview_thumb_136x226.png", file=sys.stderr)
|
||||
@@ -244,8 +244,8 @@ bool CrossPointSettings::loadFromFile() {
|
||||
|
||||
float CrossPointSettings::getReaderLineCompression() const {
|
||||
switch (fontFamily) {
|
||||
#ifndef OMIT_BOOKERLY
|
||||
case BOOKERLY:
|
||||
default:
|
||||
switch (lineSpacing) {
|
||||
case TIGHT:
|
||||
return 0.95f;
|
||||
@@ -255,6 +255,8 @@ float CrossPointSettings::getReaderLineCompression() const {
|
||||
case WIDE:
|
||||
return 1.1f;
|
||||
}
|
||||
#endif // OMIT_BOOKERLY
|
||||
#ifndef OMIT_NOTOSANS
|
||||
case NOTOSANS:
|
||||
switch (lineSpacing) {
|
||||
case TIGHT:
|
||||
@@ -265,6 +267,8 @@ float CrossPointSettings::getReaderLineCompression() const {
|
||||
case WIDE:
|
||||
return 1.0f;
|
||||
}
|
||||
#endif // OMIT_NOTOSANS
|
||||
#ifndef OMIT_OPENDYSLEXIC
|
||||
case OPENDYSLEXIC:
|
||||
switch (lineSpacing) {
|
||||
case TIGHT:
|
||||
@@ -275,6 +279,30 @@ float CrossPointSettings::getReaderLineCompression() const {
|
||||
case WIDE:
|
||||
return 1.0f;
|
||||
}
|
||||
#endif // OMIT_OPENDYSLEXIC
|
||||
default:
|
||||
// Fallback: use Bookerly-style compression, or Noto Sans if Bookerly is omitted
|
||||
#if !defined(OMIT_BOOKERLY)
|
||||
switch (lineSpacing) {
|
||||
case TIGHT:
|
||||
return 0.95f;
|
||||
case NORMAL:
|
||||
default:
|
||||
return 1.0f;
|
||||
case WIDE:
|
||||
return 1.1f;
|
||||
}
|
||||
#else
|
||||
switch (lineSpacing) {
|
||||
case TIGHT:
|
||||
return 0.90f;
|
||||
case NORMAL:
|
||||
default:
|
||||
return 0.95f;
|
||||
case WIDE:
|
||||
return 1.0f;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,8 +340,8 @@ int CrossPointSettings::getRefreshFrequency() const {
|
||||
|
||||
int CrossPointSettings::getReaderFontId() const {
|
||||
switch (fontFamily) {
|
||||
#ifndef OMIT_BOOKERLY
|
||||
case BOOKERLY:
|
||||
default:
|
||||
switch (fontSize) {
|
||||
case SMALL:
|
||||
return BOOKERLY_12_FONT_ID;
|
||||
@@ -325,6 +353,8 @@ int CrossPointSettings::getReaderFontId() const {
|
||||
case EXTRA_LARGE:
|
||||
return BOOKERLY_18_FONT_ID;
|
||||
}
|
||||
#endif // OMIT_BOOKERLY
|
||||
#ifndef OMIT_NOTOSANS
|
||||
case NOTOSANS:
|
||||
switch (fontSize) {
|
||||
case SMALL:
|
||||
@@ -337,6 +367,8 @@ int CrossPointSettings::getReaderFontId() const {
|
||||
case EXTRA_LARGE:
|
||||
return NOTOSANS_18_FONT_ID;
|
||||
}
|
||||
#endif // OMIT_NOTOSANS
|
||||
#ifndef OMIT_OPENDYSLEXIC
|
||||
case OPENDYSLEXIC:
|
||||
switch (fontSize) {
|
||||
case SMALL:
|
||||
@@ -349,5 +381,17 @@ int CrossPointSettings::getReaderFontId() const {
|
||||
case EXTRA_LARGE:
|
||||
return OPENDYSLEXIC_14_FONT_ID;
|
||||
}
|
||||
#endif // OMIT_OPENDYSLEXIC
|
||||
default:
|
||||
// Fallback to first available font family at medium size
|
||||
#if !defined(OMIT_BOOKERLY)
|
||||
return BOOKERLY_14_FONT_ID;
|
||||
#elif !defined(OMIT_NOTOSANS)
|
||||
return NOTOSANS_14_FONT_ID;
|
||||
#elif !defined(OMIT_OPENDYSLEXIC)
|
||||
return OPENDYSLEXIC_10_FONT_ID;
|
||||
#else
|
||||
#error "At least one font family must be available"
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,15 @@ void RecentBooksStore::addBook(const std::string& path, const std::string& title
|
||||
saveToFile();
|
||||
}
|
||||
|
||||
void RecentBooksStore::removeBook(const std::string& path) {
|
||||
auto it =
|
||||
std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; });
|
||||
if (it != recentBooks.end()) {
|
||||
recentBooks.erase(it);
|
||||
saveToFile();
|
||||
}
|
||||
}
|
||||
|
||||
void RecentBooksStore::updateBook(const std::string& path, const std::string& title, const std::string& author,
|
||||
const std::string& coverBmpPath) {
|
||||
auto it =
|
||||
@@ -88,7 +97,7 @@ RecentBook RecentBooksStore::getDataFromBook(std::string path) const {
|
||||
// If epub, try to load the metadata for title/author and cover
|
||||
if (StringUtils::checkFileExtension(lastBookFileName, ".epub")) {
|
||||
Epub epub(path, "/.crosspoint");
|
||||
epub.load(false);
|
||||
epub.load(false, true);
|
||||
return RecentBook{path, epub.getTitle(), epub.getAuthor(), epub.getThumbBmpPath()};
|
||||
} else if (StringUtils::checkFileExtension(lastBookFileName, ".xtch") ||
|
||||
StringUtils::checkFileExtension(lastBookFileName, ".xtc")) {
|
||||
|
||||
@@ -30,6 +30,9 @@ class RecentBooksStore {
|
||||
void updateBook(const std::string& path, const std::string& title, const std::string& author,
|
||||
const std::string& coverBmpPath);
|
||||
|
||||
// Remove a book from the recent list by path
|
||||
void removeBook(const std::string& path);
|
||||
|
||||
// Get the list of recent books (most recent first)
|
||||
const std::vector<RecentBook>& getBooks() const { return recentBooks; }
|
||||
|
||||
|
||||
@@ -6,10 +6,36 @@
|
||||
#include "KOReaderCredentialStore.h"
|
||||
#include "activities/settings/SettingsActivity.h"
|
||||
|
||||
// Compile-time table of available font families and their enum values.
|
||||
// Used by the DynamicEnum getter/setter to map between list indices and stored FONT_FAMILY values.
|
||||
struct FontFamilyMapping {
|
||||
const char* name;
|
||||
uint8_t value;
|
||||
};
|
||||
inline constexpr FontFamilyMapping kFontFamilyMappings[] = {
|
||||
#ifndef OMIT_BOOKERLY
|
||||
{"Bookerly", CrossPointSettings::BOOKERLY},
|
||||
#endif
|
||||
#ifndef OMIT_NOTOSANS
|
||||
{"Noto Sans", CrossPointSettings::NOTOSANS},
|
||||
#endif
|
||||
#ifndef OMIT_OPENDYSLEXIC
|
||||
{"Open Dyslexic", CrossPointSettings::OPENDYSLEXIC},
|
||||
#endif
|
||||
};
|
||||
inline constexpr size_t kFontFamilyMappingCount = sizeof(kFontFamilyMappings) / sizeof(kFontFamilyMappings[0]);
|
||||
static_assert(kFontFamilyMappingCount > 0, "At least one font family must be available");
|
||||
|
||||
// Shared settings list used by both the device settings UI and the web settings API.
|
||||
// Each entry has a key (for JSON API) and category (for grouping).
|
||||
// ACTION-type entries and entries without a key are device-only.
|
||||
inline std::vector<SettingInfo> getSettingsList() {
|
||||
// Build font family options from the compile-time mapping table
|
||||
std::vector<std::string> fontFamilyOptions;
|
||||
for (size_t i = 0; i < kFontFamilyMappingCount; i++) {
|
||||
fontFamilyOptions.push_back(kFontFamilyMappings[i].name);
|
||||
}
|
||||
|
||||
return {
|
||||
// --- Display ---
|
||||
SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen,
|
||||
@@ -32,8 +58,20 @@ inline std::vector<SettingInfo> getSettingsList() {
|
||||
SettingInfo::Toggle("Sunlight Fading Fix", &CrossPointSettings::fadingFix, "fadingFix", "Display"),
|
||||
|
||||
// --- Reader ---
|
||||
SettingInfo::Enum("Font Family", &CrossPointSettings::fontFamily, {"Bookerly", "Noto Sans", "Open Dyslexic"},
|
||||
"fontFamily", "Reader"),
|
||||
SettingInfo::DynamicEnum(
|
||||
"Font Family", std::move(fontFamilyOptions),
|
||||
[]() -> uint8_t {
|
||||
for (uint8_t i = 0; i < kFontFamilyMappingCount; i++) {
|
||||
if (kFontFamilyMappings[i].value == SETTINGS.fontFamily) return i;
|
||||
}
|
||||
return 0; // fallback to first available family
|
||||
},
|
||||
[](uint8_t idx) {
|
||||
if (idx < kFontFamilyMappingCount) {
|
||||
SETTINGS.fontFamily = kFontFamilyMappings[idx].value;
|
||||
}
|
||||
},
|
||||
"fontFamily", "Reader"),
|
||||
SettingInfo::Enum("Font Size", &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}, "fontSize",
|
||||
"Reader"),
|
||||
SettingInfo::Enum("Line Spacing", &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}, "lineSpacing",
|
||||
|
||||
@@ -14,4 +14,6 @@ class ActivityWithSubactivity : public Activity {
|
||||
: Activity(std::move(name), renderer, mappedInput) {}
|
||||
void loop() override;
|
||||
void onExit() override;
|
||||
bool preventAutoSleep() override { return subActivity && subActivity->preventAutoSleep(); }
|
||||
bool skipLoopDelay() override { return subActivity && subActivity->skipLoopDelay(); }
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <PlaceholderCoverGenerator.h>
|
||||
#include <Serialization.h>
|
||||
#include <Txt.h>
|
||||
#include <Xtc.h>
|
||||
@@ -599,6 +600,11 @@ void SleepActivity::renderCoverSleepScreen() const {
|
||||
}
|
||||
|
||||
if (!lastXtc.generateCoverBmp()) {
|
||||
LOG_DBG("SLP", "XTC cover generation failed, trying placeholder");
|
||||
PlaceholderCoverGenerator::generate(lastXtc.getCoverBmpPath(), lastXtc.getTitle(), lastXtc.getAuthor(), 480, 800);
|
||||
}
|
||||
|
||||
if (!Storage.exists(lastXtc.getCoverBmpPath().c_str())) {
|
||||
LOG_ERR("SLP", "Failed to generate XTC cover bmp");
|
||||
return (this->*renderNoCoverSleepScreen)();
|
||||
}
|
||||
@@ -614,6 +620,11 @@ void SleepActivity::renderCoverSleepScreen() const {
|
||||
}
|
||||
|
||||
if (!lastTxt.generateCoverBmp()) {
|
||||
LOG_DBG("SLP", "TXT cover generation failed, trying placeholder");
|
||||
PlaceholderCoverGenerator::generate(lastTxt.getCoverBmpPath(), lastTxt.getTitle(), "", 480, 800);
|
||||
}
|
||||
|
||||
if (!Storage.exists(lastTxt.getCoverBmpPath().c_str())) {
|
||||
LOG_ERR("SLP", "No cover image found for TXT file");
|
||||
return (this->*renderNoCoverSleepScreen)();
|
||||
}
|
||||
@@ -630,6 +641,15 @@ void SleepActivity::renderCoverSleepScreen() const {
|
||||
}
|
||||
|
||||
if (!lastEpub.generateCoverBmp(cropped)) {
|
||||
LOG_DBG("SLP", "EPUB cover generation failed, trying placeholder");
|
||||
if (!PlaceholderCoverGenerator::generate(lastEpub.getCoverBmpPath(cropped), lastEpub.getTitle(),
|
||||
lastEpub.getAuthor(), 480, 800)) {
|
||||
LOG_DBG("SLP", "Placeholder generation failed, creating X-pattern marker");
|
||||
lastEpub.generateInvalidFormatCoverBmp(cropped);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Epub::isValidThumbnailBmp(lastEpub.getCoverBmpPath(cropped))) {
|
||||
LOG_ERR("SLP", "Failed to generate cover bmp");
|
||||
return (this->*renderNoCoverSleepScreen)();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Utf8.h>
|
||||
#include <PlaceholderCoverGenerator.h>
|
||||
#include <Xtc.h>
|
||||
|
||||
#include <cstring>
|
||||
@@ -64,46 +65,61 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
|
||||
for (RecentBook& book : recentBooks) {
|
||||
if (!book.coverBmpPath.empty()) {
|
||||
std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight);
|
||||
if (!Storage.exists(coverPath.c_str())) {
|
||||
// If epub, try to load the metadata for title/author and cover
|
||||
if (!Epub::isValidThumbnailBmp(coverPath)) {
|
||||
if (!showingLoading) {
|
||||
showingLoading = true;
|
||||
popupRect = GUI.drawPopup(renderer, "Loading...");
|
||||
}
|
||||
GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size()));
|
||||
|
||||
bool success = false;
|
||||
|
||||
// Try format-specific thumbnail generation first (Real Cover)
|
||||
if (StringUtils::checkFileExtension(book.path, ".epub")) {
|
||||
Epub epub(book.path, "/.crosspoint");
|
||||
// Skip loading css since we only need metadata here
|
||||
epub.load(false, true);
|
||||
|
||||
// Try to generate thumbnail image for Continue Reading card
|
||||
if (!showingLoading) {
|
||||
showingLoading = true;
|
||||
popupRect = GUI.drawPopup(renderer, "Loading...");
|
||||
// Try fast cache-only load first; only build cache if missing
|
||||
if (!epub.load(false, true)) {
|
||||
// Cache missing — build it (may take longer)
|
||||
epub.load(true, true);
|
||||
}
|
||||
GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size()));
|
||||
bool success = epub.generateThumbBmp(coverHeight);
|
||||
if (!success) {
|
||||
RECENT_BOOKS.updateBook(book.path, book.title, book.author, "");
|
||||
book.coverBmpPath = "";
|
||||
success = epub.generateThumbBmp(coverHeight);
|
||||
if (success) {
|
||||
const std::string thumbPath = epub.getThumbBmpPath(coverHeight);
|
||||
RECENT_BOOKS.updateBook(book.path, book.title, book.author, thumbPath);
|
||||
book.coverBmpPath = thumbPath;
|
||||
} else {
|
||||
// Fallback: generate a placeholder thumbnail with title/author
|
||||
const int thumbWidth = static_cast<int>(coverHeight * 0.6);
|
||||
success = PlaceholderCoverGenerator::generate(coverPath, book.title, book.author, thumbWidth, coverHeight);
|
||||
if (!success) {
|
||||
// Last resort: X-pattern marker to prevent repeated generation attempts
|
||||
epub.generateInvalidFormatThumbBmp(coverHeight);
|
||||
}
|
||||
}
|
||||
coverRendered = false;
|
||||
updateRequired = true;
|
||||
} else if (StringUtils::checkFileExtension(book.path, ".xtch") ||
|
||||
StringUtils::checkFileExtension(book.path, ".xtc")) {
|
||||
// Handle XTC file
|
||||
Xtc xtc(book.path, "/.crosspoint");
|
||||
if (xtc.load()) {
|
||||
// Try to generate thumbnail image for Continue Reading card
|
||||
if (!showingLoading) {
|
||||
showingLoading = true;
|
||||
popupRect = GUI.drawPopup(renderer, "Loading...");
|
||||
success = xtc.generateThumbBmp(coverHeight);
|
||||
if (success) {
|
||||
const std::string thumbPath = xtc.getThumbBmpPath(coverHeight);
|
||||
RECENT_BOOKS.updateBook(book.path, book.title, book.author, thumbPath);
|
||||
book.coverBmpPath = thumbPath;
|
||||
}
|
||||
GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size()));
|
||||
bool success = xtc.generateThumbBmp(coverHeight);
|
||||
if (!success) {
|
||||
RECENT_BOOKS.updateBook(book.path, book.title, book.author, "");
|
||||
book.coverBmpPath = "";
|
||||
}
|
||||
coverRendered = false;
|
||||
updateRequired = true;
|
||||
}
|
||||
if (!success) {
|
||||
// Fallback: generate a placeholder thumbnail with title/author
|
||||
const int thumbWidth = static_cast<int>(coverHeight * 0.6);
|
||||
PlaceholderCoverGenerator::generate(coverPath, book.title, book.author, thumbWidth, coverHeight);
|
||||
}
|
||||
} else {
|
||||
// Unknown format: generate a placeholder thumbnail
|
||||
const int thumbWidth = static_cast<int>(coverHeight * 0.6);
|
||||
PlaceholderCoverGenerator::generate(coverPath, book.title, book.author, thumbWidth, coverHeight);
|
||||
}
|
||||
|
||||
coverRendered = false;
|
||||
updateRequired = true;
|
||||
}
|
||||
}
|
||||
progress++;
|
||||
|
||||
@@ -450,8 +450,16 @@ void DictionaryDefinitionActivity::loop() {
|
||||
updateRequired = true;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (onDone) {
|
||||
onDone();
|
||||
} else {
|
||||
onBack();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
@@ -491,8 +499,8 @@ void DictionaryDefinitionActivity::renderScreen() {
|
||||
renderer.getScreenHeight() - 50, pageInfo.c_str());
|
||||
}
|
||||
|
||||
// Button hints (bottom face buttons — hide Confirm stub like Home Screen)
|
||||
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "", "\xC2\xAB Page", "Page \xC2\xBB");
|
||||
// Button hints (bottom face buttons)
|
||||
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", onDone ? "Done" : "", "\xC2\xAB Page", "Page \xC2\xBB");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
// Side button hints (drawn in portrait coordinates for correct placement)
|
||||
|
||||
@@ -14,13 +14,15 @@ class DictionaryDefinitionActivity final : public Activity {
|
||||
public:
|
||||
explicit DictionaryDefinitionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::string& headword, const std::string& definition, int readerFontId,
|
||||
uint8_t orientation, const std::function<void()>& onBack)
|
||||
uint8_t orientation, const std::function<void()>& onBack,
|
||||
const std::function<void()>& onDone = nullptr)
|
||||
: Activity("DictionaryDefinition", renderer, mappedInput),
|
||||
headword(headword),
|
||||
definition(definition),
|
||||
readerFontId(readerFontId),
|
||||
orientation(orientation),
|
||||
onBack(onBack) {}
|
||||
onBack(onBack),
|
||||
onDone(onDone) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
@@ -53,6 +55,7 @@ class DictionaryDefinitionActivity final : public Activity {
|
||||
int readerFontId;
|
||||
uint8_t orientation;
|
||||
const std::function<void()> onBack;
|
||||
const std::function<void()> onDone;
|
||||
|
||||
std::vector<std::vector<Segment>> wrappedLines;
|
||||
int currentPage = 0;
|
||||
|
||||
141
src/activities/reader/DictionarySuggestionsActivity.cpp
Normal file
141
src/activities/reader/DictionarySuggestionsActivity.cpp
Normal file
@@ -0,0 +1,141 @@
|
||||
#include "DictionarySuggestionsActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include "DictionaryDefinitionActivity.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/Dictionary.h"
|
||||
|
||||
void DictionarySuggestionsActivity::taskTrampoline(void* param) {
|
||||
auto* self = static_cast<DictionarySuggestionsActivity*>(param);
|
||||
self->displayTaskLoop();
|
||||
}
|
||||
|
||||
void DictionarySuggestionsActivity::displayTaskLoop() {
|
||||
while (true) {
|
||||
if (updateRequired && !subActivity) {
|
||||
updateRequired = false;
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
renderScreen();
|
||||
xSemaphoreGive(renderingMutex);
|
||||
}
|
||||
vTaskDelay(10 / portTICK_PERIOD_MS);
|
||||
}
|
||||
}
|
||||
|
||||
void DictionarySuggestionsActivity::onEnter() {
|
||||
ActivityWithSubactivity::onEnter();
|
||||
renderingMutex = xSemaphoreCreateMutex();
|
||||
updateRequired = true;
|
||||
xTaskCreate(&DictionarySuggestionsActivity::taskTrampoline, "DictSugTask", 4096, this, 1, &displayTaskHandle);
|
||||
}
|
||||
|
||||
void DictionarySuggestionsActivity::onExit() {
|
||||
ActivityWithSubactivity::onExit();
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
if (displayTaskHandle) {
|
||||
vTaskDelete(displayTaskHandle);
|
||||
displayTaskHandle = nullptr;
|
||||
}
|
||||
vSemaphoreDelete(renderingMutex);
|
||||
renderingMutex = nullptr;
|
||||
}
|
||||
|
||||
void DictionarySuggestionsActivity::loop() {
|
||||
if (subActivity) {
|
||||
subActivity->loop();
|
||||
if (pendingBackFromDef) {
|
||||
pendingBackFromDef = false;
|
||||
exitActivity();
|
||||
updateRequired = true;
|
||||
}
|
||||
if (pendingExitToReader) {
|
||||
pendingExitToReader = false;
|
||||
exitActivity();
|
||||
onDone();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (suggestions.empty()) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
onBack();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator.onNext([this] {
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(suggestions.size()));
|
||||
updateRequired = true;
|
||||
});
|
||||
|
||||
buttonNavigator.onPrevious([this] {
|
||||
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(suggestions.size()));
|
||||
updateRequired = true;
|
||||
});
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
const std::string& selected = suggestions[selectedIndex];
|
||||
std::string definition = Dictionary::lookup(selected);
|
||||
|
||||
if (definition.empty()) {
|
||||
GUI.drawPopup(renderer, "Not found");
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
vTaskDelay(1000 / portTICK_PERIOD_MS);
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
enterNewActivity(new DictionaryDefinitionActivity(
|
||||
renderer, mappedInput, selected, definition, readerFontId, orientation,
|
||||
[this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void DictionarySuggestionsActivity::renderScreen() {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto orient = renderer.getOrientation();
|
||||
const auto metrics = UITheme::getInstance().getMetrics();
|
||||
const bool isLandscapeCw = orient == GfxRenderer::Orientation::LandscapeClockwise;
|
||||
const bool isLandscapeCcw = orient == GfxRenderer::Orientation::LandscapeCounterClockwise;
|
||||
const bool isInverted = orient == GfxRenderer::Orientation::PortraitInverted;
|
||||
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? metrics.sideButtonHintsWidth : 0;
|
||||
const int hintGutterHeight = isInverted ? (metrics.buttonHintsHeight + metrics.verticalSpacing) : 0;
|
||||
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
|
||||
const int leftPadding = contentX + metrics.contentSidePadding;
|
||||
const int pageWidth = renderer.getScreenWidth();
|
||||
const int pageHeight = renderer.getScreenHeight();
|
||||
|
||||
// Header
|
||||
GUI.drawHeader(
|
||||
renderer,
|
||||
Rect{contentX, hintGutterHeight + metrics.topPadding, pageWidth - hintGutterWidth, metrics.headerHeight},
|
||||
"Did you mean?");
|
||||
|
||||
// Subtitle: the original word (manual, below header)
|
||||
const int subtitleY = hintGutterHeight + metrics.topPadding + metrics.headerHeight + 5;
|
||||
std::string subtitle = "\"" + originalWord + "\" not found";
|
||||
renderer.drawText(SMALL_FONT_ID, leftPadding, subtitleY, subtitle.c_str());
|
||||
|
||||
// Suggestion list
|
||||
const int listTop = subtitleY + 25;
|
||||
const int listHeight = pageHeight - listTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
|
||||
GUI.drawList(
|
||||
renderer, Rect{contentX, listTop, pageWidth - hintGutterWidth, listHeight}, suggestions.size(), selectedIndex,
|
||||
[this](int index) { return suggestions[index]; }, nullptr, nullptr, nullptr);
|
||||
|
||||
// Button hints
|
||||
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", "Up", "Down");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
}
|
||||
53
src/activities/reader/DictionarySuggestionsActivity.h
Normal file
53
src/activities/reader/DictionarySuggestionsActivity.h
Normal file
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../ActivityWithSubactivity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
class DictionarySuggestionsActivity final : public ActivityWithSubactivity {
|
||||
public:
|
||||
explicit DictionarySuggestionsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::string& originalWord, const std::vector<std::string>& suggestions,
|
||||
int readerFontId, uint8_t orientation, const std::string& cachePath,
|
||||
const std::function<void()>& onBack, const std::function<void()>& onDone)
|
||||
: ActivityWithSubactivity("DictionarySuggestions", renderer, mappedInput),
|
||||
originalWord(originalWord),
|
||||
suggestions(suggestions),
|
||||
readerFontId(readerFontId),
|
||||
orientation(orientation),
|
||||
cachePath(cachePath),
|
||||
onBack(onBack),
|
||||
onDone(onDone) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
|
||||
private:
|
||||
std::string originalWord;
|
||||
std::vector<std::string> suggestions;
|
||||
int readerFontId;
|
||||
uint8_t orientation;
|
||||
std::string cachePath;
|
||||
const std::function<void()> onBack;
|
||||
const std::function<void()> onDone;
|
||||
|
||||
int selectedIndex = 0;
|
||||
bool updateRequired = false;
|
||||
bool pendingBackFromDef = false;
|
||||
bool pendingExitToReader = false;
|
||||
ButtonNavigator buttonNavigator;
|
||||
|
||||
TaskHandle_t displayTaskHandle = nullptr;
|
||||
SemaphoreHandle_t renderingMutex = nullptr;
|
||||
|
||||
void renderScreen();
|
||||
static void taskTrampoline(void* param);
|
||||
[[noreturn]] void displayTaskLoop();
|
||||
};
|
||||
@@ -6,6 +6,8 @@
|
||||
#include <climits>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "DictionaryDefinitionActivity.h"
|
||||
#include "DictionarySuggestionsActivity.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
@@ -19,7 +21,7 @@ void DictionaryWordSelectActivity::taskTrampoline(void* param) {
|
||||
|
||||
void DictionaryWordSelectActivity::displayTaskLoop() {
|
||||
while (true) {
|
||||
if (updateRequired) {
|
||||
if (updateRequired && !subActivity) {
|
||||
updateRequired = false;
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
renderScreen();
|
||||
@@ -30,7 +32,7 @@ void DictionaryWordSelectActivity::displayTaskLoop() {
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
ActivityWithSubactivity::onEnter();
|
||||
renderingMutex = xSemaphoreCreateMutex();
|
||||
extractWords();
|
||||
mergeHyphenatedWords();
|
||||
@@ -43,7 +45,7 @@ void DictionaryWordSelectActivity::onEnter() {
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::onExit() {
|
||||
Activity::onExit();
|
||||
ActivityWithSubactivity::onExit();
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
if (displayTaskHandle) {
|
||||
vTaskDelete(displayTaskHandle);
|
||||
@@ -82,9 +84,55 @@ void DictionaryWordSelectActivity::extractWords() {
|
||||
while (wordIt != wordList.end() && xIt != xPosList.end()) {
|
||||
int16_t screenX = line->xPos + static_cast<int16_t>(*xIt) + marginLeft;
|
||||
int16_t screenY = line->yPos + marginTop;
|
||||
int16_t wordWidth = renderer.getTextWidth(fontId, wordIt->c_str());
|
||||
const std::string& wordText = *wordIt;
|
||||
|
||||
// Split on en-dash (U+2013: E2 80 93) and em-dash (U+2014: E2 80 94)
|
||||
std::vector<size_t> splitStarts;
|
||||
size_t partStart = 0;
|
||||
for (size_t i = 0; i < wordText.size();) {
|
||||
if (i + 2 < wordText.size() && static_cast<uint8_t>(wordText[i]) == 0xE2 &&
|
||||
static_cast<uint8_t>(wordText[i + 1]) == 0x80 &&
|
||||
(static_cast<uint8_t>(wordText[i + 2]) == 0x93 || static_cast<uint8_t>(wordText[i + 2]) == 0x94)) {
|
||||
if (i > partStart) splitStarts.push_back(partStart);
|
||||
i += 3;
|
||||
partStart = i;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
if (partStart < wordText.size()) splitStarts.push_back(partStart);
|
||||
|
||||
if (splitStarts.size() <= 1 && partStart == 0) {
|
||||
// No dashes found -- add as a single word
|
||||
int16_t wordWidth = renderer.getTextWidth(fontId, wordText.c_str());
|
||||
words.push_back({wordText, screenX, screenY, wordWidth, 0});
|
||||
} else {
|
||||
// Add each part as a separate selectable word
|
||||
for (size_t si = 0; si < splitStarts.size(); si++) {
|
||||
size_t start = splitStarts[si];
|
||||
size_t end = (si + 1 < splitStarts.size()) ? splitStarts[si + 1] : wordText.size();
|
||||
// Find actual end by trimming any trailing dash bytes
|
||||
size_t textEnd = end;
|
||||
while (textEnd > start && textEnd <= wordText.size()) {
|
||||
if (textEnd >= 3 && static_cast<uint8_t>(wordText[textEnd - 3]) == 0xE2 &&
|
||||
static_cast<uint8_t>(wordText[textEnd - 2]) == 0x80 &&
|
||||
(static_cast<uint8_t>(wordText[textEnd - 1]) == 0x93 ||
|
||||
static_cast<uint8_t>(wordText[textEnd - 1]) == 0x94)) {
|
||||
textEnd -= 3;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
std::string part = wordText.substr(start, textEnd - start);
|
||||
if (part.empty()) continue;
|
||||
|
||||
std::string prefix = wordText.substr(0, start);
|
||||
int16_t offsetX = prefix.empty() ? 0 : renderer.getTextWidth(fontId, prefix.c_str());
|
||||
int16_t partWidth = renderer.getTextWidth(fontId, part.c_str());
|
||||
words.push_back({part, static_cast<int16_t>(screenX + offsetX), screenY, partWidth, 0});
|
||||
}
|
||||
}
|
||||
|
||||
words.push_back({*wordIt, screenX, screenY, wordWidth, 0});
|
||||
++wordIt;
|
||||
++xIt;
|
||||
}
|
||||
@@ -146,11 +194,53 @@ void DictionaryWordSelectActivity::mergeHyphenatedWords() {
|
||||
words[nextWordIdx].continuationIndex = nextWordIdx; // self-ref so highlight logic finds the second part
|
||||
}
|
||||
|
||||
// Cross-page hyphenation: last word on page + first word of next page
|
||||
if (!nextPageFirstWord.empty() && !rows.empty()) {
|
||||
int lastWordIdx = rows.back().wordIndices.back();
|
||||
const std::string& lastWord = words[lastWordIdx].text;
|
||||
if (!lastWord.empty()) {
|
||||
bool endsWithHyphen = false;
|
||||
if (lastWord.back() == '-') {
|
||||
endsWithHyphen = true;
|
||||
} else if (lastWord.size() >= 2 && static_cast<uint8_t>(lastWord[lastWord.size() - 2]) == 0xC2 &&
|
||||
static_cast<uint8_t>(lastWord[lastWord.size() - 1]) == 0xAD) {
|
||||
endsWithHyphen = true;
|
||||
}
|
||||
if (endsWithHyphen) {
|
||||
std::string firstPart = lastWord;
|
||||
if (firstPart.back() == '-') {
|
||||
firstPart.pop_back();
|
||||
} else if (firstPart.size() >= 2 && static_cast<uint8_t>(firstPart[firstPart.size() - 2]) == 0xC2 &&
|
||||
static_cast<uint8_t>(firstPart[firstPart.size() - 1]) == 0xAD) {
|
||||
firstPart.erase(firstPart.size() - 2);
|
||||
}
|
||||
std::string merged = firstPart + nextPageFirstWord;
|
||||
words[lastWordIdx].lookupText = merged;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove empty rows that may result from merging (e.g., a row whose only word was a continuation)
|
||||
rows.erase(std::remove_if(rows.begin(), rows.end(), [](const Row& r) { return r.wordIndices.empty(); }), rows.end());
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::loop() {
|
||||
// Delegate to subactivity (definition/suggestions screen) if active
|
||||
if (subActivity) {
|
||||
subActivity->loop();
|
||||
if (pendingBackFromDef) {
|
||||
pendingBackFromDef = false;
|
||||
exitActivity();
|
||||
updateRequired = true;
|
||||
}
|
||||
if (pendingExitToReader) {
|
||||
pendingExitToReader = false;
|
||||
exitActivity();
|
||||
onBack();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (words.empty()) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
onBack();
|
||||
@@ -297,16 +387,40 @@ void DictionaryWordSelectActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (definition.empty()) {
|
||||
GUI.drawPopup(renderer, "Not found");
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
vTaskDelay(1500 / portTICK_PERIOD_MS);
|
||||
updateRequired = true;
|
||||
LookupHistory::addWord(cachePath, cleaned);
|
||||
|
||||
if (!definition.empty()) {
|
||||
enterNewActivity(new DictionaryDefinitionActivity(
|
||||
renderer, mappedInput, cleaned, definition, fontId, orientation,
|
||||
[this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; }));
|
||||
return;
|
||||
}
|
||||
|
||||
LookupHistory::addWord(cachePath, cleaned);
|
||||
onLookup(cleaned, definition);
|
||||
// Try stem variants (e.g., "jumped" -> "jump")
|
||||
auto stems = Dictionary::getStemVariants(cleaned);
|
||||
for (const auto& stem : stems) {
|
||||
std::string stemDef = Dictionary::lookup(stem);
|
||||
if (!stemDef.empty()) {
|
||||
enterNewActivity(new DictionaryDefinitionActivity(
|
||||
renderer, mappedInput, stem, stemDef, fontId, orientation,
|
||||
[this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Find similar words for suggestions
|
||||
auto similar = Dictionary::findSimilar(cleaned, 6);
|
||||
if (!similar.empty()) {
|
||||
enterNewActivity(new DictionarySuggestionsActivity(
|
||||
renderer, mappedInput, cleaned, similar, fontId, orientation, cachePath,
|
||||
[this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; }));
|
||||
return;
|
||||
}
|
||||
|
||||
GUI.drawPopup(renderer, "Not found");
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
vTaskDelay(1500 / portTICK_PERIOD_MS);
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,16 +9,16 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Activity.h"
|
||||
#include "../ActivityWithSubactivity.h"
|
||||
|
||||
class DictionaryWordSelectActivity final : public Activity {
|
||||
class DictionaryWordSelectActivity final : public ActivityWithSubactivity {
|
||||
public:
|
||||
explicit DictionaryWordSelectActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
std::unique_ptr<Page> page, int fontId, int marginLeft, int marginTop,
|
||||
const std::string& cachePath, uint8_t orientation,
|
||||
const std::function<void()>& onBack,
|
||||
const std::function<void(const std::string&, const std::string&)>& onLookup)
|
||||
: Activity("DictionaryWordSelect", renderer, mappedInput),
|
||||
const std::string& nextPageFirstWord = "")
|
||||
: ActivityWithSubactivity("DictionaryWordSelect", renderer, mappedInput),
|
||||
page(std::move(page)),
|
||||
fontId(fontId),
|
||||
marginLeft(marginLeft),
|
||||
@@ -26,7 +26,7 @@ class DictionaryWordSelectActivity final : public Activity {
|
||||
cachePath(cachePath),
|
||||
orientation(orientation),
|
||||
onBack(onBack),
|
||||
onLookup(onLookup) {}
|
||||
nextPageFirstWord(nextPageFirstWord) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
@@ -58,13 +58,15 @@ class DictionaryWordSelectActivity final : public Activity {
|
||||
std::string cachePath;
|
||||
uint8_t orientation;
|
||||
const std::function<void()> onBack;
|
||||
const std::function<void(const std::string&, const std::string&)> onLookup;
|
||||
std::string nextPageFirstWord;
|
||||
|
||||
std::vector<WordInfo> words;
|
||||
std::vector<Row> rows;
|
||||
int currentRow = 0;
|
||||
int currentWordInRow = 0;
|
||||
bool updateRequired = false;
|
||||
bool pendingBackFromDef = false;
|
||||
bool pendingExitToReader = false;
|
||||
|
||||
TaskHandle_t displayTaskHandle = nullptr;
|
||||
SemaphoreHandle_t renderingMutex = nullptr;
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include <PlaceholderCoverGenerator.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "EpubReaderBookmarkSelectionActivity.h"
|
||||
@@ -19,7 +21,11 @@
|
||||
#include "fontIds.h"
|
||||
#include "util/BookmarkStore.h"
|
||||
#include "util/Dictionary.h"
|
||||
#include "util/LookupHistory.h"
|
||||
|
||||
// Image refresh optimization strategy:
|
||||
// 0 = Use double FAST_REFRESH technique (default, feels snappier)
|
||||
// 1 = Use displayWindow() for partial refresh (experimental)
|
||||
#define USE_IMAGE_DOUBLE_FAST_REFRESH 0
|
||||
|
||||
namespace {
|
||||
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
|
||||
@@ -125,17 +131,42 @@ void EpubReaderActivity::onEnter() {
|
||||
GUI.fillPopupProgress(renderer, popupRect, completedSteps * 100 / totalSteps);
|
||||
};
|
||||
|
||||
if (!Storage.exists(epub->getCoverBmpPath(false).c_str())) {
|
||||
if (!Epub::isValidThumbnailBmp(epub->getCoverBmpPath(false))) {
|
||||
epub->generateCoverBmp(false);
|
||||
// Fallback: generate placeholder if real cover extraction failed
|
||||
if (!Epub::isValidThumbnailBmp(epub->getCoverBmpPath(false))) {
|
||||
if (!PlaceholderCoverGenerator::generate(epub->getCoverBmpPath(false), epub->getTitle(), epub->getAuthor(),
|
||||
480, 800)) {
|
||||
// Last resort: X-pattern marker
|
||||
epub->generateInvalidFormatCoverBmp(false);
|
||||
}
|
||||
}
|
||||
updateProgress();
|
||||
}
|
||||
if (!Storage.exists(epub->getCoverBmpPath(true).c_str())) {
|
||||
if (!Epub::isValidThumbnailBmp(epub->getCoverBmpPath(true))) {
|
||||
epub->generateCoverBmp(true);
|
||||
if (!Epub::isValidThumbnailBmp(epub->getCoverBmpPath(true))) {
|
||||
if (!PlaceholderCoverGenerator::generate(epub->getCoverBmpPath(true), epub->getTitle(), epub->getAuthor(),
|
||||
480, 800)) {
|
||||
// Last resort: X-pattern marker
|
||||
epub->generateInvalidFormatCoverBmp(true);
|
||||
}
|
||||
}
|
||||
updateProgress();
|
||||
}
|
||||
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
|
||||
if (!Storage.exists(epub->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) {
|
||||
if (!Epub::isValidThumbnailBmp(epub->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]))) {
|
||||
epub->generateThumbBmp(PRERENDER_THUMB_HEIGHTS[i]);
|
||||
// Fallback: generate placeholder thumbnail
|
||||
if (!Epub::isValidThumbnailBmp(epub->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]))) {
|
||||
const int thumbHeight = PRERENDER_THUMB_HEIGHTS[i];
|
||||
const int thumbWidth = static_cast<int>(thumbHeight * 0.6);
|
||||
if (!PlaceholderCoverGenerator::generate(epub->getThumbBmpPath(thumbHeight), epub->getTitle(),
|
||||
epub->getAuthor(), thumbWidth, thumbHeight)) {
|
||||
// Last resort: X-pattern marker
|
||||
epub->generateInvalidFormatThumbBmp(thumbHeight);
|
||||
}
|
||||
}
|
||||
updateProgress();
|
||||
}
|
||||
}
|
||||
@@ -665,24 +696,27 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
||||
const std::string bookCachePath = epub->getCachePath();
|
||||
const uint8_t currentOrientation = SETTINGS.orientation;
|
||||
|
||||
// Get first word of next page for cross-page hyphenation
|
||||
std::string nextPageFirstWord;
|
||||
if (section && section->currentPage < section->pageCount - 1) {
|
||||
int savedPage = section->currentPage;
|
||||
section->currentPage = savedPage + 1;
|
||||
auto nextPage = section->loadPageFromSectionFile();
|
||||
section->currentPage = savedPage;
|
||||
if (nextPage && !nextPage->elements.empty()) {
|
||||
const auto* firstLine = static_cast<const PageLine*>(nextPage->elements[0].get());
|
||||
if (firstLine->getBlock() && !firstLine->getBlock()->getWords().empty()) {
|
||||
nextPageFirstWord = firstLine->getBlock()->getWords().front();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exitActivity();
|
||||
|
||||
if (pageForLookup) {
|
||||
enterNewActivity(new DictionaryWordSelectActivity(
|
||||
renderer, mappedInput, std::move(pageForLookup), readerFontId, orientedMarginLeft, orientedMarginTop,
|
||||
bookCachePath, currentOrientation,
|
||||
[this]() {
|
||||
// On back from word select
|
||||
pendingSubactivityExit = true;
|
||||
},
|
||||
[this, bookCachePath, readerFontId, currentOrientation](const std::string& headword,
|
||||
const std::string& definition) {
|
||||
// On successful lookup - show definition
|
||||
exitActivity();
|
||||
enterNewActivity(new DictionaryDefinitionActivity(renderer, mappedInput, headword, definition,
|
||||
readerFontId, currentOrientation,
|
||||
[this]() { pendingSubactivityExit = true; }));
|
||||
}));
|
||||
bookCachePath, currentOrientation, [this]() { pendingSubactivityExit = true; }, nextPageFirstWord));
|
||||
}
|
||||
|
||||
xSemaphoreGive(renderingMutex);
|
||||
@@ -690,36 +724,11 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::LOOKED_UP_WORDS: {
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
const std::string bookCachePath = epub->getCachePath();
|
||||
const int readerFontId = SETTINGS.getReaderFontId();
|
||||
const uint8_t currentOrientation = SETTINGS.orientation;
|
||||
|
||||
exitActivity();
|
||||
enterNewActivity(new LookedUpWordsActivity(
|
||||
renderer, mappedInput, bookCachePath,
|
||||
[this]() {
|
||||
// On back from looked up words
|
||||
pendingSubactivityExit = true;
|
||||
},
|
||||
[this, bookCachePath, readerFontId, currentOrientation](const std::string& headword) {
|
||||
// Look up the word and show definition with progress bar
|
||||
Rect popupLayout = GUI.drawPopup(renderer, "Looking up...");
|
||||
|
||||
std::string definition = Dictionary::lookup(
|
||||
headword, [this, &popupLayout](int percent) { GUI.fillPopupProgress(renderer, popupLayout, percent); });
|
||||
|
||||
if (definition.empty()) {
|
||||
GUI.drawPopup(renderer, "Not found");
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
vTaskDelay(1500 / portTICK_PERIOD_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
exitActivity();
|
||||
enterNewActivity(new DictionaryDefinitionActivity(renderer, mappedInput, headword, definition, readerFontId,
|
||||
currentOrientation,
|
||||
[this]() { pendingSubactivityExit = true; }));
|
||||
}));
|
||||
renderer, mappedInput, epub->getCachePath(), SETTINGS.getReaderFontId(), SETTINGS.orientation,
|
||||
[this]() { pendingSubactivityExit = true; }, [this]() { pendingSubactivityExit = true; }));
|
||||
xSemaphoreGive(renderingMutex);
|
||||
break;
|
||||
}
|
||||
@@ -745,6 +754,9 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
||||
epub->setupCacheDir();
|
||||
|
||||
saveProgress(backupSpine, backupPage, backupPageCount);
|
||||
|
||||
// 5. Remove from recent books so the home screen doesn't show a stale/placeholder cover
|
||||
RECENT_BOOKS.removeBook(epub->getPath());
|
||||
}
|
||||
xSemaphoreGive(renderingMutex);
|
||||
// Defer go home to avoid race condition with display task
|
||||
@@ -866,6 +878,8 @@ void EpubReaderActivity::renderScreen() {
|
||||
}
|
||||
|
||||
if (!section) {
|
||||
loadingSection = true;
|
||||
|
||||
const auto filepath = epub->getSpineItem(currentSpineIndex).href;
|
||||
LOG_DBG("ERS", "Loading file: %s, index: %d", filepath.c_str(), currentSpineIndex);
|
||||
section = std::unique_ptr<Section>(new Section(epub, currentSpineIndex, renderer));
|
||||
@@ -885,6 +899,7 @@ void EpubReaderActivity::renderScreen() {
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, popupFn)) {
|
||||
LOG_ERR("ERS", "Failed to persist page data to SD");
|
||||
section.reset();
|
||||
loadingSection = false;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@@ -917,6 +932,8 @@ void EpubReaderActivity::renderScreen() {
|
||||
section->currentPage = newPage;
|
||||
pendingPercentJump = false;
|
||||
}
|
||||
|
||||
loadingSection = false;
|
||||
}
|
||||
|
||||
renderer.clearScreen();
|
||||
@@ -972,6 +989,14 @@ void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageC
|
||||
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int orientedMarginTop,
|
||||
const int orientedMarginRight, const int orientedMarginBottom,
|
||||
const int orientedMarginLeft) {
|
||||
// Determine if this page needs special image handling
|
||||
bool pageHasImages = page->hasImages();
|
||||
bool useAntiAliasing = SETTINGS.textAntiAliasing;
|
||||
|
||||
// Force half refresh for pages with images when anti-aliasing is on,
|
||||
// as grayscale tones require half refresh to display correctly
|
||||
bool forceFullRefresh = pageHasImages && useAntiAliasing;
|
||||
|
||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
|
||||
// Draw bookmark ribbon indicator in top-right corner if current page is bookmarked
|
||||
@@ -990,10 +1015,42 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
}
|
||||
|
||||
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||
|
||||
// Check if half-refresh is needed (either entering Reader or pages counter reached)
|
||||
if (pagesUntilFullRefresh <= 1) {
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
||||
} else if (forceFullRefresh) {
|
||||
// OPTIMIZATION: For image pages with anti-aliasing, use fast double-refresh technique
|
||||
// to reduce perceived lag. Only when pagesUntilFullRefresh > 1 (screen already clean).
|
||||
int imgX, imgY, imgW, imgH;
|
||||
if (page->getImageBoundingBox(imgX, imgY, imgW, imgH)) {
|
||||
int screenX = imgX + orientedMarginLeft;
|
||||
int screenY = imgY + orientedMarginTop;
|
||||
LOG_DBG("ERS", "Image page: fast double-refresh (page bbox: %d,%d %dx%d, screen: %d,%d %dx%d)",
|
||||
imgX, imgY, imgW, imgH, screenX, screenY, imgW, imgH);
|
||||
|
||||
#if USE_IMAGE_DOUBLE_FAST_REFRESH == 0
|
||||
// Method A: Fill blank area + two FAST_REFRESH operations
|
||||
renderer.fillRect(screenX, screenY, imgW, imgH, false);
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
#else
|
||||
// Method B (experimental): Use displayWindow() for partial refresh
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||
renderer.displayWindow(screenX, screenY, imgW, imgH, HalDisplay::FAST_REFRESH);
|
||||
#endif
|
||||
} else {
|
||||
LOG_DBG("ERS", "Image page but no bbox, using standard half refresh");
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
}
|
||||
pagesUntilFullRefresh--;
|
||||
} else {
|
||||
// Normal page without images, or images without anti-aliasing
|
||||
renderer.displayBuffer();
|
||||
pagesUntilFullRefresh--;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
#include <freertos/semphr.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include "DictionaryDefinitionActivity.h"
|
||||
#include "DictionaryWordSelectActivity.h"
|
||||
#include "EpubReaderMenuActivity.h"
|
||||
#include "LookedUpWordsActivity.h"
|
||||
@@ -30,6 +29,7 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
|
||||
bool pendingSubactivityExit = false; // Defer subactivity exit to avoid use-after-free
|
||||
bool pendingGoHome = false; // Defer go home to avoid race condition with display task
|
||||
bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit
|
||||
volatile bool loadingSection = false; // True during the entire !section block (read from main loop)
|
||||
const std::function<void()> onGoBack;
|
||||
const std::function<void()> onGoHome;
|
||||
|
||||
@@ -56,4 +56,10 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
// Defer low-power mode and auto-sleep while a section is loading/building.
|
||||
// !section covers the period before the Section object is created (including
|
||||
// cover prerendering in onEnter). loadingSection covers the full !section block
|
||||
// in renderScreen (including createSectionFile), during which section is non-null
|
||||
// but the section file is still being built.
|
||||
bool preventAutoSleep() override { return !section || loadingSection; }
|
||||
};
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "DictionaryDefinitionActivity.h"
|
||||
#include "DictionarySuggestionsActivity.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/Dictionary.h"
|
||||
#include "util/LookupHistory.h"
|
||||
|
||||
void LookedUpWordsActivity::taskTrampoline(void* param) {
|
||||
@@ -30,6 +33,7 @@ void LookedUpWordsActivity::onEnter() {
|
||||
ActivityWithSubactivity::onEnter();
|
||||
renderingMutex = xSemaphoreCreateMutex();
|
||||
words = LookupHistory::load(cachePath);
|
||||
std::reverse(words.begin(), words.end());
|
||||
updateRequired = true;
|
||||
xTaskCreate(&LookedUpWordsActivity::taskTrampoline, "LookedUpTask", 4096, this, 1, &displayTaskHandle);
|
||||
}
|
||||
@@ -48,6 +52,16 @@ void LookedUpWordsActivity::onExit() {
|
||||
void LookedUpWordsActivity::loop() {
|
||||
if (subActivity) {
|
||||
subActivity->loop();
|
||||
if (pendingBackFromDef) {
|
||||
pendingBackFromDef = false;
|
||||
exitActivity();
|
||||
updateRequired = true;
|
||||
}
|
||||
if (pendingExitToReader) {
|
||||
pendingExitToReader = false;
|
||||
exitActivity();
|
||||
onDone();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -94,18 +108,68 @@ void LookedUpWordsActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator.onNext([this] {
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(words.size()));
|
||||
const int totalItems = static_cast<int>(words.size());
|
||||
const int pageItems = getPageItems();
|
||||
|
||||
buttonNavigator.onNextRelease([this, totalItems] {
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, totalItems);
|
||||
updateRequired = true;
|
||||
});
|
||||
|
||||
buttonNavigator.onPrevious([this] {
|
||||
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(words.size()));
|
||||
buttonNavigator.onPreviousRelease([this, totalItems] {
|
||||
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, totalItems);
|
||||
updateRequired = true;
|
||||
});
|
||||
|
||||
buttonNavigator.onNextContinuous([this, totalItems, pageItems] {
|
||||
selectedIndex = ButtonNavigator::nextPageIndex(selectedIndex, totalItems, pageItems);
|
||||
updateRequired = true;
|
||||
});
|
||||
|
||||
buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] {
|
||||
selectedIndex = ButtonNavigator::previousPageIndex(selectedIndex, totalItems, pageItems);
|
||||
updateRequired = true;
|
||||
});
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
onSelectWord(words[selectedIndex]);
|
||||
const std::string& headword = words[selectedIndex];
|
||||
|
||||
Rect popupLayout = GUI.drawPopup(renderer, "Looking up...");
|
||||
std::string definition = Dictionary::lookup(
|
||||
headword, [this, &popupLayout](int percent) { GUI.fillPopupProgress(renderer, popupLayout, percent); });
|
||||
|
||||
if (!definition.empty()) {
|
||||
enterNewActivity(new DictionaryDefinitionActivity(
|
||||
renderer, mappedInput, headword, definition, readerFontId, orientation,
|
||||
[this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Try stem variants
|
||||
auto stems = Dictionary::getStemVariants(headword);
|
||||
for (const auto& stem : stems) {
|
||||
std::string stemDef = Dictionary::lookup(stem);
|
||||
if (!stemDef.empty()) {
|
||||
enterNewActivity(new DictionaryDefinitionActivity(
|
||||
renderer, mappedInput, stem, stemDef, readerFontId, orientation,
|
||||
[this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Show similar word suggestions
|
||||
auto similar = Dictionary::findSimilar(headword, 6);
|
||||
if (!similar.empty()) {
|
||||
enterNewActivity(new DictionarySuggestionsActivity(
|
||||
renderer, mappedInput, headword, similar, readerFontId, orientation, cachePath,
|
||||
[this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; }));
|
||||
return;
|
||||
}
|
||||
|
||||
GUI.drawPopup(renderer, "Not found");
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
vTaskDelay(1500 / portTICK_PERIOD_MS);
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -115,39 +179,46 @@ void LookedUpWordsActivity::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
int LookedUpWordsActivity::getPageItems() const {
|
||||
const auto orient = renderer.getOrientation();
|
||||
const auto metrics = UITheme::getInstance().getMetrics();
|
||||
const bool isInverted = orient == GfxRenderer::Orientation::PortraitInverted;
|
||||
const int hintGutterHeight = isInverted ? (metrics.buttonHintsHeight + metrics.verticalSpacing) : 0;
|
||||
const int contentTop = hintGutterHeight + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight =
|
||||
renderer.getScreenHeight() - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
|
||||
return std::max(1, contentHeight / metrics.listRowHeight);
|
||||
}
|
||||
|
||||
void LookedUpWordsActivity::renderScreen() {
|
||||
renderer.clearScreen();
|
||||
|
||||
constexpr int sidePadding = 20;
|
||||
constexpr int titleY = 15;
|
||||
constexpr int startY = 60;
|
||||
constexpr int lineHeight = 30;
|
||||
const auto orient = renderer.getOrientation();
|
||||
const auto metrics = UITheme::getInstance().getMetrics();
|
||||
const bool isLandscapeCw = orient == GfxRenderer::Orientation::LandscapeClockwise;
|
||||
const bool isLandscapeCcw = orient == GfxRenderer::Orientation::LandscapeCounterClockwise;
|
||||
const bool isInverted = orient == GfxRenderer::Orientation::PortraitInverted;
|
||||
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? metrics.sideButtonHintsWidth : 0;
|
||||
const int hintGutterHeight = isInverted ? (metrics.buttonHintsHeight + metrics.verticalSpacing) : 0;
|
||||
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
|
||||
const int pageWidth = renderer.getScreenWidth();
|
||||
const int pageHeight = renderer.getScreenHeight();
|
||||
|
||||
// Title
|
||||
const int titleX =
|
||||
(renderer.getScreenWidth() - renderer.getTextWidth(UI_12_FONT_ID, "Lookup History", EpdFontFamily::BOLD)) / 2;
|
||||
renderer.drawText(UI_12_FONT_ID, titleX, titleY, "Lookup History", true, EpdFontFamily::BOLD);
|
||||
// Header
|
||||
GUI.drawHeader(
|
||||
renderer,
|
||||
Rect{contentX, hintGutterHeight + metrics.topPadding, pageWidth - hintGutterWidth, metrics.headerHeight},
|
||||
"Lookup History");
|
||||
|
||||
const int contentTop = hintGutterHeight + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
|
||||
|
||||
if (words.empty()) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, 300, "No words looked up yet");
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, contentTop + 20, "No words looked up yet");
|
||||
} else {
|
||||
const int screenHeight = renderer.getScreenHeight();
|
||||
const int pageItems = std::max(1, (screenHeight - startY - 40) / lineHeight);
|
||||
const int pageStart = selectedIndex / pageItems * pageItems;
|
||||
|
||||
for (int i = 0; i < pageItems; i++) {
|
||||
int idx = pageStart + i;
|
||||
if (idx >= static_cast<int>(words.size())) break;
|
||||
|
||||
const int displayY = startY + i * lineHeight;
|
||||
const bool isSelected = (idx == selectedIndex);
|
||||
|
||||
if (isSelected) {
|
||||
renderer.fillRect(0, displayY - 2, renderer.getScreenWidth() - 1, lineHeight);
|
||||
}
|
||||
|
||||
renderer.drawText(UI_10_FONT_ID, sidePadding, displayY, words[idx].c_str(), !isSelected);
|
||||
}
|
||||
GUI.drawList(
|
||||
renderer, Rect{contentX, contentTop, pageWidth - hintGutterWidth, contentHeight}, words.size(), selectedIndex,
|
||||
[this](int index) { return words[index]; }, nullptr, nullptr, nullptr);
|
||||
}
|
||||
|
||||
if (deleteConfirmMode && pendingDeleteIndex < static_cast<int>(words.size())) {
|
||||
@@ -161,12 +232,12 @@ void LookedUpWordsActivity::renderScreen() {
|
||||
std::string msg = "Delete '" + displayWord + "'?";
|
||||
|
||||
constexpr int margin = 15;
|
||||
constexpr int popupY = 200;
|
||||
const int popupY = 200 + hintGutterHeight;
|
||||
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, msg.c_str(), EpdFontFamily::BOLD);
|
||||
const int textHeight = renderer.getLineHeight(UI_12_FONT_ID);
|
||||
const int w = textWidth + margin * 2;
|
||||
const int h = textHeight + margin * 2;
|
||||
const int x = (renderer.getScreenWidth() - w) / 2;
|
||||
const int x = contentX + (renderer.getScreenWidth() - hintGutterWidth - w) / 2;
|
||||
|
||||
renderer.fillRect(x - 2, popupY - 2, w + 4, h + 4, true);
|
||||
renderer.fillRect(x, popupY, w, h, false);
|
||||
@@ -183,12 +254,14 @@ void LookedUpWordsActivity::renderScreen() {
|
||||
if (!words.empty()) {
|
||||
const char* deleteHint = "Hold select to delete";
|
||||
const int hintWidth = renderer.getTextWidth(SMALL_FONT_ID, deleteHint);
|
||||
renderer.drawText(SMALL_FONT_ID, (renderer.getScreenWidth() - hintWidth) / 2, renderer.getScreenHeight() - 70,
|
||||
const int hintX = contentX + (renderer.getScreenWidth() - hintGutterWidth - hintWidth) / 2;
|
||||
renderer.drawText(SMALL_FONT_ID, hintX,
|
||||
renderer.getScreenHeight() - metrics.buttonHintsHeight - metrics.verticalSpacing * 2,
|
||||
deleteHint);
|
||||
}
|
||||
|
||||
// Normal button hints
|
||||
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", "^", "v");
|
||||
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", "Up", "Down");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,12 +13,14 @@
|
||||
class LookedUpWordsActivity final : public ActivityWithSubactivity {
|
||||
public:
|
||||
explicit LookedUpWordsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& cachePath,
|
||||
const std::function<void()>& onBack,
|
||||
const std::function<void(const std::string&)>& onSelectWord)
|
||||
int readerFontId, uint8_t orientation, const std::function<void()>& onBack,
|
||||
const std::function<void()>& onDone)
|
||||
: ActivityWithSubactivity("LookedUpWords", renderer, mappedInput),
|
||||
cachePath(cachePath),
|
||||
readerFontId(readerFontId),
|
||||
orientation(orientation),
|
||||
onBack(onBack),
|
||||
onSelectWord(onSelectWord) {}
|
||||
onDone(onDone) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
@@ -26,12 +28,16 @@ class LookedUpWordsActivity final : public ActivityWithSubactivity {
|
||||
|
||||
private:
|
||||
std::string cachePath;
|
||||
int readerFontId;
|
||||
uint8_t orientation;
|
||||
const std::function<void()> onBack;
|
||||
const std::function<void(const std::string&)> onSelectWord;
|
||||
const std::function<void()> onDone;
|
||||
|
||||
std::vector<std::string> words;
|
||||
int selectedIndex = 0;
|
||||
bool updateRequired = false;
|
||||
bool pendingBackFromDef = false;
|
||||
bool pendingExitToReader = false;
|
||||
ButtonNavigator buttonNavigator;
|
||||
|
||||
// Delete confirmation state
|
||||
@@ -42,6 +48,7 @@ class LookedUpWordsActivity final : public ActivityWithSubactivity {
|
||||
TaskHandle_t displayTaskHandle = nullptr;
|
||||
SemaphoreHandle_t renderingMutex = nullptr;
|
||||
|
||||
int getPageItems() const;
|
||||
void renderScreen();
|
||||
static void taskTrampoline(void* param);
|
||||
[[noreturn]] void displayTaskLoop();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <HalStorage.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "Epub.h"
|
||||
#include "EpubReaderActivity.h"
|
||||
#include "Txt.h"
|
||||
@@ -35,7 +36,7 @@ std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
|
||||
}
|
||||
|
||||
auto epub = std::unique_ptr<Epub>(new Epub(path, "/.crosspoint"));
|
||||
if (epub->load()) {
|
||||
if (epub->load(true, SETTINGS.embeddedStyle == 0)) {
|
||||
return epub;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
#include <Serialization.h>
|
||||
#include <Utf8.h>
|
||||
|
||||
#include <PlaceholderCoverGenerator.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "MappedInputManager.h"
|
||||
@@ -57,13 +59,43 @@ void TxtReaderActivity::onEnter() {
|
||||
|
||||
txt->setupCacheDir();
|
||||
|
||||
// Prerender cover on first open so the Sleep screen is instant.
|
||||
// generateCoverBmp() is a no-op if the file already exists, so this only does work once.
|
||||
// TXT has no thumbnail support, so only the sleep screen cover is generated.
|
||||
if (!Storage.exists(txt->getCoverBmpPath().c_str())) {
|
||||
Rect popupRect = GUI.drawPopup(renderer, "Preparing book...");
|
||||
txt->generateCoverBmp();
|
||||
GUI.fillPopupProgress(renderer, popupRect, 100);
|
||||
// Prerender covers and thumbnails on first open so Home and Sleep screens are instant.
|
||||
// Each generate* call is a no-op if the file already exists, so this only does work once.
|
||||
{
|
||||
int totalSteps = 0;
|
||||
if (!Storage.exists(txt->getCoverBmpPath().c_str())) totalSteps++;
|
||||
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
|
||||
if (!Storage.exists(txt->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) totalSteps++;
|
||||
}
|
||||
|
||||
if (totalSteps > 0) {
|
||||
Rect popupRect = GUI.drawPopup(renderer, "Preparing book...");
|
||||
int completedSteps = 0;
|
||||
|
||||
auto updateProgress = [&]() {
|
||||
completedSteps++;
|
||||
GUI.fillPopupProgress(renderer, popupRect, completedSteps * 100 / totalSteps);
|
||||
};
|
||||
|
||||
if (!Storage.exists(txt->getCoverBmpPath().c_str())) {
|
||||
const bool coverGenerated = txt->generateCoverBmp();
|
||||
// Fallback: generate placeholder if no cover image was found
|
||||
if (!coverGenerated) {
|
||||
PlaceholderCoverGenerator::generate(txt->getCoverBmpPath(), txt->getTitle(), "", 480, 800);
|
||||
}
|
||||
updateProgress();
|
||||
}
|
||||
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
|
||||
if (!Storage.exists(txt->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) {
|
||||
// TXT has no native thumbnail generation, always use placeholder
|
||||
const int thumbHeight = PRERENDER_THUMB_HEIGHTS[i];
|
||||
const int thumbWidth = static_cast<int>(thumbHeight * 0.6);
|
||||
PlaceholderCoverGenerator::generate(txt->getThumbBmpPath(thumbHeight), txt->getTitle(), "", thumbWidth,
|
||||
thumbHeight);
|
||||
updateProgress();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save current txt as last opened file and add to recent books
|
||||
@@ -71,7 +103,7 @@ void TxtReaderActivity::onEnter() {
|
||||
auto fileName = filePath.substr(filePath.rfind('/') + 1);
|
||||
APP_STATE.openEpubPath = filePath;
|
||||
APP_STATE.saveToFile();
|
||||
RECENT_BOOKS.addBook(filePath, fileName, "", "");
|
||||
RECENT_BOOKS.addBook(filePath, fileName, "", txt->getThumbBmpPath());
|
||||
|
||||
// Trigger first update
|
||||
updateRequired = true;
|
||||
|
||||
@@ -57,4 +57,7 @@ class TxtReaderActivity final : public ActivityWithSubactivity {
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
// Defer low-power mode and auto-sleep while the reader is initializing
|
||||
// (cover prerendering, page index building on first open).
|
||||
bool preventAutoSleep() override { return !initialized; }
|
||||
};
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
|
||||
#include <PlaceholderCoverGenerator.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "MappedInputManager.h"
|
||||
@@ -63,11 +65,22 @@ void XtcReaderActivity::onEnter() {
|
||||
|
||||
if (!Storage.exists(xtc->getCoverBmpPath().c_str())) {
|
||||
xtc->generateCoverBmp();
|
||||
// Fallback: generate placeholder if first-page cover extraction failed
|
||||
if (!Storage.exists(xtc->getCoverBmpPath().c_str())) {
|
||||
PlaceholderCoverGenerator::generate(xtc->getCoverBmpPath(), xtc->getTitle(), xtc->getAuthor(), 480, 800);
|
||||
}
|
||||
updateProgress();
|
||||
}
|
||||
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
|
||||
if (!Storage.exists(xtc->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) {
|
||||
xtc->generateThumbBmp(PRERENDER_THUMB_HEIGHTS[i]);
|
||||
// Fallback: generate placeholder thumbnail
|
||||
if (!Storage.exists(xtc->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) {
|
||||
const int thumbHeight = PRERENDER_THUMB_HEIGHTS[i];
|
||||
const int thumbWidth = static_cast<int>(thumbHeight * 0.6);
|
||||
PlaceholderCoverGenerator::generate(xtc->getThumbBmpPath(thumbHeight), xtc->getTitle(), xtc->getAuthor(),
|
||||
thumbWidth, thumbHeight);
|
||||
}
|
||||
updateProgress();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,6 +173,9 @@ void SettingsActivity::toggleCurrentSetting() {
|
||||
} else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
|
||||
const uint8_t currentValue = SETTINGS.*(setting.valuePtr);
|
||||
SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast<uint8_t>(setting.enumValues.size());
|
||||
} else if (setting.type == SettingType::ENUM && setting.valueGetter && setting.valueSetter) {
|
||||
const uint8_t currentValue = setting.valueGetter();
|
||||
setting.valueSetter((currentValue + 1) % static_cast<uint8_t>(setting.enumValues.size()));
|
||||
} else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {
|
||||
const int8_t currentValue = SETTINGS.*(setting.valuePtr);
|
||||
if (currentValue + setting.valueRange.step > setting.valueRange.max) {
|
||||
@@ -274,6 +277,11 @@ void SettingsActivity::render() const {
|
||||
} else if (settings[i].type == SettingType::ENUM && settings[i].valuePtr != nullptr) {
|
||||
const uint8_t value = SETTINGS.*(settings[i].valuePtr);
|
||||
valueText = settings[i].enumValues[value];
|
||||
} else if (settings[i].type == SettingType::ENUM && settings[i].valueGetter) {
|
||||
const uint8_t value = settings[i].valueGetter();
|
||||
if (value < settings[i].enumValues.size()) {
|
||||
valueText = settings[i].enumValues[value];
|
||||
}
|
||||
} else if (settings[i].type == SettingType::VALUE && settings[i].valuePtr != nullptr) {
|
||||
valueText = std::to_string(SETTINGS.*(settings[i].valuePtr));
|
||||
}
|
||||
|
||||
@@ -274,11 +274,10 @@ void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
|
||||
for (int i = 0; i < std::min(static_cast<int>(recentBooks.size()), LyraMetrics::values.homeRecentBooksCount);
|
||||
i++) {
|
||||
std::string coverPath = recentBooks[i].coverBmpPath;
|
||||
bool hasCover = true;
|
||||
int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i;
|
||||
if (coverPath.empty()) {
|
||||
hasCover = false;
|
||||
} else {
|
||||
renderer.drawRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection,
|
||||
tileWidth - 2 * hPaddingInSelection, LyraMetrics::values.homeCoverHeight);
|
||||
if (!coverPath.empty()) {
|
||||
const std::string coverBmpPath = UITheme::getCoverThumbPath(coverPath, LyraMetrics::values.homeCoverHeight);
|
||||
|
||||
// First time: load cover from SD and render
|
||||
@@ -292,20 +291,12 @@ void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
|
||||
const float tileRatio = static_cast<float>(tileWidth - 2 * hPaddingInSelection) /
|
||||
static_cast<float>(LyraMetrics::values.homeCoverHeight);
|
||||
float cropX = 1.0f - (tileRatio / ratio);
|
||||
|
||||
renderer.drawBitmap(bitmap, tileX + hPaddingInSelection, tileY + hPaddingInSelection,
|
||||
tileWidth - 2 * hPaddingInSelection, LyraMetrics::values.homeCoverHeight, cropX);
|
||||
} else {
|
||||
hasCover = false;
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasCover) {
|
||||
renderer.drawRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection,
|
||||
tileWidth - 2 * hPaddingInSelection, LyraMetrics::values.homeCoverHeight);
|
||||
}
|
||||
}
|
||||
|
||||
coverBufferStored = storeCoverBuffer();
|
||||
|
||||
23
src/main.cpp
23
src/main.cpp
@@ -39,13 +39,16 @@ GfxRenderer renderer(display);
|
||||
Activity* currentActivity;
|
||||
|
||||
// Fonts
|
||||
#ifndef OMIT_BOOKERLY
|
||||
EpdFont bookerly14RegularFont(&bookerly_14_regular);
|
||||
EpdFont bookerly14BoldFont(&bookerly_14_bold);
|
||||
EpdFont bookerly14ItalicFont(&bookerly_14_italic);
|
||||
EpdFont bookerly14BoldItalicFont(&bookerly_14_bolditalic);
|
||||
EpdFontFamily bookerly14FontFamily(&bookerly14RegularFont, &bookerly14BoldFont, &bookerly14ItalicFont,
|
||||
&bookerly14BoldItalicFont);
|
||||
#endif // OMIT_BOOKERLY
|
||||
#ifndef OMIT_FONTS
|
||||
#ifndef OMIT_BOOKERLY
|
||||
EpdFont bookerly12RegularFont(&bookerly_12_regular);
|
||||
EpdFont bookerly12BoldFont(&bookerly_12_bold);
|
||||
EpdFont bookerly12ItalicFont(&bookerly_12_italic);
|
||||
@@ -64,7 +67,9 @@ EpdFont bookerly18ItalicFont(&bookerly_18_italic);
|
||||
EpdFont bookerly18BoldItalicFont(&bookerly_18_bolditalic);
|
||||
EpdFontFamily bookerly18FontFamily(&bookerly18RegularFont, &bookerly18BoldFont, &bookerly18ItalicFont,
|
||||
&bookerly18BoldItalicFont);
|
||||
#endif // OMIT_BOOKERLY
|
||||
|
||||
#ifndef OMIT_NOTOSANS
|
||||
EpdFont notosans12RegularFont(¬osans_12_regular);
|
||||
EpdFont notosans12BoldFont(¬osans_12_bold);
|
||||
EpdFont notosans12ItalicFont(¬osans_12_italic);
|
||||
@@ -89,7 +94,9 @@ EpdFont notosans18ItalicFont(¬osans_18_italic);
|
||||
EpdFont notosans18BoldItalicFont(¬osans_18_bolditalic);
|
||||
EpdFontFamily notosans18FontFamily(¬osans18RegularFont, ¬osans18BoldFont, ¬osans18ItalicFont,
|
||||
¬osans18BoldItalicFont);
|
||||
#endif // OMIT_NOTOSANS
|
||||
|
||||
#ifndef OMIT_OPENDYSLEXIC
|
||||
EpdFont opendyslexic8RegularFont(&opendyslexic_8_regular);
|
||||
EpdFont opendyslexic8BoldFont(&opendyslexic_8_bold);
|
||||
EpdFont opendyslexic8ItalicFont(&opendyslexic_8_italic);
|
||||
@@ -114,6 +121,7 @@ EpdFont opendyslexic14ItalicFont(&opendyslexic_14_italic);
|
||||
EpdFont opendyslexic14BoldItalicFont(&opendyslexic_14_bolditalic);
|
||||
EpdFontFamily opendyslexic14FontFamily(&opendyslexic14RegularFont, &opendyslexic14BoldFont, &opendyslexic14ItalicFont,
|
||||
&opendyslexic14BoldItalicFont);
|
||||
#endif // OMIT_OPENDYSLEXIC
|
||||
#endif // OMIT_FONTS
|
||||
|
||||
EpdFont smallFont(¬osans_8_regular);
|
||||
@@ -259,20 +267,28 @@ void setupDisplayAndFonts() {
|
||||
display.begin();
|
||||
renderer.begin();
|
||||
LOG_DBG("MAIN", "Display initialized");
|
||||
#ifndef OMIT_BOOKERLY
|
||||
renderer.insertFont(BOOKERLY_14_FONT_ID, bookerly14FontFamily);
|
||||
#endif
|
||||
#ifndef OMIT_FONTS
|
||||
#ifndef OMIT_BOOKERLY
|
||||
renderer.insertFont(BOOKERLY_12_FONT_ID, bookerly12FontFamily);
|
||||
renderer.insertFont(BOOKERLY_16_FONT_ID, bookerly16FontFamily);
|
||||
renderer.insertFont(BOOKERLY_18_FONT_ID, bookerly18FontFamily);
|
||||
#endif // OMIT_BOOKERLY
|
||||
|
||||
#ifndef OMIT_NOTOSANS
|
||||
renderer.insertFont(NOTOSANS_12_FONT_ID, notosans12FontFamily);
|
||||
renderer.insertFont(NOTOSANS_14_FONT_ID, notosans14FontFamily);
|
||||
renderer.insertFont(NOTOSANS_16_FONT_ID, notosans16FontFamily);
|
||||
renderer.insertFont(NOTOSANS_18_FONT_ID, notosans18FontFamily);
|
||||
#endif // OMIT_NOTOSANS
|
||||
#ifndef OMIT_OPENDYSLEXIC
|
||||
renderer.insertFont(OPENDYSLEXIC_8_FONT_ID, opendyslexic8FontFamily);
|
||||
renderer.insertFont(OPENDYSLEXIC_10_FONT_ID, opendyslexic10FontFamily);
|
||||
renderer.insertFont(OPENDYSLEXIC_12_FONT_ID, opendyslexic12FontFamily);
|
||||
renderer.insertFont(OPENDYSLEXIC_14_FONT_ID, opendyslexic14FontFamily);
|
||||
#endif // OMIT_OPENDYSLEXIC
|
||||
#endif // OMIT_FONTS
|
||||
renderer.insertFont(UI_10_FONT_ID, ui10FontFamily);
|
||||
renderer.insertFont(UI_12_FONT_ID, ui12FontFamily);
|
||||
@@ -424,6 +440,13 @@ void loop() {
|
||||
}
|
||||
}
|
||||
|
||||
// Re-check preventAutoSleep: the activity may have changed during loop() above
|
||||
// (e.g., HomeActivity transitioned to EpubReaderActivity with pending section work).
|
||||
if (currentActivity && currentActivity->preventAutoSleep()) {
|
||||
lastActivityTime = millis();
|
||||
powerManager.setPowerSaving(false);
|
||||
}
|
||||
|
||||
// Add delay at the end of the loop to prevent tight spinning
|
||||
// When an activity requests skip loop delay (e.g., webserver running), use yield() for faster response
|
||||
// Otherwise, use longer delay to save power
|
||||
|
||||
@@ -326,3 +326,264 @@ std::string Dictionary::lookup(const std::string& word, const std::function<void
|
||||
if (onProgress) onProgress(100);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<std::string> Dictionary::getStemVariants(const std::string& word) {
|
||||
std::vector<std::string> variants;
|
||||
size_t len = word.size();
|
||||
if (len < 3) return variants;
|
||||
|
||||
auto endsWith = [&word, len](const char* suffix) {
|
||||
size_t slen = strlen(suffix);
|
||||
return len >= slen && word.compare(len - slen, slen, suffix) == 0;
|
||||
};
|
||||
|
||||
auto add = [&variants](const std::string& s) {
|
||||
if (s.size() >= 2) variants.push_back(s);
|
||||
};
|
||||
|
||||
// Plurals (longer suffixes first to avoid partial matches)
|
||||
if (endsWith("sses")) add(word.substr(0, len - 2));
|
||||
if (endsWith("ses")) add(word.substr(0, len - 2) + "is"); // analyses -> analysis
|
||||
if (endsWith("ies")) {
|
||||
add(word.substr(0, len - 3) + "y");
|
||||
add(word.substr(0, len - 2)); // dies -> die, ties -> tie
|
||||
}
|
||||
if (endsWith("ves")) {
|
||||
add(word.substr(0, len - 3) + "f"); // wolves -> wolf
|
||||
add(word.substr(0, len - 3) + "fe"); // knives -> knife
|
||||
add(word.substr(0, len - 1)); // misgives -> misgive
|
||||
}
|
||||
if (endsWith("men")) add(word.substr(0, len - 3) + "man"); // firemen -> fireman
|
||||
if (endsWith("es") && !endsWith("sses") && !endsWith("ies") && !endsWith("ves")) {
|
||||
add(word.substr(0, len - 2));
|
||||
add(word.substr(0, len - 1));
|
||||
}
|
||||
if (endsWith("s") && !endsWith("ss") && !endsWith("us") && !endsWith("es")) {
|
||||
add(word.substr(0, len - 1));
|
||||
}
|
||||
|
||||
// Past tense
|
||||
if (endsWith("ied")) {
|
||||
add(word.substr(0, len - 3) + "y");
|
||||
add(word.substr(0, len - 1));
|
||||
}
|
||||
if (endsWith("ed") && !endsWith("ied")) {
|
||||
add(word.substr(0, len - 2));
|
||||
add(word.substr(0, len - 1));
|
||||
if (len > 4 && word[len - 3] == word[len - 4]) {
|
||||
add(word.substr(0, len - 3));
|
||||
}
|
||||
}
|
||||
|
||||
// Progressive
|
||||
if (endsWith("ying")) {
|
||||
add(word.substr(0, len - 4) + "ie");
|
||||
}
|
||||
if (endsWith("ing") && !endsWith("ying")) {
|
||||
add(word.substr(0, len - 3));
|
||||
add(word.substr(0, len - 3) + "e");
|
||||
if (len > 5 && word[len - 4] == word[len - 5]) {
|
||||
add(word.substr(0, len - 4));
|
||||
}
|
||||
}
|
||||
|
||||
// Adverb
|
||||
if (endsWith("ically")) {
|
||||
add(word.substr(0, len - 6) + "ic"); // historically -> historic
|
||||
add(word.substr(0, len - 4)); // basically -> basic
|
||||
}
|
||||
if (endsWith("ally") && !endsWith("ically")) {
|
||||
add(word.substr(0, len - 4) + "al"); // accidentally -> accidental
|
||||
add(word.substr(0, len - 2)); // naturally -> natur... (fallback to -ly strip)
|
||||
}
|
||||
if (endsWith("ily") && !endsWith("ally")) {
|
||||
add(word.substr(0, len - 3) + "y");
|
||||
}
|
||||
if (endsWith("ly") && !endsWith("ily") && !endsWith("ally")) {
|
||||
add(word.substr(0, len - 2));
|
||||
}
|
||||
|
||||
// Comparative / superlative
|
||||
if (endsWith("ier")) {
|
||||
add(word.substr(0, len - 3) + "y");
|
||||
}
|
||||
if (endsWith("er") && !endsWith("ier")) {
|
||||
add(word.substr(0, len - 2));
|
||||
add(word.substr(0, len - 1));
|
||||
if (len > 4 && word[len - 3] == word[len - 4]) {
|
||||
add(word.substr(0, len - 3));
|
||||
}
|
||||
}
|
||||
if (endsWith("iest")) {
|
||||
add(word.substr(0, len - 4) + "y");
|
||||
}
|
||||
if (endsWith("est") && !endsWith("iest")) {
|
||||
add(word.substr(0, len - 3));
|
||||
add(word.substr(0, len - 2));
|
||||
if (len > 5 && word[len - 4] == word[len - 5]) {
|
||||
add(word.substr(0, len - 4));
|
||||
}
|
||||
}
|
||||
|
||||
// Derivational suffixes
|
||||
if (endsWith("ness")) add(word.substr(0, len - 4));
|
||||
if (endsWith("ment")) add(word.substr(0, len - 4));
|
||||
if (endsWith("ful")) add(word.substr(0, len - 3));
|
||||
if (endsWith("less")) add(word.substr(0, len - 4));
|
||||
if (endsWith("able")) {
|
||||
add(word.substr(0, len - 4));
|
||||
add(word.substr(0, len - 4) + "e");
|
||||
}
|
||||
if (endsWith("ible")) {
|
||||
add(word.substr(0, len - 4));
|
||||
add(word.substr(0, len - 4) + "e");
|
||||
}
|
||||
if (endsWith("ation")) {
|
||||
add(word.substr(0, len - 5)); // information -> inform
|
||||
add(word.substr(0, len - 5) + "e"); // exploration -> explore
|
||||
add(word.substr(0, len - 5) + "ate"); // donation -> donate
|
||||
}
|
||||
if (endsWith("tion") && !endsWith("ation")) {
|
||||
add(word.substr(0, len - 4) + "te"); // completion -> complete
|
||||
add(word.substr(0, len - 3)); // action -> act
|
||||
add(word.substr(0, len - 3) + "e"); // reduction -> reduce
|
||||
}
|
||||
if (endsWith("ion") && !endsWith("tion")) {
|
||||
add(word.substr(0, len - 3)); // revision -> revis (-> revise via +e)
|
||||
add(word.substr(0, len - 3) + "e"); // revision -> revise
|
||||
}
|
||||
if (endsWith("al") && !endsWith("ial")) {
|
||||
add(word.substr(0, len - 2));
|
||||
add(word.substr(0, len - 2) + "e");
|
||||
}
|
||||
if (endsWith("ial")) {
|
||||
add(word.substr(0, len - 3));
|
||||
add(word.substr(0, len - 3) + "e");
|
||||
}
|
||||
if (endsWith("ous")) {
|
||||
add(word.substr(0, len - 3)); // dangerous -> danger
|
||||
add(word.substr(0, len - 3) + "e"); // famous -> fame
|
||||
}
|
||||
if (endsWith("ive")) {
|
||||
add(word.substr(0, len - 3)); // active -> act
|
||||
add(word.substr(0, len - 3) + "e"); // creative -> create
|
||||
}
|
||||
if (endsWith("ize")) {
|
||||
add(word.substr(0, len - 3)); // modernize -> modern
|
||||
add(word.substr(0, len - 3) + "e");
|
||||
}
|
||||
if (endsWith("ise")) {
|
||||
add(word.substr(0, len - 3)); // advertise -> advert
|
||||
add(word.substr(0, len - 3) + "e");
|
||||
}
|
||||
if (endsWith("en")) {
|
||||
add(word.substr(0, len - 2)); // darken -> dark
|
||||
add(word.substr(0, len - 2) + "e"); // widen -> wide
|
||||
}
|
||||
|
||||
// Prefix removal
|
||||
if (len > 5 && word.compare(0, 2, "un") == 0) add(word.substr(2));
|
||||
if (len > 6 && word.compare(0, 3, "dis") == 0) add(word.substr(3));
|
||||
if (len > 6 && word.compare(0, 3, "mis") == 0) add(word.substr(3));
|
||||
if (len > 6 && word.compare(0, 3, "pre") == 0) add(word.substr(3));
|
||||
if (len > 7 && word.compare(0, 4, "over") == 0) add(word.substr(4));
|
||||
if (len > 5 && word.compare(0, 2, "re") == 0) add(word.substr(2));
|
||||
|
||||
// Deduplicate while preserving insertion order (inflectional stems first, prefixes last)
|
||||
std::vector<std::string> deduped;
|
||||
for (const auto& v : variants) {
|
||||
if (std::find(deduped.begin(), deduped.end(), v) != deduped.end()) continue;
|
||||
// cppcheck-suppress useStlAlgorithm
|
||||
deduped.push_back(v);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
int Dictionary::editDistance(const std::string& a, const std::string& b, int maxDist) {
|
||||
int m = static_cast<int>(a.size());
|
||||
int n = static_cast<int>(b.size());
|
||||
if (std::abs(m - n) > maxDist) return maxDist + 1;
|
||||
|
||||
std::vector<int> dp(n + 1);
|
||||
for (int j = 0; j <= n; j++) dp[j] = j;
|
||||
|
||||
for (int i = 1; i <= m; i++) {
|
||||
int prev = dp[0];
|
||||
dp[0] = i;
|
||||
int rowMin = dp[0];
|
||||
for (int j = 1; j <= n; j++) {
|
||||
int temp = dp[j];
|
||||
if (a[i - 1] == b[j - 1]) {
|
||||
dp[j] = prev;
|
||||
} else {
|
||||
dp[j] = 1 + std::min({prev, dp[j], dp[j - 1]});
|
||||
}
|
||||
prev = temp;
|
||||
if (dp[j] < rowMin) rowMin = dp[j];
|
||||
}
|
||||
if (rowMin > maxDist) return maxDist + 1;
|
||||
}
|
||||
return dp[n];
|
||||
}
|
||||
|
||||
std::vector<std::string> Dictionary::findSimilar(const std::string& word, int maxResults) {
|
||||
if (!indexLoaded || sparseOffsets.empty()) return {};
|
||||
|
||||
FsFile idx;
|
||||
if (!Storage.openFileForRead("DICT", IDX_PATH, idx)) return {};
|
||||
|
||||
// Binary search to find the segment containing or nearest to the word
|
||||
int lo = 0, hi = static_cast<int>(sparseOffsets.size()) - 1;
|
||||
while (lo < hi) {
|
||||
int mid = lo + (hi - lo + 1) / 2;
|
||||
idx.seekSet(sparseOffsets[mid]);
|
||||
std::string key = readWord(idx);
|
||||
if (stardictCmp(key.c_str(), word.c_str()) <= 0) {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Scan entries from the segment before through the segment after the target
|
||||
int startSeg = std::max(0, lo - 1);
|
||||
int endSeg = std::min(static_cast<int>(sparseOffsets.size()) - 1, lo + 1);
|
||||
idx.seekSet(sparseOffsets[startSeg]);
|
||||
|
||||
int totalToScan = (endSeg - startSeg + 1) * SPARSE_INTERVAL;
|
||||
int remaining = static_cast<int>(totalWords) - startSeg * SPARSE_INTERVAL;
|
||||
if (totalToScan > remaining) totalToScan = remaining;
|
||||
|
||||
int maxDist = std::max(2, static_cast<int>(word.size()) / 3 + 1);
|
||||
|
||||
struct Candidate {
|
||||
std::string text;
|
||||
int distance;
|
||||
};
|
||||
std::vector<Candidate> candidates;
|
||||
|
||||
for (int i = 0; i < totalToScan; i++) {
|
||||
std::string key = readWord(idx);
|
||||
if (key.empty()) break;
|
||||
|
||||
uint8_t skip[8];
|
||||
if (idx.read(skip, 8) != 8) break;
|
||||
|
||||
if (key == word) continue;
|
||||
int dist = editDistance(key, word, maxDist);
|
||||
if (dist <= maxDist) {
|
||||
candidates.push_back({key, dist});
|
||||
}
|
||||
}
|
||||
|
||||
idx.close();
|
||||
|
||||
std::sort(candidates.begin(), candidates.end(),
|
||||
[](const Candidate& a, const Candidate& b) { return a.distance < b.distance; });
|
||||
|
||||
std::vector<std::string> results;
|
||||
for (size_t i = 0; i < candidates.size() && static_cast<int>(results.size()) < maxResults; i++) {
|
||||
results.push_back(candidates[i].text);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ class Dictionary {
|
||||
static std::string lookup(const std::string& word, const std::function<void(int percent)>& onProgress = nullptr,
|
||||
const std::function<bool()>& shouldCancel = nullptr);
|
||||
static std::string cleanWord(const std::string& word);
|
||||
static std::vector<std::string> getStemVariants(const std::string& word);
|
||||
static std::vector<std::string> findSimilar(const std::string& word, int maxResults = 6);
|
||||
|
||||
private:
|
||||
static constexpr int SPARSE_INTERVAL = 512;
|
||||
@@ -28,4 +30,5 @@ class Dictionary {
|
||||
static std::string searchIndex(const std::string& word, const std::function<bool()>& shouldCancel);
|
||||
static std::string readWord(FsFile& file);
|
||||
static std::string readDefinition(uint32_t offset, uint32_t size);
|
||||
static int editDistance(const std::string& a, const std::string& b, int maxDist);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user