Compare commits
81 Commits
103fac2ee1
...
mod/sync-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
013a738144
|
||
|
|
c1d1e98909
|
||
|
|
6403ce6309
|
||
|
|
109f95df78
|
||
|
|
de981f5072
|
||
|
|
2bcc1c1495
|
||
|
|
aa7c0a882a
|
||
|
|
950faf4cd2
|
||
|
|
e5d574a07a
|
||
|
|
c8ddb6b61d
|
||
|
|
ef52af1a52
|
||
|
|
8a28755c69
|
||
|
|
4b713f40f1
|
||
|
|
ab5e18aca3
|
||
|
|
a8f0d63693
|
||
|
|
a8a89e35b8
|
||
|
|
724c1969b9
|
||
|
|
21b81bd177
|
||
|
|
b5c48af3b2
|
||
|
|
426a978e44
|
||
|
|
a1ac11ab51
|
||
|
|
7819cf0f77
|
||
|
|
3d7340ca6f
|
||
|
|
966fbef3d1
|
||
|
|
38a87298f3
|
||
|
|
ab4540b26f
|
||
|
|
7e15c9835f
|
||
|
|
7b3de29c59
|
||
|
|
1d7971ae60
|
||
|
|
61fb11cae3
|
||
|
|
424e332c75
|
||
|
|
f21720dc79
|
||
|
|
a9f5149444
|
||
|
|
0222cbf19b
|
||
|
|
02f2474e3b
|
||
|
|
f06e3a0a82
|
||
|
|
a585f219f4
|
||
|
|
df6cc637ec
|
||
|
|
4cfe155488
|
||
|
|
f1966f1e26
|
||
|
|
ebcd3a8b94
|
||
|
|
ed8a0feac1
|
||
|
|
12cc7de49e
|
||
|
|
f622e87c10
|
||
|
|
24c1df0308
|
||
|
|
6cc68e828a
|
||
|
|
6097ee03df
|
||
|
|
d11ad45e59
|
||
|
|
b965ce9fb7
|
||
|
|
744d6160e8
|
||
|
|
66f703df69
|
||
|
|
19004eefaa
|
||
|
|
f90aebc891
|
||
|
|
3096d6066b
|
||
|
|
1383d75c84
|
||
|
|
632b76c9ed
|
||
|
|
5dc9d21bdb
|
||
|
|
c1dfe92ea3
|
||
|
|
82bfbd8fa6
|
||
|
|
6aa0b865c2
|
||
|
|
0c71e0b13f
|
||
|
|
ea11d2f7d3
|
||
|
|
31878a77bc
|
||
|
|
21a75c624d
|
||
|
|
8d4bbf284d
|
||
|
|
905f694576
|
||
|
|
e798065a5c
|
||
|
|
5e269f912f
|
||
|
|
182c236050
|
||
|
|
73cd05827a | ||
|
|
ea32ba0f8d | ||
|
|
f7b1113819 | ||
|
|
228a1cb511 | ||
|
|
b72283d304 | ||
|
|
8cf226613b | ||
|
|
d4f25c44bf | ||
|
|
bc12556da1 | ||
|
|
4e7bb8979c | ||
|
|
4edb14bdd9
|
||
|
|
eb79b98f2b | ||
|
|
a85d5e627b
|
5
.gitignore
vendored
5
.gitignore
vendored
@@ -10,3 +10,8 @@ build
|
||||
**/__pycache__/
|
||||
/compile_commands.json
|
||||
/.cache
|
||||
|
||||
# mod
|
||||
mod/*
|
||||
.cursor/*
|
||||
chat-summaries/*
|
||||
@@ -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,12 +1,15 @@
|
||||
#include "Epub.h"
|
||||
|
||||
#include <FsHelpers.h>
|
||||
#include <HalDisplay.h>
|
||||
#include <HalStorage.h>
|
||||
#include <JpegToBmpConverter.h>
|
||||
#include <Logging.h>
|
||||
#include <PngToBmpConverter.h>
|
||||
#include <ZipFile.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "Epub/parsers/ContainerParser.h"
|
||||
#include "Epub/parsers/ContentOpfParser.h"
|
||||
#include "Epub/parsers/TocNavParser.h"
|
||||
@@ -77,54 +80,6 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
|
||||
bookMetadata.author = opfParser.author;
|
||||
bookMetadata.language = opfParser.language;
|
||||
bookMetadata.coverItemHref = opfParser.coverItemHref;
|
||||
|
||||
// Guide-based cover fallback: if no cover found via metadata/properties,
|
||||
// try extracting the image reference from the guide's cover page XHTML
|
||||
if (bookMetadata.coverItemHref.empty() && !opfParser.guideCoverPageHref.empty()) {
|
||||
LOG_DBG("EBP", "No cover from metadata, trying guide cover page: %s", opfParser.guideCoverPageHref.c_str());
|
||||
size_t coverPageSize;
|
||||
uint8_t* coverPageData = readItemContentsToBytes(opfParser.guideCoverPageHref, &coverPageSize, true);
|
||||
if (coverPageData) {
|
||||
const std::string coverPageHtml(reinterpret_cast<char*>(coverPageData), coverPageSize);
|
||||
free(coverPageData);
|
||||
|
||||
// Determine base path of the cover page for resolving relative image references
|
||||
std::string coverPageBase;
|
||||
const auto lastSlash = opfParser.guideCoverPageHref.rfind('/');
|
||||
if (lastSlash != std::string::npos) {
|
||||
coverPageBase = opfParser.guideCoverPageHref.substr(0, lastSlash + 1);
|
||||
}
|
||||
|
||||
// Search for image references: xlink:href="..." (SVG) and src="..." (img)
|
||||
std::string imageRef;
|
||||
for (const char* pattern : {"xlink:href=\"", "src=\""}) {
|
||||
auto pos = coverPageHtml.find(pattern);
|
||||
while (pos != std::string::npos) {
|
||||
pos += strlen(pattern);
|
||||
const auto endPos = coverPageHtml.find('"', pos);
|
||||
if (endPos != std::string::npos) {
|
||||
const auto ref = coverPageHtml.substr(pos, endPos - pos);
|
||||
// Check if it's an image file
|
||||
if (ref.length() >= 4) {
|
||||
const auto ext = ref.substr(ref.length() - 4);
|
||||
if (ext == ".png" || ext == ".jpg" || ext == "jpeg" || ext == ".gif") {
|
||||
imageRef = ref;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
pos = coverPageHtml.find(pattern, pos);
|
||||
}
|
||||
if (!imageRef.empty()) break;
|
||||
}
|
||||
|
||||
if (!imageRef.empty()) {
|
||||
bookMetadata.coverItemHref = FsHelpers::normalisePath(coverPageBase + imageRef);
|
||||
LOG_DBG("EBP", "Found cover image from guide: %s", bookMetadata.coverItemHref.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bookMetadata.textReferenceHref = opfParser.textReferenceHref;
|
||||
|
||||
if (!opfParser.tocNcxPath.empty()) {
|
||||
@@ -513,9 +468,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()) {
|
||||
@@ -524,13 +488,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";
|
||||
|
||||
@@ -538,7 +522,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)) {
|
||||
@@ -563,7 +547,8 @@ bool Epub::generateCoverBmp(bool cropped) const {
|
||||
return success;
|
||||
}
|
||||
|
||||
if (coverImageHref.substr(coverImageHref.length() - 4) == ".png") {
|
||||
bool isPng = lowerHref.substr(lowerHref.length() - 4) == ".png";
|
||||
if (isPng) {
|
||||
LOG_DBG("EBP", "Generating BMP from PNG cover image (%s mode)", cropped ? "cropped" : "fit");
|
||||
const auto coverPngTempPath = getCachePath() + "/.cover.png";
|
||||
|
||||
@@ -571,7 +556,7 @@ bool Epub::generateCoverBmp(bool cropped) const {
|
||||
if (!Storage.openFileForWrite("EBP", coverPngTempPath, coverPng)) {
|
||||
return false;
|
||||
}
|
||||
readItemContentsToStream(coverImageHref, coverPng, 1024);
|
||||
readItemContentsToStream(effectiveCoverImageHref, coverPng, 1024);
|
||||
coverPng.close();
|
||||
|
||||
if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) {
|
||||
@@ -604,9 +589,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()) {
|
||||
@@ -615,90 +609,283 @@ 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 if (coverImageHref.substr(coverImageHref.length() - 4) == ".png") {
|
||||
LOG_DBG("EBP", "Generating thumb BMP from PNG cover image");
|
||||
const auto coverPngTempPath = getCachePath() + "/.cover.png";
|
||||
|
||||
FsFile coverPng;
|
||||
if (!Storage.openFileForWrite("EBP", coverPngTempPath, coverPng)) {
|
||||
return false;
|
||||
}
|
||||
readItemContentsToStream(coverImageHref, coverPng, 1024);
|
||||
coverPng.close();
|
||||
|
||||
if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
FsFile thumbBmp;
|
||||
if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
|
||||
coverPng.close();
|
||||
return false;
|
||||
}
|
||||
int THUMB_TARGET_WIDTH = height * 0.6;
|
||||
int THUMB_TARGET_HEIGHT = height;
|
||||
const bool success =
|
||||
PngToBmpConverter::pngFileTo1BitBmpStreamWithSize(coverPng, thumbBmp, THUMB_TARGET_WIDTH, THUMB_TARGET_HEIGHT);
|
||||
coverPng.close();
|
||||
thumbBmp.close();
|
||||
Storage.remove(coverPngTempPath.c_str());
|
||||
|
||||
if (!success) {
|
||||
LOG_ERR("EBP", "Failed to generate thumb BMP from PNG cover image");
|
||||
Storage.remove(getThumbBmpPath(height).c_str());
|
||||
}
|
||||
LOG_DBG("EBP", "Generated thumb BMP from PNG cover image, success: %s", success ? "yes" : "no");
|
||||
return success;
|
||||
} else {
|
||||
// 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;
|
||||
}
|
||||
|
||||
bool isPng = lowerHref.substr(lowerHref.length() - 4) == ".png";
|
||||
if (isPng) {
|
||||
LOG_DBG("EBP", "Generating thumb BMP from PNG cover image");
|
||||
const auto coverPngTempPath = getCachePath() + "/.cover.png";
|
||||
|
||||
FsFile coverPng;
|
||||
if (!Storage.openFileForWrite("EBP", coverPngTempPath, coverPng)) {
|
||||
return false;
|
||||
}
|
||||
readItemContentsToStream(effectiveCoverImageHref, coverPng, 1024);
|
||||
coverPng.close();
|
||||
|
||||
if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
FsFile thumbBmp;
|
||||
if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
|
||||
coverPng.close();
|
||||
return false;
|
||||
}
|
||||
int THUMB_TARGET_WIDTH = height * 0.6;
|
||||
int THUMB_TARGET_HEIGHT = height;
|
||||
const bool success = PngToBmpConverter::pngFileTo1BitBmpStreamWithSize(coverPng, thumbBmp, THUMB_TARGET_WIDTH,
|
||||
THUMB_TARGET_HEIGHT);
|
||||
coverPng.close();
|
||||
thumbBmp.close();
|
||||
Storage.remove(coverPngTempPath.c_str());
|
||||
|
||||
if (!success) {
|
||||
LOG_ERR("EBP", "Failed to generate thumb BMP from PNG cover image");
|
||||
Storage.remove(getThumbBmpPath(height).c_str());
|
||||
}
|
||||
LOG_DBG("EBP", "Generated thumb BMP from PNG cover image, success: %s", success ? "yes" : "no");
|
||||
return success;
|
||||
}
|
||||
|
||||
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");
|
||||
@@ -846,3 +1033,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", ".png"};
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -52,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;
|
||||
@@ -72,4 +85,9 @@ class Epub {
|
||||
size_t getBookSize() const;
|
||||
float calculateProgress(int currentSpineIndex, float currentSpineRead) const;
|
||||
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,10 @@ 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);
|
||||
@@ -48,6 +61,115 @@ std::unique_ptr<PageImage> PageImage::deserialize(FsFile& 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);
|
||||
@@ -59,9 +181,7 @@ bool Page::serialize(FsFile& file) const {
|
||||
serialization::writePod(file, count);
|
||||
|
||||
for (const auto& el : elements) {
|
||||
// Use getTag() method to determine type
|
||||
serialization::writePod(file, static_cast<uint8_t>(el->getTag()));
|
||||
|
||||
if (!el->serialize(file)) {
|
||||
return false;
|
||||
}
|
||||
@@ -83,6 +203,13 @@ 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));
|
||||
@@ -94,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
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
|
||||
enum PageElementTag : uint8_t {
|
||||
TAG_PageLine = 1,
|
||||
TAG_PageImage = 2, // New tag
|
||||
TAG_PageTableRow = 2,
|
||||
TAG_PageImage = 3,
|
||||
};
|
||||
|
||||
// represents something that has been added to a page
|
||||
@@ -20,9 +21,9 @@ 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;
|
||||
virtual PageElementTag getTag() const = 0; // Add type identification
|
||||
};
|
||||
|
||||
// a line from a block element
|
||||
@@ -32,13 +33,44 @@ class PageLine final : public PageElement {
|
||||
public:
|
||||
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;
|
||||
PageElementTag getTag() const override { return TAG_PageLine; }
|
||||
static std::unique_ptr<PageLine> deserialize(FsFile& file);
|
||||
};
|
||||
|
||||
// New PageImage class
|
||||
/// 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;
|
||||
|
||||
@@ -49,6 +81,9 @@ class PageImage final : public PageElement {
|
||||
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 {
|
||||
@@ -64,4 +99,9 @@ class Page {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <functional>
|
||||
#include <iterator>
|
||||
#include <limits>
|
||||
#include <vector>
|
||||
|
||||
@@ -65,6 +64,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
|
||||
@@ -82,37 +88,26 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
|
||||
const int spaceWidth = renderer.getSpaceWidth(fontId);
|
||||
auto wordWidths = calculateWordWidths(renderer, fontId);
|
||||
|
||||
// Build indexed continues vector from the parallel list for O(1) access during layout
|
||||
std::vector<bool> continuesVec(wordContinues.begin(), wordContinues.end());
|
||||
|
||||
std::vector<size_t> lineBreakIndices;
|
||||
if (hyphenationEnabled) {
|
||||
// Use greedy layout that can split words mid-loop when a hyphenated prefix fits.
|
||||
lineBreakIndices = computeHyphenatedLineBreaks(renderer, fontId, pageWidth, spaceWidth, wordWidths, continuesVec);
|
||||
lineBreakIndices = computeHyphenatedLineBreaks(renderer, fontId, pageWidth, spaceWidth, wordWidths, wordContinues);
|
||||
} else {
|
||||
lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, spaceWidth, wordWidths, continuesVec);
|
||||
lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, spaceWidth, wordWidths, wordContinues);
|
||||
}
|
||||
const size_t lineCount = includeLastLine ? lineBreakIndices.size() : lineBreakIndices.size() - 1;
|
||||
|
||||
for (size_t i = 0; i < lineCount; ++i) {
|
||||
extractLine(i, pageWidth, spaceWidth, wordWidths, continuesVec, lineBreakIndices, processLine);
|
||||
extractLine(i, pageWidth, spaceWidth, wordWidths, wordContinues, lineBreakIndices, processLine);
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& renderer, const int fontId) {
|
||||
const size_t totalWordCount = words.size();
|
||||
|
||||
std::vector<uint16_t> wordWidths;
|
||||
wordWidths.reserve(totalWordCount);
|
||||
wordWidths.reserve(words.size());
|
||||
|
||||
auto wordsIt = words.begin();
|
||||
auto wordStylesIt = wordStyles.begin();
|
||||
|
||||
while (wordsIt != words.end()) {
|
||||
wordWidths.push_back(measureWordWidth(renderer, fontId, *wordsIt, *wordStylesIt));
|
||||
|
||||
std::advance(wordsIt, 1);
|
||||
std::advance(wordStylesIt, 1);
|
||||
for (size_t i = 0; i < words.size(); ++i) {
|
||||
wordWidths.push_back(measureWordWidth(renderer, fontId, words[i], wordStyles[i]));
|
||||
}
|
||||
|
||||
return wordWidths;
|
||||
@@ -137,8 +132,7 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
|
||||
// First word needs to fit in reduced width if there's an indent
|
||||
const int effectiveWidth = i == 0 ? pageWidth - firstLineIndent : pageWidth;
|
||||
while (wordWidths[i] > effectiveWidth) {
|
||||
if (!hyphenateWordAtIndex(i, effectiveWidth, renderer, fontId, wordWidths, /*allowFallbackBreaks=*/true,
|
||||
&continuesVec)) {
|
||||
if (!hyphenateWordAtIndex(i, effectiveWidth, renderer, fontId, wordWidths, /*allowFallbackBreaks=*/true)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -163,6 +157,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;
|
||||
@@ -171,8 +170,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;
|
||||
}
|
||||
|
||||
@@ -195,6 +197,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
|
||||
@@ -269,6 +276,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];
|
||||
@@ -277,6 +289,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;
|
||||
}
|
||||
|
||||
@@ -284,8 +301,8 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
|
||||
const int availableWidth = effectivePageWidth - lineWidth - spacing;
|
||||
const bool allowFallbackBreaks = isFirstWord; // Only for first word on line
|
||||
|
||||
if (availableWidth > 0 && hyphenateWordAtIndex(currentIndex, availableWidth, renderer, fontId, wordWidths,
|
||||
allowFallbackBreaks, &continuesVec)) {
|
||||
if (availableWidth > 0 &&
|
||||
hyphenateWordAtIndex(currentIndex, availableWidth, renderer, fontId, wordWidths, allowFallbackBreaks)) {
|
||||
// Prefix now fits; append it to this line and move to next line
|
||||
lineWidth += spacing + wordWidths[currentIndex];
|
||||
++currentIndex;
|
||||
@@ -302,7 +319,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;
|
||||
}
|
||||
|
||||
@@ -317,20 +339,14 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
|
||||
// available width.
|
||||
bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availableWidth, const GfxRenderer& renderer,
|
||||
const int fontId, std::vector<uint16_t>& wordWidths,
|
||||
const bool allowFallbackBreaks, std::vector<bool>* continuesVec) {
|
||||
const bool allowFallbackBreaks) {
|
||||
// Guard against invalid indices or zero available width before attempting to split.
|
||||
if (availableWidth <= 0 || wordIndex >= words.size()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get iterators to target word and style.
|
||||
auto wordIt = words.begin();
|
||||
auto styleIt = wordStyles.begin();
|
||||
std::advance(wordIt, wordIndex);
|
||||
std::advance(styleIt, wordIndex);
|
||||
|
||||
const std::string& word = *wordIt;
|
||||
const auto style = *styleIt;
|
||||
const std::string& word = words[wordIndex];
|
||||
const auto style = wordStyles[wordIndex];
|
||||
|
||||
// Collect candidate breakpoints (byte offsets and hyphen requirements).
|
||||
auto breakInfos = Hyphenator::breakOffsets(word, allowFallbackBreaks);
|
||||
@@ -367,31 +383,26 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
|
||||
|
||||
// Split the word at the selected breakpoint and append a hyphen if required.
|
||||
std::string remainder = word.substr(chosenOffset);
|
||||
wordIt->resize(chosenOffset);
|
||||
words[wordIndex].resize(chosenOffset);
|
||||
if (chosenNeedsHyphen) {
|
||||
wordIt->push_back('-');
|
||||
words[wordIndex].push_back('-');
|
||||
}
|
||||
|
||||
// Insert the remainder word (with matching style and continuation flag) directly after the prefix.
|
||||
auto insertWordIt = std::next(wordIt);
|
||||
auto insertStyleIt = std::next(styleIt);
|
||||
words.insert(insertWordIt, remainder);
|
||||
wordStyles.insert(insertStyleIt, style);
|
||||
words.insert(words.begin() + wordIndex + 1, remainder);
|
||||
wordStyles.insert(wordStyles.begin() + wordIndex + 1, style);
|
||||
|
||||
// The remainder inherits whatever continuation status the original word had with the word after it.
|
||||
// Find the continues entry for the original word and insert the remainder's entry after it.
|
||||
auto continuesIt = wordContinues.begin();
|
||||
std::advance(continuesIt, wordIndex);
|
||||
const bool originalContinuedToNext = *continuesIt;
|
||||
const bool originalContinuedToNext = wordContinues[wordIndex];
|
||||
// The original word (now prefix) does NOT continue to remainder (hyphen separates them)
|
||||
*continuesIt = false;
|
||||
const auto insertContinuesIt = std::next(continuesIt);
|
||||
wordContinues.insert(insertContinuesIt, originalContinuedToNext);
|
||||
wordContinues[wordIndex] = false;
|
||||
wordContinues.insert(wordContinues.begin() + wordIndex + 1, originalContinuedToNext);
|
||||
|
||||
// Keep the indexed vector in sync if provided
|
||||
if (continuesVec) {
|
||||
(*continuesVec)[wordIndex] = false;
|
||||
continuesVec->insert(continuesVec->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.
|
||||
@@ -452,7 +463,8 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
||||
|
||||
// Pre-calculate X positions for words
|
||||
// Continuation words attach to the previous word with no space before them
|
||||
std::list<uint16_t> lineXPos;
|
||||
std::vector<uint16_t> lineXPos;
|
||||
lineXPos.reserve(lineWordCount);
|
||||
|
||||
for (size_t wordIdx = 0; wordIdx < lineWordCount; wordIdx++) {
|
||||
const uint16_t currentWordWidth = wordWidths[lastBreakAt + wordIdx];
|
||||
@@ -465,23 +477,10 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
||||
xpos += currentWordWidth + (nextIsContinuation ? 0 : spacing);
|
||||
}
|
||||
|
||||
// Iterators always start at the beginning as we are moving content with splice below
|
||||
auto wordEndIt = words.begin();
|
||||
auto wordStyleEndIt = wordStyles.begin();
|
||||
auto wordContinuesEndIt = wordContinues.begin();
|
||||
std::advance(wordEndIt, lineWordCount);
|
||||
std::advance(wordStyleEndIt, lineWordCount);
|
||||
std::advance(wordContinuesEndIt, lineWordCount);
|
||||
|
||||
// *** CRITICAL STEP: CONSUME DATA USING SPLICE ***
|
||||
std::list<std::string> lineWords;
|
||||
lineWords.splice(lineWords.begin(), words, words.begin(), wordEndIt);
|
||||
std::list<EpdFontFamily::Style> lineWordStyles;
|
||||
lineWordStyles.splice(lineWordStyles.begin(), wordStyles, wordStyles.begin(), wordStyleEndIt);
|
||||
|
||||
// Consume continues flags (not passed to TextBlock, but must be consumed to stay in sync)
|
||||
std::list<bool> lineContinues;
|
||||
lineContinues.splice(lineContinues.begin(), wordContinues, wordContinues.begin(), wordContinuesEndIt);
|
||||
// Build line data by moving from the original vectors using index range
|
||||
std::vector<std::string> lineWords(std::make_move_iterator(words.begin() + lastBreakAt),
|
||||
std::make_move_iterator(words.begin() + lineBreak));
|
||||
std::vector<EpdFontFamily::Style> lineWordStyles(wordStyles.begin() + lastBreakAt, wordStyles.begin() + lineBreak);
|
||||
|
||||
for (auto& word : lineWords) {
|
||||
if (containsSoftHyphen(word)) {
|
||||
@@ -492,3 +491,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)));
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
#include <EpdFontFamily.h>
|
||||
|
||||
#include <functional>
|
||||
#include <list>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -14,9 +13,10 @@
|
||||
class GfxRenderer;
|
||||
|
||||
class ParsedText {
|
||||
std::list<std::string> words;
|
||||
std::list<EpdFontFamily::Style> wordStyles;
|
||||
std::list<bool> wordContinues; // true = word attaches to previous (no space before it)
|
||||
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> forceBreakAfter; // true = mandatory line break after this word (e.g. <br> in table cells)
|
||||
BlockStyle blockStyle;
|
||||
bool extraParagraphSpacing;
|
||||
bool hyphenationEnabled;
|
||||
@@ -28,8 +28,7 @@ class ParsedText {
|
||||
int spaceWidth, std::vector<uint16_t>& wordWidths,
|
||||
std::vector<bool>& continuesVec);
|
||||
bool hyphenateWordAtIndex(size_t wordIndex, int availableWidth, const GfxRenderer& renderer, int fontId,
|
||||
std::vector<uint16_t>& wordWidths, bool allowFallbackBreaks,
|
||||
std::vector<bool>* continuesVec = nullptr);
|
||||
std::vector<uint16_t>& wordWidths, bool allowFallbackBreaks);
|
||||
void extractLine(size_t breakIndex, int pageWidth, int spaceWidth, const std::vector<uint16_t>& wordWidths,
|
||||
const std::vector<bool>& continuesVec, const std::vector<size_t>& lineBreakIndices,
|
||||
const std::function<void(std::shared_ptr<TextBlock>)>& processLine);
|
||||
@@ -42,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(); }
|
||||
@@ -49,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;
|
||||
};
|
||||
@@ -195,7 +195,6 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ChapterHtmlSlimParser visitor(
|
||||
epub, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
||||
viewportHeight, hyphenationEnabled,
|
||||
|
||||
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
|
||||
};
|
||||
@@ -12,16 +12,13 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
|
||||
return;
|
||||
}
|
||||
|
||||
auto wordIt = words.begin();
|
||||
auto wordStylesIt = wordStyles.begin();
|
||||
auto wordXposIt = wordXpos.begin();
|
||||
for (size_t i = 0; i < words.size(); i++) {
|
||||
const int wordX = *wordXposIt + x;
|
||||
const EpdFontFamily::Style currentStyle = *wordStylesIt;
|
||||
renderer.drawText(fontId, wordX, y, wordIt->c_str(), true, currentStyle);
|
||||
const int wordX = wordXpos[i] + x;
|
||||
const EpdFontFamily::Style currentStyle = wordStyles[i];
|
||||
renderer.drawText(fontId, wordX, y, words[i].c_str(), true, currentStyle);
|
||||
|
||||
if ((currentStyle & EpdFontFamily::UNDERLINE) != 0) {
|
||||
const std::string& w = *wordIt;
|
||||
const std::string& w = words[i];
|
||||
const int fullWordWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle);
|
||||
// y is the top of the text line; add ascender to reach baseline, then offset 2px below
|
||||
const int underlineY = y + renderer.getFontAscenderSize(fontId) + 2;
|
||||
@@ -41,10 +38,6 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
|
||||
|
||||
renderer.drawLine(startX, underlineY, startX + underlineWidth, underlineY, true);
|
||||
}
|
||||
|
||||
std::advance(wordIt, 1);
|
||||
std::advance(wordStylesIt, 1);
|
||||
std::advance(wordXposIt, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,15 +73,15 @@ bool TextBlock::serialize(FsFile& file) const {
|
||||
|
||||
std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
|
||||
uint16_t wc;
|
||||
std::list<std::string> words;
|
||||
std::list<uint16_t> wordXpos;
|
||||
std::list<EpdFontFamily::Style> wordStyles;
|
||||
std::vector<std::string> words;
|
||||
std::vector<uint16_t> wordXpos;
|
||||
std::vector<EpdFontFamily::Style> wordStyles;
|
||||
BlockStyle blockStyle;
|
||||
|
||||
// Word count
|
||||
serialization::readPod(file, wc);
|
||||
|
||||
// Sanity check: prevent allocation of unreasonably large lists (max 10000 words per block)
|
||||
// Sanity check: prevent allocation of unreasonably large vectors (max 10000 words per block)
|
||||
if (wc > 10000) {
|
||||
LOG_ERR("TXB", "Deserialization failed: word count %u exceeds maximum", wc);
|
||||
return nullptr;
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
#include <EpdFontFamily.h>
|
||||
#include <HalStorage.h>
|
||||
|
||||
#include <list>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Block.h"
|
||||
#include "BlockStyle.h"
|
||||
@@ -12,14 +12,14 @@
|
||||
// Represents a line of text on a page
|
||||
class TextBlock final : public Block {
|
||||
private:
|
||||
std::list<std::string> words;
|
||||
std::list<uint16_t> wordXpos;
|
||||
std::list<EpdFontFamily::Style> wordStyles;
|
||||
std::vector<std::string> words;
|
||||
std::vector<uint16_t> wordXpos;
|
||||
std::vector<EpdFontFamily::Style> wordStyles;
|
||||
BlockStyle blockStyle;
|
||||
|
||||
public:
|
||||
explicit TextBlock(std::list<std::string> words, std::list<uint16_t> word_xpos,
|
||||
std::list<EpdFontFamily::Style> word_styles, const BlockStyle& blockStyle = BlockStyle())
|
||||
explicit TextBlock(std::vector<std::string> words, std::vector<uint16_t> word_xpos,
|
||||
std::vector<EpdFontFamily::Style> word_styles, const BlockStyle& blockStyle = BlockStyle())
|
||||
: words(std::move(words)),
|
||||
wordXpos(std::move(word_xpos)),
|
||||
wordStyles(std::move(word_styles)),
|
||||
@@ -27,6 +27,9 @@ class TextBlock final : public Block {
|
||||
~TextBlock() override = default;
|
||||
void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; }
|
||||
const BlockStyle& getBlockStyle() const { return blockStyle; }
|
||||
const std::vector<std::string>& getWords() const { return words; }
|
||||
const std::vector<uint16_t>& getWordXpos() const { return wordXpos; }
|
||||
const std::vector<EpdFontFamily::Style>& getWordStyles() const { return wordStyles; }
|
||||
bool isEmpty() override { return words.empty(); }
|
||||
// given a renderer works out where to break the words into lines
|
||||
void render(const GfxRenderer& renderer, int fontId, int x, int y) const;
|
||||
|
||||
@@ -37,4 +37,4 @@ class ImageToFramebufferDecoder {
|
||||
|
||||
bool validateImageDimensions(int width, int height, const std::string& format);
|
||||
void warnUnsupportedFeature(const std::string& feature, const std::string& imagePath);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -14,4 +14,4 @@ class PngToFramebufferConverter final : public ImageToFramebufferDecoder {
|
||||
|
||||
static bool supportsFormat(const std::string& extension);
|
||||
const char* getFormatName() const override { return "PNG"; }
|
||||
};
|
||||
};
|
||||
|
||||
@@ -295,6 +295,9 @@ void CssParser::parseDeclarationIntoStyle(const std::string& decl, CssStyle& sty
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,51 +1,91 @@
|
||||
#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
|
||||
#ifndef OMIT_HYPH_UK
|
||||
#include "generated/hyph-uk.trie.h"
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
#ifndef OMIT_HYPH_EN
|
||||
// English hyphenation patterns (3/3 minimum prefix/suffix length)
|
||||
LanguageHyphenator englishHyphenator(en_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_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
|
||||
#ifndef OMIT_HYPH_UK
|
||||
LanguageHyphenator ukrainianHyphenator(uk_patterns, isCyrillicLetter, toLowerCyrillic);
|
||||
#endif
|
||||
|
||||
using EntryArray = std::array<LanguageEntry, 7>;
|
||||
|
||||
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},
|
||||
{"ukrainian", "uk", &ukrainianHyphenator}}};
|
||||
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
|
||||
#ifndef OMIT_HYPH_UK
|
||||
{"ukrainian", "uk", &ukrainianHyphenator},
|
||||
#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()};
|
||||
}
|
||||
LanguageEntryView getLanguageEntries() { return entries(); }
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
#include <Logging.h>
|
||||
#include <expat.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "../../Epub.h"
|
||||
#include "../Page.h"
|
||||
#include "../converters/ImageDecoderFactory.h"
|
||||
@@ -37,8 +39,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++) {
|
||||
@@ -53,10 +77,6 @@ bool isHeaderOrBlock(const char* name) {
|
||||
return matches(name, HEADER_TAGS, NUM_HEADER_TAGS) || matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS);
|
||||
}
|
||||
|
||||
bool isTableStructuralTag(const char* name) {
|
||||
return strcmp(name, "table") == 0 || strcmp(name, "tr") == 0 || strcmp(name, "td") == 0 || strcmp(name, "th") == 0;
|
||||
}
|
||||
|
||||
// Update effective bold/italic/underline based on block style and inline style stack
|
||||
void ChapterHtmlSlimParser::updateEffectiveInlineStyle() {
|
||||
// Start with block-level styles
|
||||
@@ -100,13 +120,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
|
||||
@@ -149,67 +193,183 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
centeredBlockStyle.textAlignDefined = true;
|
||||
centeredBlockStyle.alignment = CssTextAlign::Center;
|
||||
|
||||
// Special handling for tables/cells: flatten into per-cell paragraphs with a prefixed header.
|
||||
// --- Table handling ---
|
||||
if (strcmp(name, "table") == 0) {
|
||||
// skip nested tables
|
||||
if (self->tableDepth > 0) {
|
||||
self->tableDepth += 1;
|
||||
if (self->inTable) {
|
||||
// Nested table: skip it entirely for v1
|
||||
self->skipUntilDepth = self->depth;
|
||||
self->depth += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (self->partWordBufferIndex > 0) {
|
||||
self->flushPartWordBuffer();
|
||||
// Flush any pending content before the table
|
||||
if (self->currentTextBlock && !self->currentTextBlock->isEmpty()) {
|
||||
self->makePages();
|
||||
}
|
||||
self->tableDepth += 1;
|
||||
self->tableRowIndex = 0;
|
||||
self->tableColIndex = 0;
|
||||
|
||||
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->depth += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (self->tableDepth == 1 && strcmp(name, "tr") == 0) {
|
||||
self->tableRowIndex += 1;
|
||||
self->tableColIndex = 0;
|
||||
self->depth += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (self->tableDepth == 1 && (strcmp(name, "td") == 0 || strcmp(name, "th") == 0)) {
|
||||
if (self->partWordBufferIndex > 0) {
|
||||
self->flushPartWordBuffer();
|
||||
// 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;
|
||||
}
|
||||
self->tableColIndex += 1;
|
||||
|
||||
auto tableCellBlockStyle = BlockStyle();
|
||||
tableCellBlockStyle.textAlignDefined = true;
|
||||
const auto align = (self->paragraphAlignment == static_cast<uint8_t>(CssTextAlign::None))
|
||||
? CssTextAlign::Justify
|
||||
: static_cast<CssTextAlign>(self->paragraphAlignment);
|
||||
tableCellBlockStyle.alignment = align;
|
||||
self->startNewTextBlock(tableCellBlockStyle);
|
||||
// <col> — capture width hint for column sizing
|
||||
if (strcmp(name, "col") == 0) {
|
||||
CssLength widthHint;
|
||||
bool hasHint = false;
|
||||
|
||||
const std::string headerText =
|
||||
"Tab Row " + std::to_string(self->tableRowIndex) + ", Cell " + std::to_string(self->tableColIndex) + ":";
|
||||
StyleStackEntry headerStyle;
|
||||
headerStyle.depth = self->depth;
|
||||
headerStyle.hasBold = true;
|
||||
headerStyle.bold = false;
|
||||
headerStyle.hasItalic = true;
|
||||
headerStyle.italic = true;
|
||||
headerStyle.hasUnderline = true;
|
||||
headerStyle.underline = false;
|
||||
self->inlineStyleStack.push_back(headerStyle);
|
||||
self->updateEffectiveInlineStyle();
|
||||
self->characterData(userData, headerText.c_str(), static_cast<int>(headerText.length()));
|
||||
if (self->partWordBufferIndex > 0) {
|
||||
self->flushPartWordBuffer();
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
self->nextWordContinues = false;
|
||||
self->inlineStyleStack.pop_back();
|
||||
self->updateEffectiveInlineStyle();
|
||||
|
||||
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)) {
|
||||
@@ -231,7 +391,9 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
// Resolve the image path relative to the HTML file
|
||||
std::string resolvedPath = FsHelpers::normalisePath(self->contentBase + src);
|
||||
|
||||
if (ImageDecoderFactory::isFormatSupported(resolvedPath)) {
|
||||
// Check format support before any file I/O
|
||||
ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(resolvedPath);
|
||||
if (decoder) {
|
||||
// Create a unique filename for the cached image
|
||||
std::string ext;
|
||||
size_t extPos = resolvedPath.rfind('.');
|
||||
@@ -253,8 +415,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
if (extractSuccess) {
|
||||
// Get image dimensions
|
||||
ImageDimensions dims = {0, 0};
|
||||
ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(cachedImagePath);
|
||||
if (decoder && decoder->getDimensions(cachedImagePath, dims)) {
|
||||
if (decoder->getDimensions(cachedImagePath, dims)) {
|
||||
LOG_DBG("EHP", "Image dimensions: %dx%d", dims.width, dims.height);
|
||||
|
||||
// Scale to fit viewport while maintaining aspect ratio
|
||||
@@ -313,7 +474,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
} else {
|
||||
LOG_ERR("EHP", "Failed to extract image");
|
||||
}
|
||||
} // isFormatSupported
|
||||
} // if (decoder)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,18 +545,24 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
} else if (matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS)) {
|
||||
if (strcmp(name, "br") == 0) {
|
||||
if (self->partWordBufferIndex > 0) {
|
||||
// flush word preceding <br/> to currentTextBlock before calling startNewTextBlock
|
||||
self->flushPartWordBuffer();
|
||||
}
|
||||
self->startNewTextBlock(self->currentTextBlock->getBlockStyle());
|
||||
} else if (strcmp(name, "li") == 0) {
|
||||
self->currentCssStyle = cssStyle;
|
||||
self->startNewTextBlock(userAlignmentBlockStyle);
|
||||
self->updateEffectiveInlineStyle();
|
||||
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
|
||||
self->listItemUntilDepth = std::min(self->listItemUntilDepth, self->depth);
|
||||
} else if (strcmp(name, "p") == 0 && self->listItemUntilDepth < self->depth) {
|
||||
// Inside a <li> element - don't start a new text block for <p>
|
||||
// This prevents bullet points from appearing on their own line
|
||||
self->currentCssStyle = cssStyle;
|
||||
self->updateEffectiveInlineStyle();
|
||||
} else {
|
||||
self->currentCssStyle = cssStyle;
|
||||
self->startNewTextBlock(userAlignmentBlockStyle);
|
||||
self->updateEffectiveInlineStyle();
|
||||
|
||||
if (strcmp(name, "li") == 0) {
|
||||
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
|
||||
}
|
||||
}
|
||||
} else if (matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS)) {
|
||||
// Flush buffer before style change so preceding text gets current style
|
||||
@@ -497,11 +664,6 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char* s, const int len) {
|
||||
auto* self = static_cast<ChapterHtmlSlimParser*>(userData);
|
||||
|
||||
// Skip content of nested table
|
||||
if (self->tableDepth > 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Middle of skip
|
||||
if (self->skipUntilDepth < self->depth) {
|
||||
return;
|
||||
@@ -567,7 +729,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,
|
||||
@@ -605,24 +768,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 tableStructuralTag = isTableStructuralTag(name);
|
||||
|
||||
if (self->tableDepth > 1 && strcmp(name, "table") == 0) {
|
||||
// get rid of all text inside the nested table
|
||||
self->partWordBufferIndex = 0;
|
||||
self->tableDepth -= 1;
|
||||
LOG_DBG("EHP", "nested table detected, get rid of its content");
|
||||
return;
|
||||
}
|
||||
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 && !tableStructuralTag && !matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS) && self->depth != 1;
|
||||
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) || tableStructuralTag ||
|
||||
matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS) || isTableTag || isTableCellTag ||
|
||||
matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS) || self->depth == 1;
|
||||
|
||||
if (shouldFlush) {
|
||||
@@ -634,6 +790,58 @@ 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->listItemUntilDepth == self->depth) self->listItemUntilDepth = 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
|
||||
@@ -641,21 +849,6 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
||||
self->skipUntilDepth = INT_MAX;
|
||||
}
|
||||
|
||||
if (self->tableDepth == 1 && (strcmp(name, "td") == 0 || strcmp(name, "th") == 0)) {
|
||||
self->nextWordContinues = false;
|
||||
}
|
||||
|
||||
if (self->tableDepth == 1 && (strcmp(name, "tr") == 0)) {
|
||||
self->nextWordContinues = false;
|
||||
}
|
||||
|
||||
if (self->tableDepth == 1 && strcmp(name, "table") == 0) {
|
||||
self->tableDepth -= 1;
|
||||
self->tableRowIndex = 0;
|
||||
self->tableColIndex = 0;
|
||||
self->nextWordContinues = false;
|
||||
}
|
||||
|
||||
// Leaving bold tag
|
||||
if (self->boldUntilDepth == self->depth) {
|
||||
self->boldUntilDepth = INT_MAX;
|
||||
@@ -671,6 +864,11 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
||||
self->underlineUntilDepth = INT_MAX;
|
||||
}
|
||||
|
||||
// Leaving list item
|
||||
if (self->listItemUntilDepth == self->depth) {
|
||||
self->listItemUntilDepth = INT_MAX;
|
||||
}
|
||||
|
||||
// Pop from inline style stack if we pushed an entry at this depth
|
||||
// This handles all inline elements: b, i, u, span, etc.
|
||||
if (!self->inlineStyleStack.empty() && self->inlineStyleStack.back().depth == self->depth) {
|
||||
@@ -686,6 +884,7 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
||||
}
|
||||
|
||||
bool ChapterHtmlSlimParser::parseAndBuildPages() {
|
||||
unsigned long chapterStartTime = millis();
|
||||
auto paragraphAlignmentBlockStyle = BlockStyle();
|
||||
paragraphAlignmentBlockStyle.textAlignDefined = true;
|
||||
// Resolve None sentinel to Justify for initial block (no CSS context yet)
|
||||
@@ -722,8 +921,6 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
|
||||
XML_SetElementHandler(parser, startElement, endElement);
|
||||
XML_SetCharacterDataHandler(parser, characterData);
|
||||
|
||||
// Compute the time taken to parse and build pages
|
||||
const uint32_t chapterStartTime = millis();
|
||||
do {
|
||||
void* const buf = XML_GetBuffer(parser, PARSE_BUFFER_SIZE);
|
||||
if (!buf) {
|
||||
@@ -761,7 +958,6 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
|
||||
return false;
|
||||
}
|
||||
} while (!done);
|
||||
LOG_DBG("EHP", "Time to parse and build pages: %lu ms", millis() - chapterStartTime);
|
||||
|
||||
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
|
||||
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
|
||||
@@ -777,6 +973,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
|
||||
currentTextBlock.reset();
|
||||
}
|
||||
|
||||
LOG_DBG("EHP", "Chapter parsed in %lu ms", millis() - chapterStartTime);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -839,3 +1036,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,12 +7,14 @@
|
||||
#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;
|
||||
|
||||
@@ -29,6 +31,7 @@ class ChapterHtmlSlimParser {
|
||||
int boldUntilDepth = INT_MAX;
|
||||
int italicUntilDepth = INT_MAX;
|
||||
int underlineUntilDepth = INT_MAX;
|
||||
int listItemUntilDepth = INT_MAX;
|
||||
// buffer for building up words from characters, will auto break if longer than this
|
||||
// leave one char at end for null pointer
|
||||
char partWordBuffer[MAX_WORD_SIZE + 1] = {};
|
||||
@@ -62,14 +65,17 @@ class ChapterHtmlSlimParser {
|
||||
bool effectiveBold = false;
|
||||
bool effectiveItalic = false;
|
||||
bool effectiveUnderline = false;
|
||||
int tableDepth = 0;
|
||||
int tableRowIndex = 0;
|
||||
int tableColIndex = 0;
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -296,22 +296,23 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
|
||||
// parse the guide
|
||||
if (self->state == IN_GUIDE && (strcmp(name, "reference") == 0 || strcmp(name, "opf:reference") == 0)) {
|
||||
std::string type;
|
||||
std::string guideHref;
|
||||
std::string textHref;
|
||||
for (int i = 0; atts[i]; i += 2) {
|
||||
if (strcmp(atts[i], "type") == 0) {
|
||||
type = atts[i + 1];
|
||||
if (type == "text" || type == "start") {
|
||||
continue;
|
||||
} else {
|
||||
LOG_DBG("COF", "Skipping non-text reference in guide: %s", type.c_str());
|
||||
break;
|
||||
}
|
||||
} else if (strcmp(atts[i], "href") == 0) {
|
||||
guideHref = FsHelpers::normalisePath(self->baseContentPath + atts[i + 1]);
|
||||
textHref = FsHelpers::normalisePath(self->baseContentPath + atts[i + 1]);
|
||||
}
|
||||
}
|
||||
if (!guideHref.empty()) {
|
||||
if (type == "text" || (type == "start" && !self->textReferenceHref.empty())) {
|
||||
LOG_DBG("COF", "Found %s reference in guide: %s", type.c_str(), guideHref.c_str());
|
||||
self->textReferenceHref = guideHref;
|
||||
} else if ((type == "cover" || type == "cover-page") && self->guideCoverPageHref.empty()) {
|
||||
LOG_DBG("COF", "Found cover reference in guide: %s", guideHref.c_str());
|
||||
self->guideCoverPageHref = guideHref;
|
||||
}
|
||||
if ((type == "text" || (type == "start" && !self->textReferenceHref.empty())) && (textHref.length() > 0)) {
|
||||
LOG_DBG("COF", "Found %s reference in guide: %s.", type.c_str(), textHref.c_str());
|
||||
self->textReferenceHref = textHref;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,6 @@ class ContentOpfParser final : public Print {
|
||||
std::string tocNcxPath;
|
||||
std::string tocNavPath; // EPUB 3 nav document path
|
||||
std::string coverItemHref;
|
||||
std::string guideCoverPageHref; // Guide reference with type="cover" or "cover-page" (points to XHTML wrapper)
|
||||
std::string textReferenceHref;
|
||||
std::vector<std::string> cssFiles; // CSS stylesheet paths
|
||||
|
||||
|
||||
@@ -104,3 +104,20 @@ uint8_t quantize1bit(int gray, int x, int y) {
|
||||
const int adjustedThreshold = 128 + ((threshold - 128) / 2); // Range: 64-192
|
||||
return (gray >= adjustedThreshold) ? 1 : 0;
|
||||
}
|
||||
|
||||
// Noise dithering for gradient fills - always uses hash-based noise regardless of global dithering config.
|
||||
// Produces smooth-looking gradients on the 4-level e-ink display.
|
||||
uint8_t quantizeNoiseDither(int gray, int x, int y) {
|
||||
uint32_t hash = static_cast<uint32_t>(x) * 374761393u + static_cast<uint32_t>(y) * 668265263u;
|
||||
hash = (hash ^ (hash >> 13)) * 1274126177u;
|
||||
const int threshold = static_cast<int>(hash >> 24);
|
||||
|
||||
const int scaled = gray * 3;
|
||||
if (scaled < 255) {
|
||||
return (scaled + threshold >= 255) ? 1 : 0;
|
||||
} else if (scaled < 510) {
|
||||
return ((scaled - 255) + threshold >= 255) ? 2 : 1;
|
||||
} else {
|
||||
return ((scaled - 510) + threshold >= 255) ? 3 : 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ uint8_t quantize(int gray, int x, int y);
|
||||
uint8_t quantizeSimple(int gray);
|
||||
uint8_t quantize1bit(int gray, int x, int y);
|
||||
int adjustPixel(int gray);
|
||||
uint8_t quantizeNoiseDither(int gray, int x, int y);
|
||||
|
||||
// 1-bit Atkinson dithering - better quality than noise dithering for thumbnails
|
||||
// Error distribution pattern (same as 2-bit but quantizes to 2 levels):
|
||||
|
||||
@@ -23,7 +23,7 @@ void GfxRenderer::begin() {
|
||||
}
|
||||
}
|
||||
|
||||
void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) { fontMap.insert({fontId, font}); }
|
||||
void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) { fontMap.insert({fontId, std::move(font)}); }
|
||||
|
||||
// Translate logical (x,y) coordinates to physical panel coordinates based on current orientation
|
||||
// This should always be inlined for better performance
|
||||
@@ -85,6 +85,16 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
|
||||
}
|
||||
}
|
||||
|
||||
void GfxRenderer::drawPixelGray(const int x, const int y, const uint8_t val2bit) const {
|
||||
if (renderMode == BW && val2bit < 3) {
|
||||
drawPixel(x, y);
|
||||
} else if (renderMode == GRAYSCALE_MSB && (val2bit == 1 || val2bit == 2)) {
|
||||
drawPixel(x, y, false);
|
||||
} else if (renderMode == GRAYSCALE_LSB && val2bit == 1) {
|
||||
drawPixel(x, y, false);
|
||||
}
|
||||
}
|
||||
|
||||
int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontFamily::Style style) const {
|
||||
const auto fontIt = fontMap.find(fontId);
|
||||
if (fontIt == fontMap.end()) {
|
||||
@@ -298,7 +308,7 @@ void GfxRenderer::drawPixelDither<Color::LightGray>(const int x, const int y) co
|
||||
|
||||
template <>
|
||||
void GfxRenderer::drawPixelDither<Color::DarkGray>(const int x, const int y) const {
|
||||
drawPixel(x, y, (x + y) % 2 == 0); // TODO: maybe find a better pattern?
|
||||
drawPixel(x, y, (x + y) % 2 == 0);
|
||||
}
|
||||
|
||||
void GfxRenderer::fillRectDither(const int x, const int y, const int width, const int height, Color color) const {
|
||||
@@ -452,12 +462,20 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
|
||||
LOG_DBG("GFX", "Cropping %dx%d by %dx%d pix, is %s", bitmap.getWidth(), bitmap.getHeight(), cropPixX, cropPixY,
|
||||
bitmap.isTopDown() ? "top-down" : "bottom-up");
|
||||
|
||||
if (maxWidth > 0 && (1.0f - cropX) * bitmap.getWidth() > maxWidth) {
|
||||
scale = static_cast<float>(maxWidth) / static_cast<float>((1.0f - cropX) * bitmap.getWidth());
|
||||
const float effectiveWidth = (1.0f - cropX) * bitmap.getWidth();
|
||||
const float effectiveHeight = (1.0f - cropY) * bitmap.getHeight();
|
||||
|
||||
// Calculate scale factor: supports both downscaling and upscaling when both constraints are provided
|
||||
if (maxWidth > 0 && maxHeight > 0) {
|
||||
const float scaleX = static_cast<float>(maxWidth) / effectiveWidth;
|
||||
const float scaleY = static_cast<float>(maxHeight) / effectiveHeight;
|
||||
scale = std::min(scaleX, scaleY);
|
||||
isScaled = (scale < 0.999f || scale > 1.001f);
|
||||
} else if (maxWidth > 0 && effectiveWidth > static_cast<float>(maxWidth)) {
|
||||
scale = static_cast<float>(maxWidth) / effectiveWidth;
|
||||
isScaled = true;
|
||||
}
|
||||
if (maxHeight > 0 && (1.0f - cropY) * bitmap.getHeight() > maxHeight) {
|
||||
scale = std::min(scale, static_cast<float>(maxHeight) / static_cast<float>((1.0f - cropY) * bitmap.getHeight()));
|
||||
} else if (maxHeight > 0 && effectiveHeight > static_cast<float>(maxHeight)) {
|
||||
scale = static_cast<float>(maxHeight) / effectiveHeight;
|
||||
isScaled = true;
|
||||
}
|
||||
LOG_DBG("GFX", "Scaling by %f - %s", scale, isScaled ? "scaled" : "not scaled");
|
||||
@@ -478,12 +496,17 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
|
||||
for (int bmpY = 0; bmpY < (bitmap.getHeight() - cropPixY); bmpY++) {
|
||||
// The BMP's (0, 0) is the bottom-left corner (if the height is positive, top-left if negative).
|
||||
// Screen's (0, 0) is the top-left corner.
|
||||
int screenY = -cropPixY + (bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY);
|
||||
const int logicalY = -cropPixY + (bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY);
|
||||
int screenYStart, screenYEnd;
|
||||
if (isScaled) {
|
||||
screenY = std::floor(screenY * scale);
|
||||
screenYStart = static_cast<int>(std::floor(logicalY * scale)) + y;
|
||||
screenYEnd = static_cast<int>(std::floor((logicalY + 1) * scale)) + y;
|
||||
} else {
|
||||
screenYStart = logicalY + y;
|
||||
screenYEnd = screenYStart + 1;
|
||||
}
|
||||
screenY += y; // the offset should not be scaled
|
||||
if (screenY >= getScreenHeight()) {
|
||||
|
||||
if (screenYStart >= getScreenHeight()) {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -494,7 +517,7 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
|
||||
return;
|
||||
}
|
||||
|
||||
if (screenY < 0) {
|
||||
if (screenYEnd <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -503,27 +526,42 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
|
||||
continue;
|
||||
}
|
||||
|
||||
const int syStart = std::max(screenYStart, 0);
|
||||
const int syEnd = std::min(screenYEnd, getScreenHeight());
|
||||
|
||||
for (int bmpX = cropPixX; bmpX < bitmap.getWidth() - cropPixX; bmpX++) {
|
||||
int screenX = bmpX - cropPixX;
|
||||
const int outX = bmpX - cropPixX;
|
||||
int screenXStart, screenXEnd;
|
||||
if (isScaled) {
|
||||
screenX = std::floor(screenX * scale);
|
||||
screenXStart = static_cast<int>(std::floor(outX * scale)) + x;
|
||||
screenXEnd = static_cast<int>(std::floor((outX + 1) * scale)) + x;
|
||||
} else {
|
||||
screenXStart = outX + x;
|
||||
screenXEnd = screenXStart + 1;
|
||||
}
|
||||
screenX += x; // the offset should not be scaled
|
||||
if (screenX >= getScreenWidth()) {
|
||||
|
||||
if (screenXStart >= getScreenWidth()) {
|
||||
break;
|
||||
}
|
||||
if (screenX < 0) {
|
||||
if (screenXEnd <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3;
|
||||
|
||||
if (renderMode == BW && val < 3) {
|
||||
drawPixel(screenX, screenY);
|
||||
} else if (renderMode == GRAYSCALE_MSB && (val == 1 || val == 2)) {
|
||||
drawPixel(screenX, screenY, false);
|
||||
} else if (renderMode == GRAYSCALE_LSB && val == 1) {
|
||||
drawPixel(screenX, screenY, false);
|
||||
const int sxStart = std::max(screenXStart, 0);
|
||||
const int sxEnd = std::min(screenXEnd, getScreenWidth());
|
||||
|
||||
for (int sy = syStart; sy < syEnd; sy++) {
|
||||
for (int sx = sxStart; sx < sxEnd; sx++) {
|
||||
if (renderMode == BW && val < 3) {
|
||||
drawPixel(sx, sy);
|
||||
} else if (renderMode == GRAYSCALE_MSB && (val == 1 || val == 2)) {
|
||||
drawPixel(sx, sy, false);
|
||||
} else if (renderMode == GRAYSCALE_LSB && val == 1) {
|
||||
drawPixel(sx, sy, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -536,11 +574,16 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y,
|
||||
const int maxHeight) const {
|
||||
float scale = 1.0f;
|
||||
bool isScaled = false;
|
||||
if (maxWidth > 0 && bitmap.getWidth() > maxWidth) {
|
||||
// Calculate scale factor: supports both downscaling and upscaling when both constraints are provided
|
||||
if (maxWidth > 0 && maxHeight > 0) {
|
||||
const float scaleX = static_cast<float>(maxWidth) / static_cast<float>(bitmap.getWidth());
|
||||
const float scaleY = static_cast<float>(maxHeight) / static_cast<float>(bitmap.getHeight());
|
||||
scale = std::min(scaleX, scaleY);
|
||||
isScaled = (scale < 0.999f || scale > 1.001f);
|
||||
} else if (maxWidth > 0 && bitmap.getWidth() > maxWidth) {
|
||||
scale = static_cast<float>(maxWidth) / static_cast<float>(bitmap.getWidth());
|
||||
isScaled = true;
|
||||
}
|
||||
if (maxHeight > 0 && bitmap.getHeight() > maxHeight) {
|
||||
} else if (maxHeight > 0 && bitmap.getHeight() > maxHeight) {
|
||||
scale = std::min(scale, static_cast<float>(maxHeight) / static_cast<float>(bitmap.getHeight()));
|
||||
isScaled = true;
|
||||
}
|
||||
@@ -568,20 +611,37 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y,
|
||||
|
||||
// Calculate screen Y based on whether BMP is top-down or bottom-up
|
||||
const int bmpYOffset = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY;
|
||||
int screenY = y + (isScaled ? static_cast<int>(std::floor(bmpYOffset * scale)) : bmpYOffset);
|
||||
if (screenY >= getScreenHeight()) {
|
||||
int screenYStart, screenYEnd;
|
||||
if (isScaled) {
|
||||
screenYStart = static_cast<int>(std::floor(bmpYOffset * scale)) + y;
|
||||
screenYEnd = static_cast<int>(std::floor((bmpYOffset + 1) * scale)) + y;
|
||||
} else {
|
||||
screenYStart = bmpYOffset + y;
|
||||
screenYEnd = screenYStart + 1;
|
||||
}
|
||||
if (screenYStart >= getScreenHeight()) {
|
||||
continue; // Continue reading to keep row counter in sync
|
||||
}
|
||||
if (screenY < 0) {
|
||||
if (screenYEnd <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const int syStart = std::max(screenYStart, 0);
|
||||
const int syEnd = std::min(screenYEnd, getScreenHeight());
|
||||
|
||||
for (int bmpX = 0; bmpX < bitmap.getWidth(); bmpX++) {
|
||||
int screenX = x + (isScaled ? static_cast<int>(std::floor(bmpX * scale)) : bmpX);
|
||||
if (screenX >= getScreenWidth()) {
|
||||
int screenXStart, screenXEnd;
|
||||
if (isScaled) {
|
||||
screenXStart = static_cast<int>(std::floor(bmpX * scale)) + x;
|
||||
screenXEnd = static_cast<int>(std::floor((bmpX + 1) * scale)) + x;
|
||||
} else {
|
||||
screenXStart = bmpX + x;
|
||||
screenXEnd = screenXStart + 1;
|
||||
}
|
||||
if (screenXStart >= getScreenWidth()) {
|
||||
break;
|
||||
}
|
||||
if (screenX < 0) {
|
||||
if (screenXEnd <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -591,7 +651,13 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y,
|
||||
// For 1-bit source: 0 or 1 -> map to black (0,1,2) or white (3)
|
||||
// val < 3 means black pixel (draw it)
|
||||
if (val < 3) {
|
||||
drawPixel(screenX, screenY, true);
|
||||
const int sxStart = std::max(screenXStart, 0);
|
||||
const int sxEnd = std::min(screenXEnd, getScreenWidth());
|
||||
for (int sy = syStart; sy < syEnd; sy++) {
|
||||
for (int sx = sxStart; sx < sxEnd; sx++) {
|
||||
drawPixel(sx, sy, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
// White pixels (val == 3) are not drawn (leave background)
|
||||
}
|
||||
@@ -689,6 +755,20 @@ 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 "";
|
||||
@@ -872,6 +952,87 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
|
||||
}
|
||||
}
|
||||
|
||||
void GfxRenderer::drawTextRotated90CCW(const int fontId, const int x, const int y, const char* text, const bool black,
|
||||
const EpdFontFamily::Style style) const {
|
||||
// Cannot draw a NULL / empty string
|
||||
if (text == nullptr || *text == '\0') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (fontMap.count(fontId) == 0) {
|
||||
LOG_ERR("GFX", "Font %d not found", fontId);
|
||||
return;
|
||||
}
|
||||
const auto font = fontMap.at(fontId);
|
||||
|
||||
// For 90° counter-clockwise rotation:
|
||||
// Mirror of CW: glyphY maps to -X direction, glyphX maps to +Y direction
|
||||
// Text reads from top to bottom
|
||||
|
||||
const int advanceY = font.getData(style)->advanceY;
|
||||
const int ascender = font.getData(style)->ascender;
|
||||
|
||||
int yPos = y; // Current Y position (increases as we draw characters)
|
||||
|
||||
uint32_t cp;
|
||||
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
|
||||
const EpdGlyph* glyph = font.getGlyph(cp, style);
|
||||
if (!glyph) {
|
||||
glyph = font.getGlyph(REPLACEMENT_GLYPH, style);
|
||||
}
|
||||
if (!glyph) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const int is2Bit = font.getData(style)->is2Bit;
|
||||
const uint32_t offset = glyph->dataOffset;
|
||||
const uint8_t width = glyph->width;
|
||||
const uint8_t height = glyph->height;
|
||||
const int left = glyph->left;
|
||||
const int top = glyph->top;
|
||||
|
||||
const uint8_t* bitmap = &font.getData(style)->bitmap[offset];
|
||||
|
||||
if (bitmap != nullptr) {
|
||||
for (int glyphY = 0; glyphY < height; glyphY++) {
|
||||
for (int glyphX = 0; glyphX < width; glyphX++) {
|
||||
const int pixelPosition = glyphY * width + glyphX;
|
||||
|
||||
// 90° counter-clockwise rotation transformation:
|
||||
// screenX = mirrored CW X (right-to-left within advanceY span)
|
||||
// screenY = yPos + (left + glyphX) (downward)
|
||||
const int screenX = x + advanceY - 1 - (ascender - top + glyphY);
|
||||
const int screenY = yPos + left + glyphX;
|
||||
|
||||
if (is2Bit) {
|
||||
const uint8_t byte = bitmap[pixelPosition / 4];
|
||||
const uint8_t bit_index = (3 - pixelPosition % 4) * 2;
|
||||
const uint8_t bmpVal = 3 - (byte >> bit_index) & 0x3;
|
||||
|
||||
if (renderMode == BW && bmpVal < 3) {
|
||||
drawPixel(screenX, screenY, black);
|
||||
} else if (renderMode == GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) {
|
||||
drawPixel(screenX, screenY, false);
|
||||
} else if (renderMode == GRAYSCALE_LSB && bmpVal == 1) {
|
||||
drawPixel(screenX, screenY, false);
|
||||
}
|
||||
} else {
|
||||
const uint8_t byte = bitmap[pixelPosition / 8];
|
||||
const uint8_t bit_index = 7 - (pixelPosition % 8);
|
||||
|
||||
if ((byte >> bit_index) & 1) {
|
||||
drawPixel(screenX, screenY, black);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Move to next character position (going down, so increase Y)
|
||||
yPos += glyph->advanceX;
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t* GfxRenderer::getFrameBuffer() const { return frameBuffer; }
|
||||
|
||||
size_t GfxRenderer::getBufferSize() { return HalDisplay::BUFFER_SIZE; }
|
||||
|
||||
@@ -77,13 +77,15 @@ 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;
|
||||
|
||||
// Drawing
|
||||
void drawPixel(int x, int y, bool state = true) const;
|
||||
void drawPixelGray(int x, int y, uint8_t val2bit) const;
|
||||
void drawLine(int x1, int y1, int x2, int y2, bool state = true) const;
|
||||
void drawLine(int x1, int y1, int x2, int y2, int lineWidth, bool state) const;
|
||||
void drawArc(int maxRadius, int cx, int cy, int xDir, int yDir, int lineWidth, bool state) const;
|
||||
@@ -117,9 +119,11 @@ class GfxRenderer {
|
||||
std::string truncatedText(int fontId, const char* text, int maxWidth,
|
||||
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
|
||||
|
||||
// Helper for drawing rotated text (90 degrees clockwise, for side buttons)
|
||||
// Helpers for drawing rotated text (for side buttons)
|
||||
void drawTextRotated90CW(int fontId, int x, int y, const char* text, bool black = true,
|
||||
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
|
||||
void drawTextRotated90CCW(int fontId, int x, int y, const char* text, bool black = true,
|
||||
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
|
||||
int getTextHeight(int fontId) const;
|
||||
|
||||
// Grayscale functions
|
||||
|
||||
@@ -120,6 +120,7 @@ enum class StrId : uint16_t {
|
||||
STR_CAT_READER,
|
||||
STR_CAT_CONTROLS,
|
||||
STR_CAT_SYSTEM,
|
||||
STR_CAT_CLOCK,
|
||||
STR_SLEEP_SCREEN,
|
||||
STR_SLEEP_COVER_MODE,
|
||||
STR_STATUS_BAR,
|
||||
@@ -351,6 +352,50 @@ enum class StrId : uint16_t {
|
||||
STR_BOOK_S_STYLE,
|
||||
STR_EMBEDDED_STYLE,
|
||||
STR_OPDS_SERVER_URL,
|
||||
STR_LETTERBOX_FILL,
|
||||
STR_DITHERED,
|
||||
STR_SOLID,
|
||||
STR_ADD_BOOKMARK,
|
||||
STR_REMOVE_BOOKMARK,
|
||||
STR_LOOKUP_WORD,
|
||||
STR_LOOKUP_HISTORY,
|
||||
STR_GO_TO_BOOKMARK,
|
||||
STR_CLOSE_BOOK,
|
||||
STR_DELETE_DICT_CACHE,
|
||||
STR_DEFAULT_OPTION,
|
||||
STR_BOOKMARK_ADDED,
|
||||
STR_BOOKMARK_REMOVED,
|
||||
STR_DICT_CACHE_DELETED,
|
||||
STR_NO_CACHE_TO_DELETE,
|
||||
STR_TABLE_OF_CONTENTS,
|
||||
STR_TOGGLE_ORIENTATION,
|
||||
STR_TOGGLE_FONT_SIZE,
|
||||
STR_OVERRIDE_LETTERBOX_FILL,
|
||||
STR_PREFERRED_PORTRAIT,
|
||||
STR_PREFERRED_LANDSCAPE,
|
||||
STR_CHOOSE_SOMETHING,
|
||||
STR_CLOCK,
|
||||
STR_CLOCK_AMPM,
|
||||
STR_CLOCK_24H,
|
||||
STR_SET_TIME,
|
||||
STR_CLOCK_SIZE,
|
||||
STR_CLOCK_SIZE_SMALL,
|
||||
STR_CLOCK_SIZE_MEDIUM,
|
||||
STR_CLOCK_SIZE_LARGE,
|
||||
STR_TIMEZONE,
|
||||
STR_TZ_UTC,
|
||||
STR_TZ_EASTERN,
|
||||
STR_TZ_CENTRAL,
|
||||
STR_TZ_MOUNTAIN,
|
||||
STR_TZ_PACIFIC,
|
||||
STR_TZ_ALASKA,
|
||||
STR_TZ_HAWAII,
|
||||
STR_TZ_CUSTOM,
|
||||
STR_SET_UTC_OFFSET,
|
||||
STR_INDEXING_DISPLAY,
|
||||
STR_INDEXING_POPUP,
|
||||
STR_INDEXING_STATUS_TEXT,
|
||||
STR_INDEXING_STATUS_ICON,
|
||||
// Sentinel - must be last
|
||||
_COUNT
|
||||
};
|
||||
|
||||
@@ -86,6 +86,7 @@ STR_CAT_DISPLAY: "Displej"
|
||||
STR_CAT_READER: "Čtečka"
|
||||
STR_CAT_CONTROLS: "Ovládací prvky"
|
||||
STR_CAT_SYSTEM: "Systém"
|
||||
STR_CAT_CLOCK: "Hodiny"
|
||||
STR_SLEEP_SCREEN: "Obrazovka spánku"
|
||||
STR_SLEEP_COVER_MODE: "Obrazovka spánku Režim krytu"
|
||||
STR_STATUS_BAR: "Stavový řádek"
|
||||
@@ -317,3 +318,26 @@ STR_UPLOAD: "Nahrát"
|
||||
STR_BOOK_S_STYLE: "Styl knihy"
|
||||
STR_EMBEDDED_STYLE: "Vložený styl"
|
||||
STR_OPDS_SERVER_URL: "URL serveru OPDS"
|
||||
STR_CHOOSE_SOMETHING: "Vyberte si něco ke čtení"
|
||||
STR_CLOCK: "Hodiny"
|
||||
STR_CLOCK_AMPM: "AM/PM"
|
||||
STR_CLOCK_24H: "24 hodin"
|
||||
STR_SET_TIME: "Nastavit čas"
|
||||
STR_CLOCK_SIZE: "Clock Size"
|
||||
STR_CLOCK_SIZE_SMALL: "Small"
|
||||
STR_CLOCK_SIZE_MEDIUM: "Medium"
|
||||
STR_CLOCK_SIZE_LARGE: "Large"
|
||||
STR_TIMEZONE: "Timezone"
|
||||
STR_TZ_UTC: "UTC"
|
||||
STR_TZ_EASTERN: "Eastern"
|
||||
STR_TZ_CENTRAL: "Central"
|
||||
STR_TZ_MOUNTAIN: "Mountain"
|
||||
STR_TZ_PACIFIC: "Pacific"
|
||||
STR_TZ_ALASKA: "Alaska"
|
||||
STR_TZ_HAWAII: "Hawaii"
|
||||
STR_TZ_CUSTOM: "Custom"
|
||||
STR_SET_UTC_OFFSET: "Set UTC Offset"
|
||||
STR_INDEXING_DISPLAY: "Zobrazení indexování"
|
||||
STR_INDEXING_POPUP: "Popup"
|
||||
STR_INDEXING_STATUS_TEXT: "Text stavového řádku"
|
||||
STR_INDEXING_STATUS_ICON: "Ikona stavového řádku"
|
||||
|
||||
@@ -86,6 +86,7 @@ STR_CAT_DISPLAY: "Display"
|
||||
STR_CAT_READER: "Reader"
|
||||
STR_CAT_CONTROLS: "Controls"
|
||||
STR_CAT_SYSTEM: "System"
|
||||
STR_CAT_CLOCK: "Clock"
|
||||
STR_SLEEP_SCREEN: "Sleep Screen"
|
||||
STR_SLEEP_COVER_MODE: "Sleep Screen Cover Mode"
|
||||
STR_STATUS_BAR: "Status Bar"
|
||||
@@ -283,7 +284,7 @@ STR_HW_LEFT_LABEL: "Left (3rd button)"
|
||||
STR_HW_RIGHT_LABEL: "Right (4th button)"
|
||||
STR_GO_TO_PERCENT: "Go to %"
|
||||
STR_GO_HOME_BUTTON: "Go Home"
|
||||
STR_SYNC_PROGRESS: "Sync Progress"
|
||||
STR_SYNC_PROGRESS: "Sync Reading Progress"
|
||||
STR_DELETE_CACHE: "Delete Book Cache"
|
||||
STR_CHAPTER_PREFIX: "Chapter: "
|
||||
STR_PAGES_SEPARATOR: " pages | "
|
||||
@@ -317,3 +318,47 @@ STR_UPLOAD: "Upload"
|
||||
STR_BOOK_S_STYLE: "Book's Style"
|
||||
STR_EMBEDDED_STYLE: "Embedded Style"
|
||||
STR_OPDS_SERVER_URL: "OPDS Server URL"
|
||||
STR_LETTERBOX_FILL: "Letterbox Fill"
|
||||
STR_DITHERED: "Dithered"
|
||||
STR_SOLID: "Solid"
|
||||
STR_ADD_BOOKMARK: "Add Bookmark"
|
||||
STR_REMOVE_BOOKMARK: "Remove Bookmark"
|
||||
STR_LOOKUP_WORD: "Lookup Word"
|
||||
STR_LOOKUP_HISTORY: "Lookup Word History"
|
||||
STR_GO_TO_BOOKMARK: "Go to Bookmark"
|
||||
STR_CLOSE_BOOK: "Close Book"
|
||||
STR_DELETE_DICT_CACHE: "Delete Dictionary Cache"
|
||||
STR_DEFAULT_OPTION: "Default"
|
||||
STR_BOOKMARK_ADDED: "Bookmark added"
|
||||
STR_BOOKMARK_REMOVED: "Bookmark removed"
|
||||
STR_DICT_CACHE_DELETED: "Dictionary cache deleted"
|
||||
STR_NO_CACHE_TO_DELETE: "No cache to delete"
|
||||
STR_TABLE_OF_CONTENTS: "Table of Contents"
|
||||
STR_TOGGLE_ORIENTATION: "Toggle Portrait/Landscape"
|
||||
STR_TOGGLE_FONT_SIZE: "Toggle Font Size"
|
||||
STR_OVERRIDE_LETTERBOX_FILL: "Override Letterbox Fill"
|
||||
STR_PREFERRED_PORTRAIT: "Preferred Portrait"
|
||||
STR_PREFERRED_LANDSCAPE: "Preferred Landscape"
|
||||
STR_CHOOSE_SOMETHING: "Choose something to read"
|
||||
STR_CLOCK: "Clock"
|
||||
STR_CLOCK_AMPM: "AM/PM"
|
||||
STR_CLOCK_24H: "24 Hour"
|
||||
STR_SET_TIME: "Set Time"
|
||||
STR_CLOCK_SIZE: "Clock Size"
|
||||
STR_CLOCK_SIZE_SMALL: "Small"
|
||||
STR_CLOCK_SIZE_MEDIUM: "Medium"
|
||||
STR_CLOCK_SIZE_LARGE: "Large"
|
||||
STR_TIMEZONE: "Timezone"
|
||||
STR_TZ_UTC: "UTC"
|
||||
STR_TZ_EASTERN: "Eastern"
|
||||
STR_TZ_CENTRAL: "Central"
|
||||
STR_TZ_MOUNTAIN: "Mountain"
|
||||
STR_TZ_PACIFIC: "Pacific"
|
||||
STR_TZ_ALASKA: "Alaska"
|
||||
STR_TZ_HAWAII: "Hawaii"
|
||||
STR_TZ_CUSTOM: "Custom"
|
||||
STR_SET_UTC_OFFSET: "Set UTC Offset"
|
||||
STR_INDEXING_DISPLAY: "Indexing Display"
|
||||
STR_INDEXING_POPUP: "Popup"
|
||||
STR_INDEXING_STATUS_TEXT: "Status Bar Text"
|
||||
STR_INDEXING_STATUS_ICON: "Status Bar Icon"
|
||||
|
||||
@@ -86,6 +86,7 @@ STR_CAT_DISPLAY: "Affichage"
|
||||
STR_CAT_READER: "Lecteur"
|
||||
STR_CAT_CONTROLS: "Commandes"
|
||||
STR_CAT_SYSTEM: "Système"
|
||||
STR_CAT_CLOCK: "Horloge"
|
||||
STR_SLEEP_SCREEN: "Écran de veille"
|
||||
STR_SLEEP_COVER_MODE: "Mode d’image de l’écran de veille"
|
||||
STR_STATUS_BAR: "Barre d’état"
|
||||
@@ -317,3 +318,26 @@ STR_UPLOAD: "Envoi"
|
||||
STR_BOOK_S_STYLE: "Style du livre"
|
||||
STR_EMBEDDED_STYLE: "Style intégré"
|
||||
STR_OPDS_SERVER_URL: "URL du serveur OPDS"
|
||||
STR_CHOOSE_SOMETHING: "Choisissez quelque chose à lire"
|
||||
STR_CLOCK: "Horloge"
|
||||
STR_CLOCK_AMPM: "AM/PM"
|
||||
STR_CLOCK_24H: "24 heures"
|
||||
STR_SET_TIME: "Régler l'heure"
|
||||
STR_CLOCK_SIZE: "Clock Size"
|
||||
STR_CLOCK_SIZE_SMALL: "Small"
|
||||
STR_CLOCK_SIZE_MEDIUM: "Medium"
|
||||
STR_CLOCK_SIZE_LARGE: "Large"
|
||||
STR_TIMEZONE: "Timezone"
|
||||
STR_TZ_UTC: "UTC"
|
||||
STR_TZ_EASTERN: "Eastern"
|
||||
STR_TZ_CENTRAL: "Central"
|
||||
STR_TZ_MOUNTAIN: "Mountain"
|
||||
STR_TZ_PACIFIC: "Pacific"
|
||||
STR_TZ_ALASKA: "Alaska"
|
||||
STR_TZ_HAWAII: "Hawaii"
|
||||
STR_TZ_CUSTOM: "Custom"
|
||||
STR_SET_UTC_OFFSET: "Set UTC Offset"
|
||||
STR_INDEXING_DISPLAY: "Affichage indexation"
|
||||
STR_INDEXING_POPUP: "Popup"
|
||||
STR_INDEXING_STATUS_TEXT: "Texte barre d'état"
|
||||
STR_INDEXING_STATUS_ICON: "Icône barre d'état"
|
||||
|
||||
@@ -86,6 +86,7 @@ STR_CAT_DISPLAY: "Anzeige"
|
||||
STR_CAT_READER: "Lesen"
|
||||
STR_CAT_CONTROLS: "Bedienung"
|
||||
STR_CAT_SYSTEM: "System"
|
||||
STR_CAT_CLOCK: "Uhr"
|
||||
STR_SLEEP_SCREEN: "Standby-Bild"
|
||||
STR_SLEEP_COVER_MODE: "Standby-Bildmodus"
|
||||
STR_STATUS_BAR: "Statusleiste"
|
||||
@@ -317,3 +318,26 @@ STR_UPLOAD: "Hochladen"
|
||||
STR_BOOK_S_STYLE: "Buch-Stil"
|
||||
STR_EMBEDDED_STYLE: "Eingebetteter Stil"
|
||||
STR_OPDS_SERVER_URL: "OPDS-Server-URL"
|
||||
STR_CHOOSE_SOMETHING: "Wähle etwas zum Lesen"
|
||||
STR_CLOCK: "Uhr"
|
||||
STR_CLOCK_AMPM: "AM/PM"
|
||||
STR_CLOCK_24H: "24 Stunden"
|
||||
STR_SET_TIME: "Uhrzeit einstellen"
|
||||
STR_CLOCK_SIZE: "Clock Size"
|
||||
STR_CLOCK_SIZE_SMALL: "Small"
|
||||
STR_CLOCK_SIZE_MEDIUM: "Medium"
|
||||
STR_CLOCK_SIZE_LARGE: "Large"
|
||||
STR_TIMEZONE: "Timezone"
|
||||
STR_TZ_UTC: "UTC"
|
||||
STR_TZ_EASTERN: "Eastern"
|
||||
STR_TZ_CENTRAL: "Central"
|
||||
STR_TZ_MOUNTAIN: "Mountain"
|
||||
STR_TZ_PACIFIC: "Pacific"
|
||||
STR_TZ_ALASKA: "Alaska"
|
||||
STR_TZ_HAWAII: "Hawaii"
|
||||
STR_TZ_CUSTOM: "Custom"
|
||||
STR_SET_UTC_OFFSET: "Set UTC Offset"
|
||||
STR_INDEXING_DISPLAY: "Indexierungsanzeige"
|
||||
STR_INDEXING_POPUP: "Popup"
|
||||
STR_INDEXING_STATUS_TEXT: "Statusleistentext"
|
||||
STR_INDEXING_STATUS_ICON: "Statusleistensymbol"
|
||||
|
||||
@@ -86,6 +86,7 @@ STR_CAT_DISPLAY: "Tela"
|
||||
STR_CAT_READER: "Leitor"
|
||||
STR_CAT_CONTROLS: "Controles"
|
||||
STR_CAT_SYSTEM: "Sistema"
|
||||
STR_CAT_CLOCK: "Relógio"
|
||||
STR_SLEEP_SCREEN: "Tela de repouso"
|
||||
STR_SLEEP_COVER_MODE: "Modo capa tela repouso"
|
||||
STR_STATUS_BAR: "Barra de status"
|
||||
@@ -317,3 +318,26 @@ STR_UPLOAD: "Enviar"
|
||||
STR_BOOK_S_STYLE: "Estilo do livro"
|
||||
STR_EMBEDDED_STYLE: "Estilo embutido"
|
||||
STR_OPDS_SERVER_URL: "URL do servidor OPDS"
|
||||
STR_CHOOSE_SOMETHING: "Escolha algo para ler"
|
||||
STR_CLOCK: "Relógio"
|
||||
STR_CLOCK_AMPM: "AM/PM"
|
||||
STR_CLOCK_24H: "24 horas"
|
||||
STR_SET_TIME: "Definir hora"
|
||||
STR_CLOCK_SIZE: "Clock Size"
|
||||
STR_CLOCK_SIZE_SMALL: "Small"
|
||||
STR_CLOCK_SIZE_MEDIUM: "Medium"
|
||||
STR_CLOCK_SIZE_LARGE: "Large"
|
||||
STR_TIMEZONE: "Timezone"
|
||||
STR_TZ_UTC: "UTC"
|
||||
STR_TZ_EASTERN: "Eastern"
|
||||
STR_TZ_CENTRAL: "Central"
|
||||
STR_TZ_MOUNTAIN: "Mountain"
|
||||
STR_TZ_PACIFIC: "Pacific"
|
||||
STR_TZ_ALASKA: "Alaska"
|
||||
STR_TZ_HAWAII: "Hawaii"
|
||||
STR_TZ_CUSTOM: "Custom"
|
||||
STR_SET_UTC_OFFSET: "Set UTC Offset"
|
||||
STR_INDEXING_DISPLAY: "Exibição de indexação"
|
||||
STR_INDEXING_POPUP: "Popup"
|
||||
STR_INDEXING_STATUS_TEXT: "Texto da barra"
|
||||
STR_INDEXING_STATUS_ICON: "Ícone da barra"
|
||||
|
||||
@@ -86,6 +86,7 @@ STR_CAT_DISPLAY: "Экран"
|
||||
STR_CAT_READER: "Чтение"
|
||||
STR_CAT_CONTROLS: "Управление"
|
||||
STR_CAT_SYSTEM: "Система"
|
||||
STR_CAT_CLOCK: "Часы"
|
||||
STR_SLEEP_SCREEN: "Экран сна"
|
||||
STR_SLEEP_COVER_MODE: "Режим обложки сна"
|
||||
STR_STATUS_BAR: "Строка состояния"
|
||||
@@ -317,3 +318,26 @@ STR_UPLOAD: "Отправить"
|
||||
STR_BOOK_S_STYLE: "Стиль книги"
|
||||
STR_EMBEDDED_STYLE: "Встроенный стиль"
|
||||
STR_OPDS_SERVER_URL: "URL OPDS сервера"
|
||||
STR_CHOOSE_SOMETHING: "Выберите что-нибудь для чтения"
|
||||
STR_CLOCK: "Часы"
|
||||
STR_CLOCK_AMPM: "AM/PM"
|
||||
STR_CLOCK_24H: "24 часа"
|
||||
STR_SET_TIME: "Установить время"
|
||||
STR_CLOCK_SIZE: "Clock Size"
|
||||
STR_CLOCK_SIZE_SMALL: "Small"
|
||||
STR_CLOCK_SIZE_MEDIUM: "Medium"
|
||||
STR_CLOCK_SIZE_LARGE: "Large"
|
||||
STR_TIMEZONE: "Timezone"
|
||||
STR_TZ_UTC: "UTC"
|
||||
STR_TZ_EASTERN: "Eastern"
|
||||
STR_TZ_CENTRAL: "Central"
|
||||
STR_TZ_MOUNTAIN: "Mountain"
|
||||
STR_TZ_PACIFIC: "Pacific"
|
||||
STR_TZ_ALASKA: "Alaska"
|
||||
STR_TZ_HAWAII: "Hawaii"
|
||||
STR_TZ_CUSTOM: "Custom"
|
||||
STR_SET_UTC_OFFSET: "Set UTC Offset"
|
||||
STR_INDEXING_DISPLAY: "Отображение индексации"
|
||||
STR_INDEXING_POPUP: "Popup"
|
||||
STR_INDEXING_STATUS_TEXT: "Текст в строке"
|
||||
STR_INDEXING_STATUS_ICON: "Иконка в строке"
|
||||
|
||||
@@ -86,6 +86,7 @@ STR_CAT_DISPLAY: "Pantalla"
|
||||
STR_CAT_READER: "Lector"
|
||||
STR_CAT_CONTROLS: "Control"
|
||||
STR_CAT_SYSTEM: "Sistema"
|
||||
STR_CAT_CLOCK: "Reloj"
|
||||
STR_SLEEP_SCREEN: "Salva Pantallas"
|
||||
STR_SLEEP_COVER_MODE: "Modo de salva pantallas"
|
||||
STR_STATUS_BAR: "Barra de estado"
|
||||
@@ -317,3 +318,26 @@ STR_UPLOAD: "Subir"
|
||||
STR_BOOK_S_STYLE: "Estilo del libro"
|
||||
STR_EMBEDDED_STYLE: "Estilo integrado"
|
||||
STR_OPDS_SERVER_URL: "URL del servidor OPDS"
|
||||
STR_CHOOSE_SOMETHING: "Elige algo para leer"
|
||||
STR_CLOCK: "Reloj"
|
||||
STR_CLOCK_AMPM: "AM/PM"
|
||||
STR_CLOCK_24H: "24 horas"
|
||||
STR_SET_TIME: "Establecer hora"
|
||||
STR_CLOCK_SIZE: "Clock Size"
|
||||
STR_CLOCK_SIZE_SMALL: "Small"
|
||||
STR_CLOCK_SIZE_MEDIUM: "Medium"
|
||||
STR_CLOCK_SIZE_LARGE: "Large"
|
||||
STR_TIMEZONE: "Timezone"
|
||||
STR_TZ_UTC: "UTC"
|
||||
STR_TZ_EASTERN: "Eastern"
|
||||
STR_TZ_CENTRAL: "Central"
|
||||
STR_TZ_MOUNTAIN: "Mountain"
|
||||
STR_TZ_PACIFIC: "Pacific"
|
||||
STR_TZ_ALASKA: "Alaska"
|
||||
STR_TZ_HAWAII: "Hawaii"
|
||||
STR_TZ_CUSTOM: "Custom"
|
||||
STR_SET_UTC_OFFSET: "Set UTC Offset"
|
||||
STR_INDEXING_DISPLAY: "Mostrar indexación"
|
||||
STR_INDEXING_POPUP: "Popup"
|
||||
STR_INDEXING_STATUS_TEXT: "Texto barra estado"
|
||||
STR_INDEXING_STATUS_ICON: "Icono barra estado"
|
||||
|
||||
@@ -86,6 +86,7 @@ STR_CAT_DISPLAY: "Skärm"
|
||||
STR_CAT_READER: "Läsare"
|
||||
STR_CAT_CONTROLS: "Kontroller"
|
||||
STR_CAT_SYSTEM: "System"
|
||||
STR_CAT_CLOCK: "Klocka"
|
||||
STR_SLEEP_SCREEN: "Viloskärm"
|
||||
STR_SLEEP_COVER_MODE: "Viloskärmens omslagsläge"
|
||||
STR_STATUS_BAR: "Statusrad"
|
||||
@@ -317,3 +318,26 @@ STR_UPLOAD: "Uppladdning"
|
||||
STR_BOOK_S_STYLE: "Bokstil"
|
||||
STR_EMBEDDED_STYLE: "Inbäddad stil"
|
||||
STR_OPDS_SERVER_URL: "OPDS-serveradress"
|
||||
STR_CHOOSE_SOMETHING: "Välj något att läsa"
|
||||
STR_CLOCK: "Klocka"
|
||||
STR_CLOCK_AMPM: "AM/PM"
|
||||
STR_CLOCK_24H: "24 timmar"
|
||||
STR_SET_TIME: "Ställ in tid"
|
||||
STR_CLOCK_SIZE: "Clock Size"
|
||||
STR_CLOCK_SIZE_SMALL: "Small"
|
||||
STR_CLOCK_SIZE_MEDIUM: "Medium"
|
||||
STR_CLOCK_SIZE_LARGE: "Large"
|
||||
STR_TIMEZONE: "Timezone"
|
||||
STR_TZ_UTC: "UTC"
|
||||
STR_TZ_EASTERN: "Eastern"
|
||||
STR_TZ_CENTRAL: "Central"
|
||||
STR_TZ_MOUNTAIN: "Mountain"
|
||||
STR_TZ_PACIFIC: "Pacific"
|
||||
STR_TZ_ALASKA: "Alaska"
|
||||
STR_TZ_HAWAII: "Hawaii"
|
||||
STR_TZ_CUSTOM: "Custom"
|
||||
STR_SET_UTC_OFFSET: "Set UTC Offset"
|
||||
STR_INDEXING_DISPLAY: "Indexeringsvisning"
|
||||
STR_INDEXING_POPUP: "Popup"
|
||||
STR_INDEXING_STATUS_TEXT: "Statusfältstext"
|
||||
STR_INDEXING_STATUS_ICON: "Statusfältsikon"
|
||||
|
||||
25
lib/PlaceholderCover/BookIcon.h
Normal file
25
lib/PlaceholderCover/BookIcon.h
Normal file
@@ -0,0 +1,25 @@
|
||||
#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,
|
||||
};
|
||||
474
lib/PlaceholderCover/PlaceholderCoverGenerator.cpp
Normal file
474
lib/PlaceholderCover/PlaceholderCoverGenerator.cpp
Normal file
@@ -0,0 +1,474 @@
|
||||
#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;
|
||||
};
|
||||
|
||||
@@ -37,6 +37,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);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,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();
|
||||
|
||||
|
||||
@@ -4,12 +4,8 @@
|
||||
#include <WiFi.h>
|
||||
#include <esp_sleep.h>
|
||||
|
||||
#include <cassert>
|
||||
|
||||
#include "HalGPIO.h"
|
||||
|
||||
HalPowerManager powerManager; // Singleton instance
|
||||
|
||||
void HalPowerManager::begin() {
|
||||
pinMode(BAT_GPIO0, INPUT);
|
||||
normalFreq = getCpuFrequencyMhz();
|
||||
@@ -19,37 +15,55 @@ void HalPowerManager::begin() {
|
||||
|
||||
void HalPowerManager::setPowerSaving(bool enabled) {
|
||||
if (normalFreq <= 0) {
|
||||
return; // invalid state
|
||||
return;
|
||||
}
|
||||
|
||||
auto wifiMode = WiFi.getMode();
|
||||
if (wifiMode != WIFI_MODE_NULL) {
|
||||
// Wifi is active, force disabling power saving
|
||||
enabled = false;
|
||||
if (enabled) {
|
||||
if (WiFi.getMode() != WIFI_MODE_NULL) {
|
||||
enabled = false;
|
||||
}
|
||||
xSemaphoreTake(modeMutex, portMAX_DELAY);
|
||||
const LockMode mode = currentLockMode;
|
||||
xSemaphoreGive(modeMutex);
|
||||
if (mode == NormalSpeed) {
|
||||
enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Note: We don't use mutex here to avoid too much overhead,
|
||||
// it's not very important if we read a slightly stale value for currentLockMode
|
||||
const LockMode mode = currentLockMode;
|
||||
|
||||
if (mode == None && enabled && !isLowPower) {
|
||||
if (enabled && !isLowPower) {
|
||||
LOG_DBG("PWR", "Going to low-power mode");
|
||||
if (!setCpuFrequencyMhz(LOW_POWER_FREQ)) {
|
||||
LOG_DBG("PWR", "Failed to set CPU frequency = %d MHz", LOW_POWER_FREQ);
|
||||
return;
|
||||
}
|
||||
isLowPower = true;
|
||||
|
||||
} else if ((!enabled || mode != None) && isLowPower) {
|
||||
}
|
||||
if (!enabled && isLowPower) {
|
||||
LOG_DBG("PWR", "Restoring normal CPU frequency");
|
||||
if (!setCpuFrequencyMhz(normalFreq)) {
|
||||
LOG_DBG("PWR", "Failed to set CPU frequency = %d MHz", normalFreq);
|
||||
return;
|
||||
}
|
||||
isLowPower = false;
|
||||
}
|
||||
isLowPower = enabled;
|
||||
}
|
||||
|
||||
// Otherwise, no change needed
|
||||
// RAII Lock implementation
|
||||
|
||||
HalPowerManager::Lock::Lock() {
|
||||
xSemaphoreTake(powerManager.modeMutex, portMAX_DELAY);
|
||||
powerManager.currentLockMode = NormalSpeed;
|
||||
valid = true;
|
||||
if (powerManager.isLowPower) {
|
||||
powerManager.setPowerSaving(false);
|
||||
}
|
||||
xSemaphoreGive(powerManager.modeMutex);
|
||||
}
|
||||
|
||||
HalPowerManager::Lock::~Lock() {
|
||||
if (!valid) return;
|
||||
xSemaphoreTake(powerManager.modeMutex, portMAX_DELAY);
|
||||
powerManager.currentLockMode = None;
|
||||
xSemaphoreGive(powerManager.modeMutex);
|
||||
}
|
||||
|
||||
void HalPowerManager::startDeepSleep(HalGPIO& gpio) const {
|
||||
@@ -68,28 +82,3 @@ int HalPowerManager::getBatteryPercentage() const {
|
||||
static const BatteryMonitor battery = BatteryMonitor(BAT_GPIO0);
|
||||
return battery.readPercentage();
|
||||
}
|
||||
|
||||
HalPowerManager::Lock::Lock() {
|
||||
xSemaphoreTake(powerManager.modeMutex, portMAX_DELAY);
|
||||
// Current limitation: only one lock at a time
|
||||
if (powerManager.currentLockMode != None) {
|
||||
LOG_ERR("PWR", "Lock already held, ignore");
|
||||
valid = false;
|
||||
} else {
|
||||
powerManager.currentLockMode = NormalSpeed;
|
||||
valid = true;
|
||||
}
|
||||
xSemaphoreGive(powerManager.modeMutex);
|
||||
if (valid) {
|
||||
// Immediately restore normal CPU frequency if currently in low-power mode
|
||||
powerManager.setPowerSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
HalPowerManager::Lock::~Lock() {
|
||||
xSemaphoreTake(powerManager.modeMutex, portMAX_DELAY);
|
||||
if (valid) {
|
||||
powerManager.currentLockMode = None;
|
||||
}
|
||||
xSemaphoreGive(powerManager.modeMutex);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <BatteryMonitor.h>
|
||||
#include <InputManager.h>
|
||||
#include <Logging.h>
|
||||
#include <freertos/semphr.h>
|
||||
|
||||
#include <cassert>
|
||||
|
||||
#include "HalGPIO.h"
|
||||
|
||||
class HalPowerManager;
|
||||
extern HalPowerManager powerManager; // Singleton
|
||||
|
||||
class HalPowerManager {
|
||||
int normalFreq = 0; // MHz
|
||||
bool isLowPower = false;
|
||||
|
||||
enum LockMode { None, NormalSpeed };
|
||||
LockMode currentLockMode = None;
|
||||
SemaphoreHandle_t modeMutex = nullptr; // Protect access to currentLockMode
|
||||
SemaphoreHandle_t modeMutex = nullptr;
|
||||
|
||||
public:
|
||||
static constexpr int LOW_POWER_FREQ = 10; // MHz
|
||||
@@ -30,27 +25,24 @@ class HalPowerManager {
|
||||
void setPowerSaving(bool enabled);
|
||||
|
||||
// Setup wake up GPIO and enter deep sleep
|
||||
// Should be called inside main loop() to handle the currentLockMode
|
||||
void startDeepSleep(HalGPIO& gpio) const;
|
||||
|
||||
// Get battery percentage (range 0-100)
|
||||
int getBatteryPercentage() const;
|
||||
|
||||
// RAII helper class to manage power saving locks
|
||||
// Usage: create an instance of Lock in a scope to disable power saving, for example when running a task that needs
|
||||
// full performance. When the Lock instance is destroyed (goes out of scope), power saving will be re-enabled.
|
||||
// RAII lock to prevent low-power mode during critical work (e.g. rendering)
|
||||
class Lock {
|
||||
friend class HalPowerManager;
|
||||
bool valid = false;
|
||||
|
||||
public:
|
||||
explicit Lock();
|
||||
Lock();
|
||||
~Lock();
|
||||
|
||||
// Non-copyable and non-movable
|
||||
Lock(const Lock&) = delete;
|
||||
Lock& operator=(const Lock&) = delete;
|
||||
Lock(Lock&&) = delete;
|
||||
Lock& operator=(Lock&&) = delete;
|
||||
};
|
||||
};
|
||||
|
||||
extern HalPowerManager powerManager;
|
||||
|
||||
@@ -67,6 +67,22 @@ build_flags =
|
||||
-DLOG_LEVEL=2 ; Set log level to debug for development builds
|
||||
|
||||
|
||||
[env:mod]
|
||||
extends = base
|
||||
extra_scripts =
|
||||
${base.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
|
||||
|
||||
[env:gh_release]
|
||||
extends = base
|
||||
build_flags =
|
||||
|
||||
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)
|
||||
@@ -84,9 +84,6 @@ def create_grayscale_test_image(filename, is_png=True):
|
||||
start_y = 65
|
||||
gap = 10
|
||||
|
||||
# Gray levels chosen to avoid Bayer dithering threshold boundaries (±40 dither offset)
|
||||
# Thresholds at 64, 128, 192 - use values in the middle of each band for solid output
|
||||
# Safe zones: 0-23 (black), 88-103 (dark gray), 152-167 (light gray), 232-255 (white)
|
||||
levels = [
|
||||
(0, "Level 0: BLACK"),
|
||||
(96, "Level 1: DARK GRAY"),
|
||||
@@ -107,13 +104,12 @@ def create_grayscale_test_image(filename, is_png=True):
|
||||
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 (well below the last square)
|
||||
# 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)
|
||||
|
||||
# Save
|
||||
if is_png:
|
||||
img.save(filename, 'PNG')
|
||||
else:
|
||||
@@ -170,7 +166,6 @@ def create_scaling_test_image(filename, is_png=True):
|
||||
"""
|
||||
Create large image to verify scaling works.
|
||||
"""
|
||||
# Make image larger than screen but within decoder limits (max 2048x1536)
|
||||
width, height = 1200, 1500
|
||||
img = Image.new('L', (width, height), 240)
|
||||
draw = ImageDraw.Draw(img)
|
||||
@@ -186,7 +181,7 @@ def create_scaling_test_image(filename, is_png=True):
|
||||
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 to verify scaling quality
|
||||
# Grid pattern
|
||||
grid_start_y = 220
|
||||
grid_size = 400
|
||||
cell_size = 50
|
||||
@@ -203,35 +198,7 @@ def create_scaling_test_image(filename, is_png=True):
|
||||
else:
|
||||
draw.rectangle([x, y, x + cell_size, y + cell_size], fill=200)
|
||||
|
||||
# Size indicator bars
|
||||
y = grid_start_y + grid_size + 60
|
||||
draw_text_centered(draw, y, "Width markers (should fit on screen):", font_small, fill=0)
|
||||
|
||||
bar_y = y + 40
|
||||
# Full width bar
|
||||
draw.rectangle([50, bar_y, width - 50, bar_y + 30], fill=0)
|
||||
draw.text((60, bar_y + 5), "FULL WIDTH", font=font_small, fill=255)
|
||||
|
||||
# Half width bar
|
||||
bar_y += 60
|
||||
half_start = width // 4
|
||||
draw.rectangle([half_start, bar_y, width - half_start, bar_y + 30], fill=85)
|
||||
draw.text((half_start + 10, bar_y + 5), "HALF WIDTH", font=font_small, fill=255)
|
||||
|
||||
# Instructions
|
||||
y = height - 350
|
||||
draw_text_centered(draw, y, "VERIFICATION:", font_medium, fill=0)
|
||||
y += 50
|
||||
instructions = [
|
||||
"1. Image fits within screen bounds",
|
||||
"2. All borders visible (not cropped)",
|
||||
"3. Grid pattern clear (no moire)",
|
||||
"4. Text readable after scaling",
|
||||
"5. Aspect ratio preserved (not stretched)",
|
||||
]
|
||||
for i, text in enumerate(instructions):
|
||||
draw_text_centered(draw, y + i * 35, text, font_small, fill=64)
|
||||
|
||||
# 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)
|
||||
@@ -241,73 +208,6 @@ def create_scaling_test_image(filename, is_png=True):
|
||||
else:
|
||||
img.save(filename, 'JPEG', quality=95)
|
||||
|
||||
def create_wide_scaling_test_image(filename, is_png=True):
|
||||
"""
|
||||
Create wide image (1807x736) to test scaling with specific dimensions
|
||||
that can trigger cache dimension mismatches due to floating-point rounding.
|
||||
"""
|
||||
width, height = 1807, 736
|
||||
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=6)
|
||||
draw.rectangle([15, 15, width-16, height-16], outline=128, width=3)
|
||||
|
||||
# Title
|
||||
draw_text_centered(draw, 40, "WIDE SCALING TEST", font, fill=0)
|
||||
draw_text_centered(draw, 100, f"Original: {width}x{height} (tests rounding edge case)", font_medium, fill=64)
|
||||
|
||||
# Grid pattern to verify scaling quality
|
||||
grid_start_x = 100
|
||||
grid_start_y = 180
|
||||
grid_width = 600
|
||||
grid_height = 300
|
||||
cell_size = 50
|
||||
|
||||
draw.text((grid_start_x, grid_start_y - 35), "Grid pattern (check for artifacts):", font=font_small, fill=0)
|
||||
|
||||
for row in range(grid_height // cell_size):
|
||||
for col in range(grid_width // cell_size):
|
||||
x = grid_start_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)
|
||||
|
||||
# Verification section on the right
|
||||
text_x = 800
|
||||
text_y = 180
|
||||
draw.text((text_x, text_y), "VERIFICATION:", font=font_medium, fill=0)
|
||||
text_y += 50
|
||||
instructions = [
|
||||
"1. Image fits within screen",
|
||||
"2. All borders visible",
|
||||
"3. Grid pattern clear",
|
||||
"4. Text readable",
|
||||
"5. No double-decode in log",
|
||||
]
|
||||
for i, text in enumerate(instructions):
|
||||
draw.text((text_x, text_y + i * 35), text, font=font_small, fill=64)
|
||||
|
||||
# Dimension info
|
||||
draw.text((text_x, 450), f"Dimensions: {width}x{height}", font=font_small, fill=0)
|
||||
draw.text((text_x, 485), "Tests cache dimension matching", font=font_small, fill=64)
|
||||
|
||||
# Pass/fail at bottom
|
||||
y = height - 80
|
||||
draw_text_centered(draw, y, "PASS: Single decode, cached correctly", font_small, fill=0)
|
||||
draw_text_centered(draw, y + 30, "FAIL: Cache mismatch, multiple decodes", 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.
|
||||
@@ -340,67 +240,6 @@ def create_cache_test_image(filename, page_num, is_png=True):
|
||||
else:
|
||||
img.save(filename, 'JPEG', quality=95)
|
||||
|
||||
def create_gradient_test_image(filename, is_png=True):
|
||||
"""
|
||||
Create horizontal gradient to test grayscale banding.
|
||||
"""
|
||||
width, height = 400, 500
|
||||
img = Image.new('L', (width, height), 255)
|
||||
draw = ImageDraw.Draw(img)
|
||||
font = get_font(16)
|
||||
font_small = get_font(14)
|
||||
|
||||
draw_text_centered(draw, 10, "GRADIENT TEST", font, fill=0)
|
||||
draw_text_centered(draw, 35, "Smooth gradient → 4 bands expected", font_small, fill=64)
|
||||
|
||||
# Horizontal gradient
|
||||
gradient_y = 70
|
||||
gradient_height = 100
|
||||
for x in range(width):
|
||||
gray = int(255 * x / width)
|
||||
draw.line([(x, gradient_y), (x, gradient_y + gradient_height)], fill=gray)
|
||||
|
||||
# Border around gradient
|
||||
draw.rectangle([0, gradient_y-1, width-1, gradient_y + gradient_height + 1], outline=0, width=1)
|
||||
|
||||
# Labels
|
||||
y = gradient_y + gradient_height + 10
|
||||
draw.text((5, y), "BLACK", font=font_small, fill=0)
|
||||
draw.text((width - 50, y), "WHITE", font=font_small, fill=0)
|
||||
|
||||
# 4-step gradient (what it should look like)
|
||||
y = 220
|
||||
draw_text_centered(draw, y, "Expected result (4 distinct bands):", font_small, fill=0)
|
||||
|
||||
band_y = y + 25
|
||||
band_height = 60
|
||||
band_width = width // 4
|
||||
for i, gray in enumerate([0, 85, 170, 255]):
|
||||
x = i * band_width
|
||||
draw.rectangle([x, band_y, x + band_width, band_y + band_height], fill=gray)
|
||||
draw.rectangle([0, band_y-1, width-1, band_y + band_height + 1], outline=0, width=1)
|
||||
|
||||
# Vertical gradient
|
||||
y = 340
|
||||
draw_text_centered(draw, y, "Vertical gradient:", font_small, fill=0)
|
||||
|
||||
vgrad_y = y + 25
|
||||
vgrad_height = 80
|
||||
for row in range(vgrad_height):
|
||||
gray = int(255 * row / vgrad_height)
|
||||
draw.line([(50, vgrad_y + row), (width - 50, vgrad_y + row)], fill=gray)
|
||||
draw.rectangle([49, vgrad_y-1, width-49, vgrad_y + vgrad_height + 1], outline=0, width=1)
|
||||
|
||||
# Pass/fail
|
||||
y = height - 50
|
||||
draw_text_centered(draw, y, "PASS: Clear 4-band quantization", font_small, fill=0)
|
||||
draw_text_centered(draw, y + 20, "FAIL: Binary/noisy dithering", 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.
|
||||
@@ -523,22 +362,18 @@ def make_chapter(title, body_content):
|
||||
def main():
|
||||
OUTPUT_DIR.mkdir(exist_ok=True)
|
||||
|
||||
# Temp directory for images
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir = Path(tmpdir)
|
||||
|
||||
print("Generating test images...")
|
||||
|
||||
# Generate all 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_wide_scaling_test_image(tmpdir / 'wide_scaling_test.jpg', is_png=False)
|
||||
create_gradient_test_image(tmpdir / 'gradient_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)
|
||||
@@ -547,8 +382,6 @@ def main():
|
||||
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_wide_scaling_test_image(tmpdir / 'wide_scaling_test.png', is_png=True)
|
||||
create_gradient_test_image(tmpdir / 'gradient_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)
|
||||
@@ -562,13 +395,6 @@ def main():
|
||||
("Introduction", make_chapter("JPEG Image Tests", """
|
||||
<p>This EPUB tests JPEG image rendering.</p>
|
||||
<p>Navigate through chapters to verify each test case.</p>
|
||||
<p><strong>Test Plan:</strong></p>
|
||||
<ul>
|
||||
<li>Grayscale rendering (4 levels)</li>
|
||||
<li>Image centering</li>
|
||||
<li>Large image scaling</li>
|
||||
<li>Cache performance</li>
|
||||
</ul>
|
||||
"""), []),
|
||||
("1. JPEG Format", make_chapter("JPEG Format Test", """
|
||||
<p>Basic JPEG decoding test.</p>
|
||||
@@ -579,30 +405,21 @@ def main():
|
||||
<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. Gradient", make_chapter("Gradient Test", """
|
||||
<p>Verify gradient quantizes to 4 bands.</p>
|
||||
<img src="images/gradient_test.jpg" alt="Gradient test"/>
|
||||
"""), [('gradient_test.jpg', images['gradient_test.jpg'])]),
|
||||
("4. Centering", make_chapter("Centering Test", """
|
||||
("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'])]),
|
||||
("5. Scaling", make_chapter("Scaling Test", """
|
||||
("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'])]),
|
||||
("6. Wide Scaling", make_chapter("Wide Scaling Test", """
|
||||
<p>This image is 1807x736 pixels - a wide landscape format.</p>
|
||||
<p>Tests scaling with dimensions that can cause cache mismatches.</p>
|
||||
<img src="images/wide_scaling_test.jpg" alt="Wide scaling test"/>
|
||||
"""), [('wide_scaling_test.jpg', images['wide_scaling_test.jpg'])]),
|
||||
("7. Cache Test A", make_chapter("Cache Test - Page A", """
|
||||
("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'])]),
|
||||
("8. Cache Test B", make_chapter("Cache Test - Page B", """
|
||||
("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>
|
||||
@@ -616,13 +433,6 @@ def main():
|
||||
("Introduction", make_chapter("PNG Image Tests", """
|
||||
<p>This EPUB tests PNG image rendering.</p>
|
||||
<p>Navigate through chapters to verify each test case.</p>
|
||||
<p><strong>Test Plan:</strong></p>
|
||||
<ul>
|
||||
<li>PNG decoding (no crash)</li>
|
||||
<li>Grayscale rendering (4 levels)</li>
|
||||
<li>Image centering</li>
|
||||
<li>Large image scaling</li>
|
||||
</ul>
|
||||
"""), []),
|
||||
("1. PNG Format", make_chapter("PNG Format Test", """
|
||||
<p>Basic PNG decoding test.</p>
|
||||
@@ -633,30 +443,21 @@ def main():
|
||||
<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. Gradient", make_chapter("Gradient Test", """
|
||||
<p>Verify gradient quantizes to 4 bands.</p>
|
||||
<img src="images/gradient_test.png" alt="Gradient test"/>
|
||||
"""), [('gradient_test.png', images['gradient_test.png'])]),
|
||||
("4. Centering", make_chapter("Centering Test", """
|
||||
("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'])]),
|
||||
("5. Scaling", make_chapter("Scaling Test", """
|
||||
("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'])]),
|
||||
("6. Wide Scaling", make_chapter("Wide Scaling Test", """
|
||||
<p>This image is 1807x736 pixels - a wide landscape format.</p>
|
||||
<p>Tests scaling with dimensions that can cause cache mismatches.</p>
|
||||
<img src="images/wide_scaling_test.png" alt="Wide scaling test"/>
|
||||
"""), [('wide_scaling_test.png', images['wide_scaling_test.png'])]),
|
||||
("7. Cache Test A", make_chapter("Cache Test - Page A", """
|
||||
("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'])]),
|
||||
("8. Cache Test B", make_chapter("Cache Test - Page B", """
|
||||
("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>
|
||||
|
||||
15
scripts/inject_mod_version.py
Normal file
15
scripts/inject_mod_version.py
Normal file
@@ -0,0 +1,15 @@
|
||||
Import("env")
|
||||
import subprocess
|
||||
|
||||
config = env.GetProjectConfig()
|
||||
version = config.get("crosspoint", "version")
|
||||
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--short", "HEAD"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
git_hash = result.stdout.strip()
|
||||
|
||||
env.Append(
|
||||
BUILD_FLAGS=[f'-DCROSSPOINT_VERSION=\\"{version}-mod+{git_hash}\\"']
|
||||
)
|
||||
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)
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <Logging.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
@@ -134,7 +135,15 @@ uint8_t CrossPointSettings::writeSettings(FsFile& file, bool count_only) const {
|
||||
writer.writeItem(file, frontButtonRight);
|
||||
writer.writeItem(file, fadingFix);
|
||||
writer.writeItem(file, embeddedStyle);
|
||||
// New fields need to be added at end for backward compatibility
|
||||
writer.writeItem(file, sleepScreenLetterboxFill);
|
||||
// New fields added at end for backward compatibility
|
||||
writer.writeItem(file, preferredPortrait);
|
||||
writer.writeItem(file, preferredLandscape);
|
||||
writer.writeItem(file, clockFormat);
|
||||
writer.writeItem(file, clockSize);
|
||||
writer.writeItem(file, timezone);
|
||||
writer.writeItem(file, timezoneOffsetHours);
|
||||
writer.writeItem(file, indexingDisplay);
|
||||
|
||||
return writer.item_count;
|
||||
}
|
||||
@@ -261,7 +270,24 @@ bool CrossPointSettings::loadFromFile() {
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
serialization::readPod(inputFile, embeddedStyle);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
readAndValidate(inputFile, sleepScreenLetterboxFill, SLEEP_SCREEN_LETTERBOX_FILL_COUNT);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
// New fields added at end for backward compatibility
|
||||
readAndValidate(inputFile, preferredPortrait, ORIENTATION_COUNT);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
readAndValidate(inputFile, preferredLandscape, ORIENTATION_COUNT);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
readAndValidate(inputFile, clockFormat, CLOCK_FORMAT_COUNT);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
readAndValidate(inputFile, clockSize, CLOCK_SIZE_COUNT);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
readAndValidate(inputFile, timezone, TZ_COUNT);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
serialization::readPod(inputFile, timezoneOffsetHours);
|
||||
if (timezoneOffsetHours < -12 || timezoneOffsetHours > 14) timezoneOffsetHours = 0;
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
readAndValidate(inputFile, indexingDisplay, INDEXING_DISPLAY_COUNT);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
} while (false);
|
||||
|
||||
if (frontButtonMappingRead) {
|
||||
@@ -277,8 +303,8 @@ bool CrossPointSettings::loadFromFile() {
|
||||
|
||||
float CrossPointSettings::getReaderLineCompression() const {
|
||||
switch (fontFamily) {
|
||||
#ifndef OMIT_BOOKERLY
|
||||
case BOOKERLY:
|
||||
default:
|
||||
switch (lineSpacing) {
|
||||
case TIGHT:
|
||||
return 0.95f;
|
||||
@@ -288,6 +314,8 @@ float CrossPointSettings::getReaderLineCompression() const {
|
||||
case WIDE:
|
||||
return 1.1f;
|
||||
}
|
||||
#endif // OMIT_BOOKERLY
|
||||
#ifndef OMIT_NOTOSANS
|
||||
case NOTOSANS:
|
||||
switch (lineSpacing) {
|
||||
case TIGHT:
|
||||
@@ -298,6 +326,8 @@ float CrossPointSettings::getReaderLineCompression() const {
|
||||
case WIDE:
|
||||
return 1.0f;
|
||||
}
|
||||
#endif // OMIT_NOTOSANS
|
||||
#ifndef OMIT_OPENDYSLEXIC
|
||||
case OPENDYSLEXIC:
|
||||
switch (lineSpacing) {
|
||||
case TIGHT:
|
||||
@@ -308,6 +338,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,8 +399,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;
|
||||
@@ -358,6 +412,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:
|
||||
@@ -370,6 +426,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:
|
||||
@@ -382,5 +440,45 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
const char* CrossPointSettings::getTimezonePosixStr() const {
|
||||
switch (timezone) {
|
||||
case TZ_EASTERN:
|
||||
return "EST5EDT,M3.2.0,M11.1.0";
|
||||
case TZ_CENTRAL:
|
||||
return "CST6CDT,M3.2.0,M11.1.0";
|
||||
case TZ_MOUNTAIN:
|
||||
return "MST7MDT,M3.2.0,M11.1.0";
|
||||
case TZ_PACIFIC:
|
||||
return "PST8PDT,M3.2.0,M11.1.0";
|
||||
case TZ_ALASKA:
|
||||
return "AKST9AKDT,M3.2.0,M11.1.0";
|
||||
case TZ_HAWAII:
|
||||
return "HST10";
|
||||
case TZ_CUSTOM: {
|
||||
// Build "UTC<offset>" string where offset sign is inverted per POSIX convention
|
||||
// POSIX TZ: positive = west of UTC, so we negate the user-facing offset
|
||||
static char buf[16];
|
||||
int posixOffset = -timezoneOffsetHours;
|
||||
snprintf(buf, sizeof(buf), "UTC%d", posixOffset);
|
||||
return buf;
|
||||
}
|
||||
case TZ_UTC:
|
||||
default:
|
||||
return "UTC0";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,12 @@ class CrossPointSettings {
|
||||
INVERTED_BLACK_AND_WHITE = 2,
|
||||
SLEEP_SCREEN_COVER_FILTER_COUNT
|
||||
};
|
||||
enum SLEEP_SCREEN_LETTERBOX_FILL {
|
||||
LETTERBOX_DITHERED = 0,
|
||||
LETTERBOX_SOLID = 1,
|
||||
LETTERBOX_NONE = 2,
|
||||
SLEEP_SCREEN_LETTERBOX_FILL_COUNT
|
||||
};
|
||||
|
||||
// Status bar display type enum
|
||||
enum STATUS_BAR_MODE {
|
||||
@@ -46,6 +52,13 @@ class CrossPointSettings {
|
||||
STATUS_BAR_MODE_COUNT
|
||||
};
|
||||
|
||||
enum INDEXING_DISPLAY {
|
||||
INDEXING_POPUP = 0,
|
||||
INDEXING_STATUS_TEXT = 1,
|
||||
INDEXING_STATUS_ICON = 2,
|
||||
INDEXING_DISPLAY_COUNT
|
||||
};
|
||||
|
||||
enum ORIENTATION {
|
||||
PORTRAIT = 0, // 480x800 logical coordinates (current default)
|
||||
LANDSCAPE_CW = 1, // 800x480 logical coordinates, rotated 180° (swap top/bottom)
|
||||
@@ -122,14 +135,37 @@ class CrossPointSettings {
|
||||
// UI Theme
|
||||
enum UI_THEME { CLASSIC = 0, LYRA = 1, LYRA_3_COVERS = 2 };
|
||||
|
||||
// Home screen clock format
|
||||
enum CLOCK_FORMAT { CLOCK_OFF = 0, CLOCK_AMPM = 1, CLOCK_24H = 2, CLOCK_FORMAT_COUNT };
|
||||
|
||||
// Clock size
|
||||
enum CLOCK_SIZE { CLOCK_SIZE_SMALL = 0, CLOCK_SIZE_MEDIUM = 1, CLOCK_SIZE_LARGE = 2, CLOCK_SIZE_COUNT };
|
||||
|
||||
// Timezone presets
|
||||
enum TIMEZONE {
|
||||
TZ_UTC = 0,
|
||||
TZ_EASTERN = 1,
|
||||
TZ_CENTRAL = 2,
|
||||
TZ_MOUNTAIN = 3,
|
||||
TZ_PACIFIC = 4,
|
||||
TZ_ALASKA = 5,
|
||||
TZ_HAWAII = 6,
|
||||
TZ_CUSTOM = 7,
|
||||
TZ_COUNT
|
||||
};
|
||||
|
||||
// Sleep screen settings
|
||||
uint8_t sleepScreen = DARK;
|
||||
// Sleep screen cover mode settings
|
||||
uint8_t sleepScreenCoverMode = FIT;
|
||||
// Sleep screen cover filter
|
||||
uint8_t sleepScreenCoverFilter = NO_FILTER;
|
||||
// Sleep screen letterbox fill mode (Dithered / Solid / None)
|
||||
uint8_t sleepScreenLetterboxFill = LETTERBOX_DITHERED;
|
||||
// Status bar settings
|
||||
uint8_t statusBar = FULL;
|
||||
// Indexing feedback display mode (popup, status bar text, status bar icon)
|
||||
uint8_t indexingDisplay = INDEXING_POPUP;
|
||||
// Text rendering settings
|
||||
uint8_t extraParagraphSpacing = 1;
|
||||
uint8_t textAntiAliasing = 1;
|
||||
@@ -175,6 +211,22 @@ class CrossPointSettings {
|
||||
// Use book's embedded CSS styles for EPUB rendering (1 = enabled, 0 = disabled)
|
||||
uint8_t embeddedStyle = 1;
|
||||
|
||||
// Preferred orientations for the portrait/landscape toggle in the reader menu.
|
||||
// preferredPortrait: PORTRAIT (0) or INVERTED (2)
|
||||
// preferredLandscape: LANDSCAPE_CW (1) or LANDSCAPE_CCW (3)
|
||||
uint8_t preferredPortrait = PORTRAIT;
|
||||
uint8_t preferredLandscape = LANDSCAPE_CW;
|
||||
|
||||
// Clock display format (OFF by default)
|
||||
uint8_t clockFormat = CLOCK_OFF;
|
||||
// Clock display size
|
||||
uint8_t clockSize = CLOCK_SIZE_SMALL;
|
||||
|
||||
// Timezone setting
|
||||
uint8_t timezone = TZ_UTC;
|
||||
// Custom timezone offset in hours from UTC (-12 to +14)
|
||||
int8_t timezoneOffsetHours = 0;
|
||||
|
||||
~CrossPointSettings() = default;
|
||||
|
||||
// Get singleton instance
|
||||
@@ -194,6 +246,7 @@ class CrossPointSettings {
|
||||
float getReaderLineCompression() const;
|
||||
unsigned long getSleepTimeoutMs() const;
|
||||
int getRefreshFrequency() const;
|
||||
const char* getTimezonePosixStr() const;
|
||||
};
|
||||
|
||||
// Helper macro to access settings
|
||||
|
||||
@@ -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 =
|
||||
@@ -76,7 +85,7 @@ bool RecentBooksStore::saveToFile() const {
|
||||
return true;
|
||||
}
|
||||
|
||||
RecentBook RecentBooksStore::getDataFromBook(std::string path) const {
|
||||
RecentBook RecentBooksStore::getDataFromBook(const std::string& path) const {
|
||||
std::string lastBookFileName = "";
|
||||
const size_t lastSlash = path.find_last_of('/');
|
||||
if (lastSlash != std::string::npos) {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -39,7 +42,7 @@ class RecentBooksStore {
|
||||
bool saveToFile() const;
|
||||
|
||||
bool loadFromFile();
|
||||
RecentBook getDataFromBook(std::string path) const;
|
||||
RecentBook getDataFromBook(const std::string& path) const;
|
||||
};
|
||||
|
||||
// Helper macro to access recent books store
|
||||
|
||||
@@ -8,10 +8,44 @@
|
||||
#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 StrId options from the compile-time mapping table
|
||||
constexpr StrId kFontFamilyStrIds[] = {
|
||||
#ifndef OMIT_BOOKERLY
|
||||
StrId::STR_BOOKERLY,
|
||||
#endif
|
||||
#ifndef OMIT_NOTOSANS
|
||||
StrId::STR_NOTO_SANS,
|
||||
#endif
|
||||
#ifndef OMIT_OPENDYSLEXIC
|
||||
StrId::STR_OPEN_DYSLEXIC,
|
||||
#endif
|
||||
};
|
||||
std::vector<StrId> fontFamilyStrIds(std::begin(kFontFamilyStrIds), std::end(kFontFamilyStrIds));
|
||||
|
||||
return {
|
||||
// --- Display ---
|
||||
SettingInfo::Enum(StrId::STR_SLEEP_SCREEN, &CrossPointSettings::sleepScreen,
|
||||
@@ -23,11 +57,17 @@ inline std::vector<SettingInfo> getSettingsList() {
|
||||
SettingInfo::Enum(StrId::STR_SLEEP_COVER_FILTER, &CrossPointSettings::sleepScreenCoverFilter,
|
||||
{StrId::STR_NONE_OPT, StrId::STR_FILTER_CONTRAST, StrId::STR_INVERTED},
|
||||
"sleepScreenCoverFilter", StrId::STR_CAT_DISPLAY),
|
||||
SettingInfo::Enum(StrId::STR_LETTERBOX_FILL, &CrossPointSettings::sleepScreenLetterboxFill,
|
||||
{StrId::STR_DITHERED, StrId::STR_SOLID, StrId::STR_NONE_OPT}, "sleepScreenLetterboxFill",
|
||||
StrId::STR_CAT_DISPLAY),
|
||||
SettingInfo::Enum(
|
||||
StrId::STR_STATUS_BAR, &CrossPointSettings::statusBar,
|
||||
{StrId::STR_NONE_OPT, StrId::STR_NO_PROGRESS, StrId::STR_STATUS_BAR_FULL_PERCENT,
|
||||
StrId::STR_STATUS_BAR_FULL_BOOK, StrId::STR_STATUS_BAR_BOOK_ONLY, StrId::STR_STATUS_BAR_FULL_CHAPTER},
|
||||
"statusBar", StrId::STR_CAT_DISPLAY),
|
||||
SettingInfo::Enum(StrId::STR_INDEXING_DISPLAY, &CrossPointSettings::indexingDisplay,
|
||||
{StrId::STR_INDEXING_POPUP, StrId::STR_INDEXING_STATUS_TEXT, StrId::STR_INDEXING_STATUS_ICON},
|
||||
"indexingDisplay", StrId::STR_CAT_DISPLAY),
|
||||
SettingInfo::Enum(StrId::STR_HIDE_BATTERY, &CrossPointSettings::hideBatteryPercentage,
|
||||
{StrId::STR_NEVER, StrId::STR_IN_READER, StrId::STR_ALWAYS}, "hideBatteryPercentage",
|
||||
StrId::STR_CAT_DISPLAY),
|
||||
@@ -40,11 +80,33 @@ inline std::vector<SettingInfo> getSettingsList() {
|
||||
StrId::STR_CAT_DISPLAY),
|
||||
SettingInfo::Toggle(StrId::STR_SUNLIGHT_FADING_FIX, &CrossPointSettings::fadingFix, "fadingFix",
|
||||
StrId::STR_CAT_DISPLAY),
|
||||
// --- Clock ---
|
||||
SettingInfo::Enum(StrId::STR_CLOCK, &CrossPointSettings::clockFormat,
|
||||
{StrId::STR_STATE_OFF, StrId::STR_CLOCK_AMPM, StrId::STR_CLOCK_24H}, "clockFormat",
|
||||
StrId::STR_CAT_CLOCK),
|
||||
SettingInfo::Enum(StrId::STR_CLOCK_SIZE, &CrossPointSettings::clockSize,
|
||||
{StrId::STR_CLOCK_SIZE_SMALL, StrId::STR_CLOCK_SIZE_MEDIUM, StrId::STR_CLOCK_SIZE_LARGE},
|
||||
"clockSize", StrId::STR_CAT_CLOCK),
|
||||
SettingInfo::Enum(StrId::STR_TIMEZONE, &CrossPointSettings::timezone,
|
||||
{StrId::STR_TZ_UTC, StrId::STR_TZ_EASTERN, StrId::STR_TZ_CENTRAL, StrId::STR_TZ_MOUNTAIN,
|
||||
StrId::STR_TZ_PACIFIC, StrId::STR_TZ_ALASKA, StrId::STR_TZ_HAWAII, StrId::STR_TZ_CUSTOM},
|
||||
"timezone", StrId::STR_CAT_CLOCK),
|
||||
|
||||
// --- Reader ---
|
||||
SettingInfo::Enum(StrId::STR_FONT_FAMILY, &CrossPointSettings::fontFamily,
|
||||
{StrId::STR_BOOKERLY, StrId::STR_NOTO_SANS, StrId::STR_OPEN_DYSLEXIC}, "fontFamily",
|
||||
StrId::STR_CAT_READER),
|
||||
SettingInfo::DynamicEnum(
|
||||
StrId::STR_FONT_FAMILY, std::move(fontFamilyStrIds),
|
||||
[]() -> 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", StrId::STR_CAT_READER),
|
||||
SettingInfo::Enum(StrId::STR_FONT_SIZE, &CrossPointSettings::fontSize,
|
||||
{StrId::STR_SMALL, StrId::STR_MEDIUM, StrId::STR_LARGE, StrId::STR_X_LARGE}, "fontSize",
|
||||
StrId::STR_CAT_READER),
|
||||
@@ -63,6 +125,21 @@ inline std::vector<SettingInfo> getSettingsList() {
|
||||
SettingInfo::Enum(StrId::STR_ORIENTATION, &CrossPointSettings::orientation,
|
||||
{StrId::STR_PORTRAIT, StrId::STR_LANDSCAPE_CW, StrId::STR_INVERTED, StrId::STR_LANDSCAPE_CCW},
|
||||
"orientation", StrId::STR_CAT_READER),
|
||||
SettingInfo::DynamicEnum(
|
||||
StrId::STR_PREFERRED_PORTRAIT, {StrId::STR_PORTRAIT, StrId::STR_INVERTED},
|
||||
[] { return static_cast<uint8_t>(SETTINGS.preferredPortrait == CrossPointSettings::INVERTED ? 1 : 0); },
|
||||
[](uint8_t idx) {
|
||||
SETTINGS.preferredPortrait = (idx == 1) ? CrossPointSettings::INVERTED : CrossPointSettings::PORTRAIT;
|
||||
},
|
||||
"preferredPortrait", StrId::STR_CAT_READER),
|
||||
SettingInfo::DynamicEnum(
|
||||
StrId::STR_PREFERRED_LANDSCAPE, {StrId::STR_LANDSCAPE_CW, StrId::STR_LANDSCAPE_CCW},
|
||||
[] { return static_cast<uint8_t>(SETTINGS.preferredLandscape == CrossPointSettings::LANDSCAPE_CCW ? 1 : 0); },
|
||||
[](uint8_t idx) {
|
||||
SETTINGS.preferredLandscape =
|
||||
(idx == 1) ? CrossPointSettings::LANDSCAPE_CCW : CrossPointSettings::LANDSCAPE_CW;
|
||||
},
|
||||
"preferredLandscape", StrId::STR_CAT_READER),
|
||||
SettingInfo::Toggle(StrId::STR_EXTRA_SPACING, &CrossPointSettings::extraParagraphSpacing, "extraParagraphSpacing",
|
||||
StrId::STR_CAT_READER),
|
||||
SettingInfo::Toggle(StrId::STR_TEXT_AA, &CrossPointSettings::textAntiAliasing, "textAntiAliasing",
|
||||
|
||||
@@ -11,7 +11,7 @@ void Activity::renderTaskLoop() {
|
||||
while (true) {
|
||||
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
|
||||
{
|
||||
HalPowerManager::Lock powerLock; // Ensure we don't go into low-power mode while rendering
|
||||
HalPowerManager::Lock powerLock;
|
||||
RenderLock lock(*this);
|
||||
render(std::move(lock));
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ void ActivityWithSubactivity::renderTaskLoop() {
|
||||
while (true) {
|
||||
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
|
||||
{
|
||||
HalPowerManager::Lock powerLock; // Ensure we don't go into low-power mode while rendering
|
||||
HalPowerManager::Lock powerLock;
|
||||
RenderLock lock(*this);
|
||||
if (!subActivity) {
|
||||
render(std::move(lock));
|
||||
|
||||
@@ -18,4 +18,6 @@ class ActivityWithSubactivity : public Activity {
|
||||
// the subactivity should request its own renders. This pauses parent rendering until exit.
|
||||
void requestUpdate() override;
|
||||
void onExit() override;
|
||||
bool preventAutoSleep() override { return subActivity && subActivity->preventAutoSleep(); }
|
||||
bool skipLoopDelay() override { return subActivity && subActivity->skipLoopDelay(); }
|
||||
};
|
||||
|
||||
@@ -4,16 +4,357 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
#include <Logging.h>
|
||||
#include <PlaceholderCoverGenerator.h>
|
||||
#include <Serialization.h>
|
||||
#include <Txt.h>
|
||||
#include <Xtc.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "images/Logo120.h"
|
||||
#include "util/BookSettings.h"
|
||||
#include "util/StringUtils.h"
|
||||
|
||||
namespace {
|
||||
|
||||
// Number of source pixels along the image edge to average for the dominant color
|
||||
constexpr int EDGE_SAMPLE_DEPTH = 20;
|
||||
|
||||
// Map a 2-bit quantized pixel value to an 8-bit grayscale value
|
||||
constexpr uint8_t val2bitToGray(uint8_t val2bit) { return val2bit * 85; }
|
||||
|
||||
// Letterbox fill data: one average gray value per edge (top/bottom or left/right).
|
||||
struct LetterboxFillData {
|
||||
uint8_t avgA = 128; // average gray of edge A (top or left)
|
||||
uint8_t avgB = 128; // average gray of edge B (bottom or right)
|
||||
int letterboxA = 0; // pixel size of the first letterbox area (top or left)
|
||||
int letterboxB = 0; // pixel size of the second letterbox area (bottom or right)
|
||||
bool horizontal = false; // true = top/bottom letterbox, false = left/right
|
||||
bool valid = false;
|
||||
};
|
||||
|
||||
// Snap an 8-bit gray value to the nearest of the 4 e-ink levels: 0, 85, 170, 255.
|
||||
uint8_t snapToEinkLevel(uint8_t gray) {
|
||||
// Thresholds at midpoints: 42, 127, 212
|
||||
if (gray < 43) return 0;
|
||||
if (gray < 128) return 85;
|
||||
if (gray < 213) return 170;
|
||||
return 255;
|
||||
}
|
||||
|
||||
// 4x4 Bayer ordered dithering matrix, values 0-255.
|
||||
// Produces a structured halftone pattern for 4-level quantization.
|
||||
// clang-format off
|
||||
constexpr uint8_t BAYER_4X4[4][4] = {
|
||||
{ 0, 128, 32, 160},
|
||||
{192, 64, 224, 96},
|
||||
{ 48, 176, 16, 144},
|
||||
{240, 112, 208, 80}
|
||||
};
|
||||
// clang-format on
|
||||
|
||||
// Ordered (Bayer) dithering for 4-level e-ink display.
|
||||
// Maps an 8-bit gray value to a 2-bit level (0-3) using the Bayer matrix
|
||||
// to produce a structured, repeating halftone pattern.
|
||||
uint8_t quantizeBayerDither(int gray, int x, int y) {
|
||||
const int threshold = BAYER_4X4[y & 3][x & 3];
|
||||
const int scaled = gray * 3;
|
||||
if (scaled < 255) {
|
||||
return (scaled + threshold >= 255) ? 1 : 0;
|
||||
} else if (scaled < 510) {
|
||||
return ((scaled - 255) + threshold >= 255) ? 2 : 1;
|
||||
} else {
|
||||
return ((scaled - 510) + threshold >= 255) ? 3 : 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Check whether a gray value would produce a dithered mix that crosses the
|
||||
// level-2 / level-3 boundary. This is the ONLY boundary where some dithered
|
||||
// pixels are BLACK (level ≤ 2) and others are WHITE (level 3) in the BW pass,
|
||||
// creating a high-frequency checkerboard that causes e-ink display crosstalk
|
||||
// and washes out adjacent content during HALF_REFRESH.
|
||||
// Gray values 171-254 produce a level-2/level-3 mix via Bayer dithering.
|
||||
bool bayerCrossesBwBoundary(uint8_t gray) { return gray > 170 && gray < 255; }
|
||||
|
||||
// Hash-based block dithering for BW-boundary gray values (171-254).
|
||||
// Each blockSize×blockSize pixel block gets a single uniform level (2 or 3),
|
||||
// determined by a deterministic spatial hash. The proportion of level-3 blocks
|
||||
// approximates the target gray. Unlike Bayer, the pattern is irregular
|
||||
// (noise-like), making it much less visually obvious at the same block size.
|
||||
// The hash is purely spatial (depends only on x, y, blockSize) so it produces
|
||||
// identical levels across BW, LSB, and MSB render passes.
|
||||
static constexpr int BW_DITHER_BLOCK = 2;
|
||||
|
||||
uint8_t hashBlockDither(uint8_t avg, int x, int y) {
|
||||
const int bx = x / BW_DITHER_BLOCK;
|
||||
const int by = y / BW_DITHER_BLOCK;
|
||||
// Fast mixing hash (splitmix32-inspired)
|
||||
uint32_t h = (uint32_t)bx * 2654435761u ^ (uint32_t)by * 2246822519u;
|
||||
h ^= h >> 16;
|
||||
h *= 0x45d9f3bu;
|
||||
h ^= h >> 16;
|
||||
// Proportion of level-3 blocks needed to approximate the target gray
|
||||
const float ratio = (avg - 170.0f) / 85.0f;
|
||||
const uint32_t threshold = (uint32_t)(ratio * 4294967295.0f);
|
||||
return (h < threshold) ? 3 : 2;
|
||||
}
|
||||
|
||||
// --- Edge average cache ---
|
||||
// Caches the computed edge averages alongside the cover BMP so we don't rescan on every sleep.
|
||||
constexpr uint8_t EDGE_CACHE_VERSION = 2;
|
||||
|
||||
bool loadEdgeCache(const std::string& path, int screenWidth, int screenHeight, LetterboxFillData& data) {
|
||||
FsFile file;
|
||||
if (!Storage.openFileForRead("SLP", path, file)) return false;
|
||||
|
||||
uint8_t version;
|
||||
serialization::readPod(file, version);
|
||||
if (version != EDGE_CACHE_VERSION) {
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
uint16_t cachedW, cachedH;
|
||||
serialization::readPod(file, cachedW);
|
||||
serialization::readPod(file, cachedH);
|
||||
if (cachedW != static_cast<uint16_t>(screenWidth) || cachedH != static_cast<uint16_t>(screenHeight)) {
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t horizontal;
|
||||
serialization::readPod(file, horizontal);
|
||||
data.horizontal = (horizontal != 0);
|
||||
|
||||
serialization::readPod(file, data.avgA);
|
||||
serialization::readPod(file, data.avgB);
|
||||
|
||||
int16_t lbA, lbB;
|
||||
serialization::readPod(file, lbA);
|
||||
serialization::readPod(file, lbB);
|
||||
data.letterboxA = lbA;
|
||||
data.letterboxB = lbB;
|
||||
|
||||
file.close();
|
||||
data.valid = true;
|
||||
LOG_DBG("SLP", "Loaded edge cache from %s (avgA=%d, avgB=%d)", path.c_str(), data.avgA, data.avgB);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool saveEdgeCache(const std::string& path, int screenWidth, int screenHeight, const LetterboxFillData& data) {
|
||||
if (!data.valid) return false;
|
||||
|
||||
FsFile file;
|
||||
if (!Storage.openFileForWrite("SLP", path, file)) return false;
|
||||
|
||||
serialization::writePod(file, EDGE_CACHE_VERSION);
|
||||
serialization::writePod(file, static_cast<uint16_t>(screenWidth));
|
||||
serialization::writePod(file, static_cast<uint16_t>(screenHeight));
|
||||
serialization::writePod(file, static_cast<uint8_t>(data.horizontal ? 1 : 0));
|
||||
serialization::writePod(file, data.avgA);
|
||||
serialization::writePod(file, data.avgB);
|
||||
serialization::writePod(file, static_cast<int16_t>(data.letterboxA));
|
||||
serialization::writePod(file, static_cast<int16_t>(data.letterboxB));
|
||||
file.close();
|
||||
|
||||
LOG_DBG("SLP", "Saved edge cache to %s", path.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
// Read the bitmap once to compute a single average gray value for the top/bottom or left/right edges.
|
||||
// Only computes running sums -- no per-pixel arrays, no malloc beyond row buffers.
|
||||
// After sampling the bitmap is rewound via rewindToData().
|
||||
LetterboxFillData computeEdgeAverages(const Bitmap& bitmap, int imgX, int imgY, int pageWidth, int pageHeight,
|
||||
float scale, float cropX, float cropY) {
|
||||
LetterboxFillData data;
|
||||
|
||||
const int cropPixX = static_cast<int>(std::floor(bitmap.getWidth() * cropX / 2.0f));
|
||||
const int cropPixY = static_cast<int>(std::floor(bitmap.getHeight() * cropY / 2.0f));
|
||||
const int visibleWidth = bitmap.getWidth() - 2 * cropPixX;
|
||||
const int visibleHeight = bitmap.getHeight() - 2 * cropPixY;
|
||||
|
||||
if (visibleWidth <= 0 || visibleHeight <= 0) return data;
|
||||
|
||||
const int outputRowSize = (bitmap.getWidth() + 3) / 4;
|
||||
auto* outputRow = static_cast<uint8_t*>(malloc(outputRowSize));
|
||||
auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes()));
|
||||
if (!outputRow || !rowBytes) {
|
||||
free(outputRow);
|
||||
free(rowBytes);
|
||||
return data;
|
||||
}
|
||||
|
||||
if (imgY > 0) {
|
||||
// Top/bottom letterboxing -- compute overall average of first/last EDGE_SAMPLE_DEPTH rows
|
||||
data.horizontal = true;
|
||||
const int scaledHeight = static_cast<int>(std::round(static_cast<float>(visibleHeight) * scale));
|
||||
data.letterboxA = imgY;
|
||||
data.letterboxB = pageHeight - imgY - scaledHeight;
|
||||
if (data.letterboxB < 0) data.letterboxB = 0;
|
||||
|
||||
const int sampleRows = std::min(EDGE_SAMPLE_DEPTH, visibleHeight);
|
||||
uint64_t sumTop = 0, sumBot = 0;
|
||||
int countTop = 0, countBot = 0;
|
||||
|
||||
for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) {
|
||||
if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) break;
|
||||
const int logicalY = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY;
|
||||
if (logicalY < cropPixY || logicalY >= bitmap.getHeight() - cropPixY) continue;
|
||||
const int outY = logicalY - cropPixY;
|
||||
|
||||
const bool inTop = (outY < sampleRows);
|
||||
const bool inBot = (outY >= visibleHeight - sampleRows);
|
||||
if (!inTop && !inBot) continue;
|
||||
|
||||
for (int bmpX = cropPixX; bmpX < bitmap.getWidth() - cropPixX; bmpX++) {
|
||||
const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3;
|
||||
const uint8_t gray = val2bitToGray(val);
|
||||
if (inTop) {
|
||||
sumTop += gray;
|
||||
countTop++;
|
||||
}
|
||||
if (inBot) {
|
||||
sumBot += gray;
|
||||
countBot++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data.avgA = countTop > 0 ? static_cast<uint8_t>(sumTop / countTop) : 128;
|
||||
data.avgB = countBot > 0 ? static_cast<uint8_t>(sumBot / countBot) : 128;
|
||||
data.valid = true;
|
||||
} else if (imgX > 0) {
|
||||
// Left/right letterboxing -- compute overall average of first/last EDGE_SAMPLE_DEPTH columns
|
||||
data.horizontal = false;
|
||||
const int scaledWidth = static_cast<int>(std::round(static_cast<float>(visibleWidth) * scale));
|
||||
data.letterboxA = imgX;
|
||||
data.letterboxB = pageWidth - imgX - scaledWidth;
|
||||
if (data.letterboxB < 0) data.letterboxB = 0;
|
||||
|
||||
const int sampleCols = std::min(EDGE_SAMPLE_DEPTH, visibleWidth);
|
||||
uint64_t sumLeft = 0, sumRight = 0;
|
||||
int countLeft = 0, countRight = 0;
|
||||
|
||||
for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) {
|
||||
if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) break;
|
||||
const int logicalY = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY;
|
||||
if (logicalY < cropPixY || logicalY >= bitmap.getHeight() - cropPixY) continue;
|
||||
|
||||
for (int bmpX = cropPixX; bmpX < cropPixX + sampleCols; bmpX++) {
|
||||
const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3;
|
||||
sumLeft += val2bitToGray(val);
|
||||
countLeft++;
|
||||
}
|
||||
for (int bmpX = bitmap.getWidth() - cropPixX - sampleCols; bmpX < bitmap.getWidth() - cropPixX; bmpX++) {
|
||||
const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3;
|
||||
sumRight += val2bitToGray(val);
|
||||
countRight++;
|
||||
}
|
||||
}
|
||||
|
||||
data.avgA = countLeft > 0 ? static_cast<uint8_t>(sumLeft / countLeft) : 128;
|
||||
data.avgB = countRight > 0 ? static_cast<uint8_t>(sumRight / countRight) : 128;
|
||||
data.valid = true;
|
||||
}
|
||||
|
||||
bitmap.rewindToData();
|
||||
free(outputRow);
|
||||
free(rowBytes);
|
||||
return data;
|
||||
}
|
||||
|
||||
// Draw letterbox fill in the areas around the cover image.
|
||||
// DITHERED: fills with the edge average using Bayer ordered dithering to approximate the color.
|
||||
// SOLID: snaps edge average to nearest e-ink level (0/85/170/255) for a clean uniform fill.
|
||||
// Must be called once per render pass (BW, GRAYSCALE_LSB, GRAYSCALE_MSB).
|
||||
void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uint8_t fillMode) {
|
||||
if (!data.valid) return;
|
||||
|
||||
const bool isSolid = (fillMode == CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_SOLID);
|
||||
|
||||
// For DITHERED mode with gray values in 171-254 (the level-2/level-3 BW boundary):
|
||||
// Pixel-level Bayer dithering creates a regular high-frequency checkerboard in
|
||||
// the BW pass that causes e-ink display crosstalk during HALF_REFRESH.
|
||||
//
|
||||
// Solution: HASH-BASED BLOCK DITHERING. Each 2x2 pixel block gets a single
|
||||
// level (2 or 3) determined by a spatial hash, with the proportion of level-3
|
||||
// blocks tuned to approximate the target gray. The 2px minimum run avoids BW
|
||||
// crosstalk, and the irregular hash pattern is much less visible than a regular
|
||||
// Bayer grid at the same block size.
|
||||
const bool hashA = !isSolid && bayerCrossesBwBoundary(data.avgA);
|
||||
const bool hashB = !isSolid && bayerCrossesBwBoundary(data.avgB);
|
||||
|
||||
// For solid mode: snap to nearest e-ink level
|
||||
const uint8_t levelA = isSolid ? snapToEinkLevel(data.avgA) / 85 : 0;
|
||||
const uint8_t levelB = isSolid ? snapToEinkLevel(data.avgB) / 85 : 0;
|
||||
|
||||
if (data.horizontal) {
|
||||
if (data.letterboxA > 0) {
|
||||
for (int y = 0; y < data.letterboxA; y++)
|
||||
for (int x = 0; x < renderer.getScreenWidth(); x++) {
|
||||
uint8_t lv;
|
||||
if (isSolid)
|
||||
lv = levelA;
|
||||
else if (hashA)
|
||||
lv = hashBlockDither(data.avgA, x, y);
|
||||
else
|
||||
lv = quantizeBayerDither(data.avgA, x, y);
|
||||
renderer.drawPixelGray(x, y, lv);
|
||||
}
|
||||
}
|
||||
if (data.letterboxB > 0) {
|
||||
const int start = renderer.getScreenHeight() - data.letterboxB;
|
||||
for (int y = start; y < renderer.getScreenHeight(); y++)
|
||||
for (int x = 0; x < renderer.getScreenWidth(); x++) {
|
||||
uint8_t lv;
|
||||
if (isSolid)
|
||||
lv = levelB;
|
||||
else if (hashB)
|
||||
lv = hashBlockDither(data.avgB, x, y);
|
||||
else
|
||||
lv = quantizeBayerDither(data.avgB, x, y);
|
||||
renderer.drawPixelGray(x, y, lv);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (data.letterboxA > 0) {
|
||||
for (int x = 0; x < data.letterboxA; x++)
|
||||
for (int y = 0; y < renderer.getScreenHeight(); y++) {
|
||||
uint8_t lv;
|
||||
if (isSolid)
|
||||
lv = levelA;
|
||||
else if (hashA)
|
||||
lv = hashBlockDither(data.avgA, x, y);
|
||||
else
|
||||
lv = quantizeBayerDither(data.avgA, x, y);
|
||||
renderer.drawPixelGray(x, y, lv);
|
||||
}
|
||||
}
|
||||
if (data.letterboxB > 0) {
|
||||
const int start = renderer.getScreenWidth() - data.letterboxB;
|
||||
for (int x = start; x < renderer.getScreenWidth(); x++)
|
||||
for (int y = 0; y < renderer.getScreenHeight(); y++) {
|
||||
uint8_t lv;
|
||||
if (isSolid)
|
||||
lv = levelB;
|
||||
else if (hashB)
|
||||
lv = hashBlockDither(data.avgB, x, y);
|
||||
else
|
||||
lv = quantizeBayerDither(data.avgB, x, y);
|
||||
renderer.drawPixelGray(x, y, lv);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void SleepActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
GUI.drawPopup(renderer, tr(STR_ENTERING_SLEEP));
|
||||
@@ -122,52 +463,91 @@ void SleepActivity::renderDefaultSleepScreen() const {
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
}
|
||||
|
||||
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
|
||||
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& edgeCachePath,
|
||||
uint8_t fillModeOverride) const {
|
||||
int x, y;
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
float cropX = 0, cropY = 0;
|
||||
|
||||
LOG_DBG("SLP", "bitmap %d x %d, screen %d x %d", bitmap.getWidth(), bitmap.getHeight(), pageWidth, pageHeight);
|
||||
if (bitmap.getWidth() > pageWidth || bitmap.getHeight() > pageHeight) {
|
||||
// image will scale, make sure placement is right
|
||||
float ratio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
|
||||
const float screenRatio = static_cast<float>(pageWidth) / static_cast<float>(pageHeight);
|
||||
|
||||
LOG_DBG("SLP", "bitmap ratio: %f, screen ratio: %f", ratio, screenRatio);
|
||||
if (ratio > screenRatio) {
|
||||
// image wider than viewport ratio, scaled down image needs to be centered vertically
|
||||
if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
|
||||
cropX = 1.0f - (screenRatio / ratio);
|
||||
LOG_DBG("SLP", "Cropping bitmap x: %f", cropX);
|
||||
ratio = (1.0f - cropX) * static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
|
||||
}
|
||||
x = 0;
|
||||
y = std::round((static_cast<float>(pageHeight) - static_cast<float>(pageWidth) / ratio) / 2);
|
||||
LOG_DBG("SLP", "Centering with ratio %f to y=%d", ratio, y);
|
||||
} else {
|
||||
// image taller than viewport ratio, scaled down image needs to be centered horizontally
|
||||
if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
|
||||
cropY = 1.0f - (ratio / screenRatio);
|
||||
LOG_DBG("SLP", "Cropping bitmap y: %f", cropY);
|
||||
ratio = static_cast<float>(bitmap.getWidth()) / ((1.0f - cropY) * static_cast<float>(bitmap.getHeight()));
|
||||
}
|
||||
x = std::round((static_cast<float>(pageWidth) - static_cast<float>(pageHeight) * ratio) / 2);
|
||||
y = 0;
|
||||
LOG_DBG("SLP", "Centering with ratio %f to x=%d", ratio, x);
|
||||
// Always compute aspect-ratio-preserving scale and position (supports both larger and smaller images)
|
||||
float ratio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
|
||||
const float screenRatio = static_cast<float>(pageWidth) / static_cast<float>(pageHeight);
|
||||
|
||||
LOG_DBG("SLP", "bitmap ratio: %f, screen ratio: %f", ratio, screenRatio);
|
||||
if (ratio > screenRatio) {
|
||||
// image wider than viewport ratio, needs to be centered vertically
|
||||
if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
|
||||
cropX = 1.0f - (screenRatio / ratio);
|
||||
LOG_DBG("SLP", "Cropping bitmap x: %f", cropX);
|
||||
ratio = (1.0f - cropX) * static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
|
||||
}
|
||||
x = 0;
|
||||
y = std::round((static_cast<float>(pageHeight) - static_cast<float>(pageWidth) / ratio) / 2);
|
||||
LOG_DBG("SLP", "Centering with ratio %f to y=%d", ratio, y);
|
||||
} else {
|
||||
// center the image
|
||||
x = (pageWidth - bitmap.getWidth()) / 2;
|
||||
y = (pageHeight - bitmap.getHeight()) / 2;
|
||||
// image taller than or equal to viewport ratio, needs to be centered horizontally
|
||||
if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
|
||||
cropY = 1.0f - (ratio / screenRatio);
|
||||
LOG_DBG("SLP", "Cropping bitmap y: %f", cropY);
|
||||
ratio = static_cast<float>(bitmap.getWidth()) / ((1.0f - cropY) * static_cast<float>(bitmap.getHeight()));
|
||||
}
|
||||
x = std::round((static_cast<float>(pageWidth) - static_cast<float>(pageHeight) * ratio) / 2);
|
||||
y = 0;
|
||||
LOG_DBG("SLP", "Centering with ratio %f to x=%d", ratio, x);
|
||||
}
|
||||
|
||||
LOG_DBG("SLP", "drawing to %d x %d", x, y);
|
||||
|
||||
// Compute the scale factor (same formula as drawBitmap) so we can map screen coords to source coords
|
||||
const float effectiveWidth = (1.0f - cropX) * bitmap.getWidth();
|
||||
const float effectiveHeight = (1.0f - cropY) * bitmap.getHeight();
|
||||
const float scale =
|
||||
std::min(static_cast<float>(pageWidth) / effectiveWidth, static_cast<float>(pageHeight) / effectiveHeight);
|
||||
|
||||
// Determine letterbox fill settings (per-book override takes precedence)
|
||||
const uint8_t fillMode = (fillModeOverride != BookSettings::USE_GLOBAL &&
|
||||
fillModeOverride < CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL_COUNT)
|
||||
? fillModeOverride
|
||||
: SETTINGS.sleepScreenLetterboxFill;
|
||||
const bool wantFill = (fillMode != CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_NONE);
|
||||
|
||||
static const char* fillModeNames[] = {"dithered", "solid", "none"};
|
||||
const char* fillModeName = (fillMode < 3) ? fillModeNames[fillMode] : "unknown";
|
||||
|
||||
// Compute edge averages if letterbox fill is requested (try cache first)
|
||||
LetterboxFillData fillData;
|
||||
const bool hasLetterbox = (x > 0 || y > 0);
|
||||
if (hasLetterbox && wantFill) {
|
||||
bool cacheLoaded = false;
|
||||
if (!edgeCachePath.empty()) {
|
||||
cacheLoaded = loadEdgeCache(edgeCachePath, pageWidth, pageHeight, fillData);
|
||||
}
|
||||
if (!cacheLoaded) {
|
||||
LOG_DBG("SLP", "Letterbox detected (x=%d, y=%d), computing edge averages for %s fill", x, y, fillModeName);
|
||||
fillData = computeEdgeAverages(bitmap, x, y, pageWidth, pageHeight, scale, cropX, cropY);
|
||||
if (fillData.valid && !edgeCachePath.empty()) {
|
||||
saveEdgeCache(edgeCachePath, pageWidth, pageHeight, fillData);
|
||||
}
|
||||
}
|
||||
if (fillData.valid) {
|
||||
LOG_DBG("SLP", "Letterbox fill: %s, horizontal=%d, avgA=%d, avgB=%d, letterboxA=%d, letterboxB=%d", fillModeName,
|
||||
fillData.horizontal, fillData.avgA, fillData.avgB, fillData.letterboxA, fillData.letterboxB);
|
||||
}
|
||||
}
|
||||
|
||||
renderer.clearScreen();
|
||||
|
||||
const bool hasGreyscale = bitmap.hasGreyscale() &&
|
||||
SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER;
|
||||
|
||||
// Draw letterbox fill (BW pass)
|
||||
if (fillData.valid) {
|
||||
drawLetterboxFill(renderer, fillData, fillMode);
|
||||
}
|
||||
|
||||
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
||||
|
||||
if (SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE) {
|
||||
@@ -180,12 +560,18 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
|
||||
bitmap.rewindToData();
|
||||
renderer.clearScreen(0x00);
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
|
||||
if (fillData.valid) {
|
||||
drawLetterboxFill(renderer, fillData, fillMode);
|
||||
}
|
||||
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
||||
renderer.copyGrayscaleLsbBuffers();
|
||||
|
||||
bitmap.rewindToData();
|
||||
renderer.clearScreen(0x00);
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
|
||||
if (fillData.valid) {
|
||||
drawLetterboxFill(renderer, fillData, fillMode);
|
||||
}
|
||||
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
||||
renderer.copyGrayscaleMsbBuffers();
|
||||
|
||||
@@ -210,6 +596,7 @@ void SleepActivity::renderCoverSleepScreen() const {
|
||||
}
|
||||
|
||||
std::string coverBmpPath;
|
||||
std::string bookCachePath;
|
||||
bool cropped = SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP;
|
||||
|
||||
// Check if the current book is XTC, TXT, or EPUB
|
||||
@@ -223,11 +610,17 @@ 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)();
|
||||
}
|
||||
|
||||
coverBmpPath = lastXtc.getCoverBmpPath();
|
||||
bookCachePath = lastXtc.getCachePath();
|
||||
} else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".txt")) {
|
||||
// Handle TXT file - looks for cover image in the same folder
|
||||
Txt lastTxt(APP_STATE.openEpubPath, "/.crosspoint");
|
||||
@@ -237,11 +630,17 @@ 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)();
|
||||
}
|
||||
|
||||
coverBmpPath = lastTxt.getCoverBmpPath();
|
||||
bookCachePath = lastTxt.getCachePath();
|
||||
} else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".epub")) {
|
||||
// Handle EPUB file
|
||||
Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint");
|
||||
@@ -252,21 +651,44 @@ 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)();
|
||||
}
|
||||
|
||||
coverBmpPath = lastEpub.getCoverBmpPath(cropped);
|
||||
bookCachePath = lastEpub.getCachePath();
|
||||
} else {
|
||||
return (this->*renderNoCoverSleepScreen)();
|
||||
}
|
||||
|
||||
// Load per-book letterbox fill override (falls back to global if not set)
|
||||
uint8_t fillModeOverride = BookSettings::USE_GLOBAL;
|
||||
if (!bookCachePath.empty()) {
|
||||
auto bookSettings = BookSettings::load(bookCachePath);
|
||||
fillModeOverride = bookSettings.letterboxFillOverride;
|
||||
}
|
||||
|
||||
FsFile file;
|
||||
if (Storage.openFileForRead("SLP", coverBmpPath, file)) {
|
||||
Bitmap bitmap(file);
|
||||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||||
LOG_DBG("SLP", "Rendering sleep cover: %s", coverBmpPath.c_str());
|
||||
renderBitmapSleepScreen(bitmap);
|
||||
// Derive edge cache path from cover BMP path (e.g. cover.bmp -> cover_edges.bin)
|
||||
std::string edgeCachePath;
|
||||
const auto dotPos = coverBmpPath.rfind(".bmp");
|
||||
if (dotPos != std::string::npos) {
|
||||
edgeCachePath = coverBmpPath.substr(0, dotPos) + "_edges.bin";
|
||||
}
|
||||
renderBitmapSleepScreen(bitmap, edgeCachePath, fillModeOverride);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "../Activity.h"
|
||||
|
||||
class Bitmap;
|
||||
@@ -13,6 +16,8 @@ class SleepActivity final : public Activity {
|
||||
void renderDefaultSleepScreen() const;
|
||||
void renderCustomSleepScreen() const;
|
||||
void renderCoverSleepScreen() const;
|
||||
void renderBitmapSleepScreen(const Bitmap& bitmap) const;
|
||||
// fillModeOverride: 0xFF = use global setting, otherwise a SLEEP_SCREEN_LETTERBOX_FILL value.
|
||||
void renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& edgeCachePath = "",
|
||||
uint8_t fillModeOverride = 0xFF) const;
|
||||
void renderBlankSleepScreen() const;
|
||||
};
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
#include <PlaceholderCoverGenerator.h>
|
||||
#include <Utf8.h>
|
||||
#include <Xtc.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
@@ -60,46 +62,59 @@ 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, tr(STR_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, tr(STR_LOADING_POPUP));
|
||||
if (!epub.load(false, true)) {
|
||||
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;
|
||||
requestUpdate();
|
||||
} 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, tr(STR_LOADING_POPUP));
|
||||
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;
|
||||
requestUpdate();
|
||||
}
|
||||
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;
|
||||
requestUpdate();
|
||||
}
|
||||
}
|
||||
progress++;
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include "activities/util/KeyboardEntryActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/TimeSync.h"
|
||||
|
||||
void WifiSelectionActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
@@ -241,6 +242,9 @@ void WifiSelectionActivity::checkConnectionStatus() {
|
||||
connectedIP = ipStr;
|
||||
autoConnecting = false;
|
||||
|
||||
// Start NTP time sync in the background (non-blocking)
|
||||
TimeSync::startNtpSync();
|
||||
|
||||
// Save this as the last connected network - SD card operations need lock as
|
||||
// we use SPI for both
|
||||
{
|
||||
|
||||
517
src/activities/reader/DictionaryDefinitionActivity.cpp
Normal file
517
src/activities/reader/DictionaryDefinitionActivity.cpp
Normal file
@@ -0,0 +1,517 @@
|
||||
#include "DictionaryDefinitionActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdlib>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
void DictionaryDefinitionActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
wrapText();
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void DictionaryDefinitionActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check if a Unicode codepoint is likely renderable by the e-ink bitmap font.
|
||||
// Keeps Latin text, combining marks, common punctuation, currency, and letterlike symbols.
|
||||
// Skips IPA extensions, Greek, Cyrillic, Arabic, CJK, and other non-Latin scripts.
|
||||
// ---------------------------------------------------------------------------
|
||||
bool DictionaryDefinitionActivity::isRenderableCodepoint(uint32_t cp) {
|
||||
if (cp <= 0x024F) return true; // Basic Latin + Latin Extended-A/B
|
||||
if (cp >= 0x0300 && cp <= 0x036F) return true; // Combining Diacritical Marks
|
||||
if (cp >= 0x2000 && cp <= 0x206F) return true; // General Punctuation
|
||||
if (cp >= 0x20A0 && cp <= 0x20CF) return true; // Currency Symbols
|
||||
if (cp >= 0x2100 && cp <= 0x214F) return true; // Letterlike Symbols
|
||||
if (cp >= 0x2190 && cp <= 0x21FF) return true; // Arrows
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML entity decoder
|
||||
// ---------------------------------------------------------------------------
|
||||
std::string DictionaryDefinitionActivity::decodeEntity(const std::string& entity) {
|
||||
// Named entities
|
||||
if (entity == "amp") return "&";
|
||||
if (entity == "lt") return "<";
|
||||
if (entity == "gt") return ">";
|
||||
if (entity == "quot") return "\"";
|
||||
if (entity == "apos") return "'";
|
||||
if (entity == "nbsp" || entity == "thinsp" || entity == "ensp" || entity == "emsp") return " ";
|
||||
if (entity == "ndash") return "\xE2\x80\x93"; // U+2013
|
||||
if (entity == "mdash") return "\xE2\x80\x94"; // U+2014
|
||||
if (entity == "lsquo") return "\xE2\x80\x98";
|
||||
if (entity == "rsquo") return "\xE2\x80\x99";
|
||||
if (entity == "ldquo") return "\xE2\x80\x9C";
|
||||
if (entity == "rdquo") return "\xE2\x80\x9D";
|
||||
if (entity == "hellip") return "\xE2\x80\xA6";
|
||||
if (entity == "lrm" || entity == "rlm" || entity == "zwj" || entity == "zwnj") return "";
|
||||
|
||||
// Numeric entities: { or 
|
||||
if (!entity.empty() && entity[0] == '#') {
|
||||
unsigned long cp = 0;
|
||||
if (entity.size() > 1 && (entity[1] == 'x' || entity[1] == 'X')) {
|
||||
cp = std::strtoul(entity.c_str() + 2, nullptr, 16);
|
||||
} else {
|
||||
cp = std::strtoul(entity.c_str() + 1, nullptr, 10);
|
||||
}
|
||||
if (cp > 0 && cp < 0x80) {
|
||||
return std::string(1, static_cast<char>(cp));
|
||||
}
|
||||
if (cp >= 0x80 && cp < 0x800) {
|
||||
char buf[3] = {static_cast<char>(0xC0 | (cp >> 6)), static_cast<char>(0x80 | (cp & 0x3F)), '\0'};
|
||||
return std::string(buf, 2);
|
||||
}
|
||||
if (cp >= 0x800 && cp < 0x10000) {
|
||||
char buf[4] = {static_cast<char>(0xE0 | (cp >> 12)), static_cast<char>(0x80 | ((cp >> 6) & 0x3F)),
|
||||
static_cast<char>(0x80 | (cp & 0x3F)), '\0'};
|
||||
return std::string(buf, 3);
|
||||
}
|
||||
if (cp >= 0x10000 && cp < 0x110000) {
|
||||
char buf[5] = {static_cast<char>(0xF0 | (cp >> 18)), static_cast<char>(0x80 | ((cp >> 12) & 0x3F)),
|
||||
static_cast<char>(0x80 | ((cp >> 6) & 0x3F)), static_cast<char>(0x80 | (cp & 0x3F)), '\0'};
|
||||
return std::string(buf, 4);
|
||||
}
|
||||
}
|
||||
|
||||
return ""; // unknown entity — drop it
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML → TextAtom list
|
||||
// ---------------------------------------------------------------------------
|
||||
std::vector<DictionaryDefinitionActivity::TextAtom> DictionaryDefinitionActivity::parseHtml(const std::string& html) {
|
||||
std::vector<TextAtom> atoms;
|
||||
|
||||
bool isBold = false;
|
||||
bool isItalic = false;
|
||||
bool inSvg = false;
|
||||
int svgDepth = 0;
|
||||
std::vector<ListState> listStack;
|
||||
std::string currentWord;
|
||||
|
||||
auto currentStyle = [&]() -> EpdFontFamily::Style {
|
||||
if (isBold && isItalic) return EpdFontFamily::BOLD_ITALIC;
|
||||
if (isBold) return EpdFontFamily::BOLD;
|
||||
if (isItalic) return EpdFontFamily::ITALIC;
|
||||
return EpdFontFamily::REGULAR;
|
||||
};
|
||||
|
||||
auto flushWord = [&]() {
|
||||
if (!currentWord.empty() && !inSvg) {
|
||||
atoms.push_back({currentWord, currentStyle(), false, 0});
|
||||
currentWord.clear();
|
||||
}
|
||||
};
|
||||
|
||||
auto indentPx = [&]() -> int {
|
||||
// 15 pixels per nesting level (the first level has no extra indent)
|
||||
int depth = static_cast<int>(listStack.size());
|
||||
return (depth > 1) ? (depth - 1) * 15 : 0;
|
||||
};
|
||||
|
||||
// Skip any leading non-HTML text (e.g. pronunciation guides like "/ˈsɪm.pəl/, /ˈsɪmpəl/")
|
||||
// that appears before the first tag in sametypesequence=h entries.
|
||||
size_t i = 0;
|
||||
{
|
||||
size_t firstTag = html.find('<');
|
||||
if (firstTag != std::string::npos) i = firstTag;
|
||||
}
|
||||
|
||||
while (i < html.size()) {
|
||||
// ------- HTML tag -------
|
||||
if (html[i] == '<') {
|
||||
flushWord();
|
||||
|
||||
size_t tagEnd = html.find('>', i);
|
||||
if (tagEnd == std::string::npos) break;
|
||||
|
||||
std::string tagContent = html.substr(i + 1, tagEnd - i - 1);
|
||||
|
||||
// Extract tag name: first token, lowercased, trailing '/' stripped.
|
||||
size_t space = tagContent.find(' ');
|
||||
std::string tagName = (space != std::string::npos) ? tagContent.substr(0, space) : tagContent;
|
||||
for (auto& c : tagName) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
if (!tagName.empty() && tagName.back() == '/') tagName.pop_back();
|
||||
|
||||
// --- SVG handling (skip all content inside <svg>…</svg>) ---
|
||||
if (tagName == "svg") {
|
||||
inSvg = true;
|
||||
svgDepth = 1;
|
||||
} else if (inSvg) {
|
||||
if (tagName == "svg") {
|
||||
svgDepth++;
|
||||
} else if (tagName == "/svg") {
|
||||
svgDepth--;
|
||||
if (svgDepth <= 0) inSvg = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!inSvg) {
|
||||
// --- Inline style tags ---
|
||||
if (tagName == "b" || tagName == "strong") {
|
||||
isBold = true;
|
||||
} else if (tagName == "/b" || tagName == "/strong") {
|
||||
isBold = false;
|
||||
} else if (tagName == "i" || tagName == "em") {
|
||||
isItalic = true;
|
||||
} else if (tagName == "/i" || tagName == "/em") {
|
||||
isItalic = false;
|
||||
|
||||
// --- Block-level tags → newlines ---
|
||||
} else if (tagName == "p" || tagName == "h1" || tagName == "h2" || tagName == "h3" || tagName == "h4") {
|
||||
atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()});
|
||||
// Headings get bold style applied to following text
|
||||
if (tagName != "p") isBold = true;
|
||||
} else if (tagName == "/p" || tagName == "/h1" || tagName == "/h2" || tagName == "/h3" || tagName == "/h4") {
|
||||
atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()});
|
||||
isBold = false;
|
||||
} else if (tagName == "br") {
|
||||
atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()});
|
||||
|
||||
// --- Separator between definition entries ---
|
||||
} else if (tagName == "/html") {
|
||||
atoms.push_back({"", EpdFontFamily::REGULAR, true, 0});
|
||||
atoms.push_back({"", EpdFontFamily::REGULAR, true, 0}); // extra blank line
|
||||
isBold = false;
|
||||
isItalic = false;
|
||||
// Skip any raw text between </html> and the next tag — this is where
|
||||
// pronunciation guides (e.g. /ˈsɪmpəl/, /ksɛpt/) live in this dictionary.
|
||||
size_t nextTag = html.find('<', tagEnd + 1);
|
||||
i = (nextTag != std::string::npos) ? nextTag : html.size();
|
||||
continue;
|
||||
|
||||
// --- Lists ---
|
||||
} else if (tagName == "ol") {
|
||||
bool alpha = tagContent.find("lower-alpha") != std::string::npos;
|
||||
listStack.push_back({0, alpha});
|
||||
} else if (tagName == "ul") {
|
||||
listStack.push_back({0, false});
|
||||
} else if (tagName == "/ol" || tagName == "/ul") {
|
||||
if (!listStack.empty()) listStack.pop_back();
|
||||
} else if (tagName == "li") {
|
||||
atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()});
|
||||
if (!listStack.empty()) {
|
||||
auto& ls = listStack.back();
|
||||
ls.counter++;
|
||||
std::string marker;
|
||||
if (ls.isAlpha && ls.counter >= 1 && ls.counter <= 26) {
|
||||
marker = std::string(1, static_cast<char>('a' + ls.counter - 1)) + ". ";
|
||||
} else if (ls.isAlpha) {
|
||||
marker = std::to_string(ls.counter) + ". ";
|
||||
} else {
|
||||
marker = std::to_string(ls.counter) + ". ";
|
||||
}
|
||||
atoms.push_back({marker, EpdFontFamily::REGULAR, false, 0});
|
||||
} else {
|
||||
// Unordered list or bare <li>
|
||||
atoms.push_back({"\xE2\x80\xA2 ", EpdFontFamily::REGULAR, false, 0});
|
||||
}
|
||||
}
|
||||
// All other tags (span, div, code, sup, sub, table, etc.) are silently ignored;
|
||||
// their text content will still be emitted.
|
||||
}
|
||||
|
||||
i = tagEnd + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip content inside SVG
|
||||
if (inSvg) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ------- HTML entity -------
|
||||
if (html[i] == '&') {
|
||||
size_t semicolon = html.find(';', i);
|
||||
if (semicolon != std::string::npos && semicolon - i < 16) {
|
||||
std::string entity = html.substr(i + 1, semicolon - i - 1);
|
||||
std::string decoded = decodeEntity(entity);
|
||||
if (!decoded.empty()) {
|
||||
// Treat decoded chars like normal text (could be space etc.)
|
||||
for (char dc : decoded) {
|
||||
if (dc == ' ') {
|
||||
flushWord();
|
||||
} else {
|
||||
currentWord += dc;
|
||||
}
|
||||
}
|
||||
}
|
||||
i = semicolon + 1;
|
||||
continue;
|
||||
}
|
||||
// Not a valid entity — emit '&' literally
|
||||
currentWord += '&';
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ------- IPA pronunciation (skip /…/ and […] containing non-ASCII) -------
|
||||
if (html[i] == '/' || html[i] == '[') {
|
||||
char closeDelim = (html[i] == '/') ? '/' : ']';
|
||||
size_t end = html.find(closeDelim, i + 1);
|
||||
if (end != std::string::npos && end - i < 80) {
|
||||
bool hasNonAscii = false;
|
||||
for (size_t j = i + 1; j < end; j++) {
|
||||
if (static_cast<unsigned char>(html[j]) > 127) {
|
||||
hasNonAscii = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasNonAscii) {
|
||||
flushWord();
|
||||
i = end + 1; // skip entire IPA section including delimiters
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Not IPA — fall through to treat as regular character
|
||||
}
|
||||
|
||||
// ------- Whitespace -------
|
||||
if (html[i] == ' ' || html[i] == '\t' || html[i] == '\n' || html[i] == '\r') {
|
||||
flushWord();
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ------- Regular character (with non-renderable character filter) -------
|
||||
{
|
||||
unsigned char byte = static_cast<unsigned char>(html[i]);
|
||||
if (byte < 0x80) {
|
||||
// ASCII — always renderable
|
||||
currentWord += html[i];
|
||||
i++;
|
||||
} else {
|
||||
// Multi-byte UTF-8: decode codepoint and check if renderable
|
||||
int seqLen = 1;
|
||||
uint32_t cp = 0;
|
||||
if ((byte & 0xE0) == 0xC0) {
|
||||
seqLen = 2;
|
||||
cp = byte & 0x1F;
|
||||
} else if ((byte & 0xF0) == 0xE0) {
|
||||
seqLen = 3;
|
||||
cp = byte & 0x0F;
|
||||
} else if ((byte & 0xF8) == 0xF0) {
|
||||
seqLen = 4;
|
||||
cp = byte & 0x07;
|
||||
} else {
|
||||
i++;
|
||||
continue;
|
||||
} // invalid start byte
|
||||
|
||||
if (i + static_cast<size_t>(seqLen) > html.size()) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
bool valid = true;
|
||||
for (int j = 1; j < seqLen; j++) {
|
||||
unsigned char cb = static_cast<unsigned char>(html[i + j]);
|
||||
if ((cb & 0xC0) != 0x80) {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
cp = (cp << 6) | (cb & 0x3F);
|
||||
}
|
||||
|
||||
if (valid && isRenderableCodepoint(cp)) {
|
||||
for (int j = 0; j < seqLen; j++) {
|
||||
currentWord += html[i + j];
|
||||
}
|
||||
}
|
||||
// else: silently skip non-renderable character
|
||||
|
||||
i += valid ? seqLen : 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flushWord();
|
||||
return atoms;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Word-wrap the parsed HTML atoms into positioned line segments
|
||||
// ---------------------------------------------------------------------------
|
||||
void DictionaryDefinitionActivity::wrapText() {
|
||||
wrappedLines.clear();
|
||||
|
||||
const bool landscape = orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW ||
|
||||
orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW;
|
||||
const int screenWidth = renderer.getScreenWidth();
|
||||
const int lineHeight = renderer.getLineHeight(readerFontId);
|
||||
const int sidePadding = landscape ? 50 : 20;
|
||||
constexpr int topArea = 50;
|
||||
constexpr int bottomArea = 50;
|
||||
const int maxWidth = screenWidth - 2 * sidePadding;
|
||||
const int spaceWidth = renderer.getSpaceWidth(readerFontId);
|
||||
|
||||
linesPerPage = (renderer.getScreenHeight() - topArea - bottomArea) / lineHeight;
|
||||
if (linesPerPage < 1) linesPerPage = 1;
|
||||
|
||||
auto atoms = parseHtml(definition);
|
||||
|
||||
std::vector<Segment> currentLine;
|
||||
int currentX = 0;
|
||||
int baseIndent = 0; // indent for continuation lines within the same block
|
||||
|
||||
for (const auto& atom : atoms) {
|
||||
// ---- Newline directive ----
|
||||
if (atom.isNewline) {
|
||||
// Collapse multiple consecutive blank lines
|
||||
if (currentLine.empty() && !wrappedLines.empty() && wrappedLines.back().empty()) {
|
||||
// Already have a blank line; update indent but don't push another
|
||||
baseIndent = atom.indent;
|
||||
currentX = baseIndent;
|
||||
continue;
|
||||
}
|
||||
wrappedLines.push_back(std::move(currentLine));
|
||||
currentLine.clear();
|
||||
baseIndent = atom.indent;
|
||||
currentX = baseIndent;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ---- Text word ----
|
||||
int wordWidth = renderer.getTextWidth(readerFontId, atom.text.c_str(), atom.style);
|
||||
int gap = (currentX > baseIndent) ? spaceWidth : 0;
|
||||
|
||||
// Wrap if this word won't fit
|
||||
if (currentX + gap + wordWidth > maxWidth && currentX > baseIndent) {
|
||||
wrappedLines.push_back(std::move(currentLine));
|
||||
currentLine.clear();
|
||||
currentX = baseIndent;
|
||||
gap = 0;
|
||||
}
|
||||
|
||||
int16_t x = static_cast<int16_t>(currentX + gap);
|
||||
currentLine.push_back({atom.text, x, atom.style});
|
||||
currentX = x + wordWidth;
|
||||
}
|
||||
|
||||
// Flush last line
|
||||
if (!currentLine.empty()) {
|
||||
wrappedLines.push_back(std::move(currentLine));
|
||||
}
|
||||
|
||||
totalPages = (static_cast<int>(wrappedLines.size()) + linesPerPage - 1) / linesPerPage;
|
||||
if (totalPages < 1) totalPages = 1;
|
||||
}
|
||||
|
||||
void DictionaryDefinitionActivity::loop() {
|
||||
const bool prevPage = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Left);
|
||||
const bool nextPage = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Right);
|
||||
|
||||
if (prevPage && currentPage > 0) {
|
||||
currentPage--;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
if (nextPage && currentPage < totalPages - 1) {
|
||||
currentPage++;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (onDone) {
|
||||
onDone();
|
||||
} else {
|
||||
onBack();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void DictionaryDefinitionActivity::render(Activity::RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const bool landscape = orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW ||
|
||||
orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW;
|
||||
const int sidePadding = landscape ? 50 : 20;
|
||||
constexpr int titleY = 10;
|
||||
const int lineHeight = renderer.getLineHeight(readerFontId);
|
||||
constexpr int bodyStartY = 50;
|
||||
|
||||
// Title: the word in bold (UI font)
|
||||
renderer.drawText(UI_12_FONT_ID, sidePadding, titleY, headword.c_str(), true, EpdFontFamily::BOLD);
|
||||
|
||||
// Separator line
|
||||
renderer.drawLine(sidePadding, 40, renderer.getScreenWidth() - sidePadding, 40);
|
||||
|
||||
// Body: styled definition lines
|
||||
int startLine = currentPage * linesPerPage;
|
||||
for (int i = 0; i < linesPerPage && (startLine + i) < static_cast<int>(wrappedLines.size()); i++) {
|
||||
int y = bodyStartY + i * lineHeight;
|
||||
const auto& line = wrappedLines[startLine + i];
|
||||
for (const auto& seg : line) {
|
||||
renderer.drawText(readerFontId, sidePadding + seg.x, y, seg.text.c_str(), true, seg.style);
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination indicator (bottom right)
|
||||
if (totalPages > 1) {
|
||||
std::string pageInfo = std::to_string(currentPage + 1) + "/" + std::to_string(totalPages);
|
||||
int textWidth = renderer.getTextWidth(SMALL_FONT_ID, pageInfo.c_str());
|
||||
renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - sidePadding - textWidth,
|
||||
renderer.getScreenHeight() - 50, pageInfo.c_str());
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
const auto origOrientation = renderer.getOrientation();
|
||||
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
|
||||
const int portW = renderer.getScreenWidth();
|
||||
|
||||
constexpr int sideButtonWidth = 30;
|
||||
constexpr int sideButtonHeight = 78;
|
||||
constexpr int sideButtonGap = 5;
|
||||
constexpr int sideTopY = 345;
|
||||
constexpr int cornerRadius = 6;
|
||||
const int sideX = portW - sideButtonWidth;
|
||||
const int sideButtonY[2] = {sideTopY, sideTopY + sideButtonHeight + sideButtonGap};
|
||||
const char* sideLabels[2] = {"\xC2\xAB Page", "Page \xC2\xBB"};
|
||||
const bool useCCW = (orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW);
|
||||
|
||||
for (int i = 0; i < 2; i++) {
|
||||
renderer.fillRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, false);
|
||||
renderer.drawRoundedRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, 1, cornerRadius, true, false,
|
||||
true, false, true);
|
||||
|
||||
const std::string truncated = renderer.truncatedText(SMALL_FONT_ID, sideLabels[i], sideButtonHeight);
|
||||
const int tw = renderer.getTextWidth(SMALL_FONT_ID, truncated.c_str());
|
||||
|
||||
if (useCCW) {
|
||||
renderer.drawTextRotated90CCW(SMALL_FONT_ID, sideX, sideButtonY[i] + (sideButtonHeight - tw) / 2,
|
||||
truncated.c_str());
|
||||
} else {
|
||||
renderer.drawTextRotated90CW(SMALL_FONT_ID, sideX, sideButtonY[i] + (sideButtonHeight + tw) / 2,
|
||||
truncated.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
renderer.setOrientation(origOrientation);
|
||||
}
|
||||
|
||||
// Use half refresh when entering the screen for cleaner transition; fast refresh for page turns.
|
||||
renderer.displayBuffer(firstRender ? HalDisplay::HALF_REFRESH : HalDisplay::FAST_REFRESH);
|
||||
firstRender = false;
|
||||
}
|
||||
68
src/activities/reader/DictionaryDefinitionActivity.h
Normal file
68
src/activities/reader/DictionaryDefinitionActivity.h
Normal file
@@ -0,0 +1,68 @@
|
||||
#pragma once
|
||||
#include <EpdFontFamily.h>
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Activity.h"
|
||||
|
||||
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,
|
||||
const std::function<void()>& onDone = nullptr)
|
||||
: Activity("DictionaryDefinition", renderer, mappedInput),
|
||||
headword(headword),
|
||||
definition(definition),
|
||||
readerFontId(readerFontId),
|
||||
orientation(orientation),
|
||||
onBack(onBack),
|
||||
onDone(onDone) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(Activity::RenderLock&&) override;
|
||||
|
||||
private:
|
||||
// A positioned text segment within a wrapped line (pre-calculated x offset and style).
|
||||
struct Segment {
|
||||
std::string text;
|
||||
int16_t x;
|
||||
EpdFontFamily::Style style;
|
||||
};
|
||||
|
||||
// An intermediate token produced by the HTML parser before word-wrapping.
|
||||
struct TextAtom {
|
||||
std::string text;
|
||||
EpdFontFamily::Style style;
|
||||
bool isNewline;
|
||||
int indent; // pixels to indent the new line (for nested lists)
|
||||
};
|
||||
|
||||
// Tracks ordered/unordered list nesting during HTML parsing.
|
||||
struct ListState {
|
||||
int counter; // incremented per <li>, 0 = not yet used
|
||||
bool isAlpha; // true for list-style-type: lower-alpha
|
||||
};
|
||||
|
||||
std::string headword;
|
||||
std::string definition;
|
||||
int readerFontId;
|
||||
uint8_t orientation;
|
||||
const std::function<void()> onBack;
|
||||
const std::function<void()> onDone;
|
||||
|
||||
std::vector<std::vector<Segment>> wrappedLines;
|
||||
int currentPage = 0;
|
||||
int linesPerPage = 0;
|
||||
int totalPages = 0;
|
||||
bool firstRender = true;
|
||||
|
||||
std::vector<TextAtom> parseHtml(const std::string& html);
|
||||
static std::string decodeEntity(const std::string& entity);
|
||||
static bool isRenderableCodepoint(uint32_t cp);
|
||||
void wrapText();
|
||||
};
|
||||
116
src/activities/reader/DictionarySuggestionsActivity.cpp
Normal file
116
src/activities/reader/DictionarySuggestionsActivity.cpp
Normal file
@@ -0,0 +1,116 @@
|
||||
#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::onEnter() {
|
||||
ActivityWithSubactivity::onEnter();
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void DictionarySuggestionsActivity::onExit() { ActivityWithSubactivity::onExit(); }
|
||||
|
||||
void DictionarySuggestionsActivity::loop() {
|
||||
if (subActivity) {
|
||||
subActivity->loop();
|
||||
if (pendingBackFromDef) {
|
||||
pendingBackFromDef = false;
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
}
|
||||
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()));
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPrevious([this] {
|
||||
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(suggestions.size()));
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
const std::string& selected = suggestions[selectedIndex];
|
||||
std::string definition = Dictionary::lookup(selected);
|
||||
|
||||
if (definition.empty()) {
|
||||
{
|
||||
Activity::RenderLock lock(*this);
|
||||
GUI.drawPopup(renderer, "Not found");
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
}
|
||||
vTaskDelay(1000 / portTICK_PERIOD_MS);
|
||||
requestUpdate();
|
||||
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::render(Activity::RenderLock&&) {
|
||||
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);
|
||||
}
|
||||
42
src/activities/reader/DictionarySuggestionsActivity.h
Normal file
42
src/activities/reader/DictionarySuggestionsActivity.h
Normal file
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
#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;
|
||||
void render(Activity::RenderLock&&) 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 pendingBackFromDef = false;
|
||||
bool pendingExitToReader = false;
|
||||
ButtonNavigator buttonNavigator;
|
||||
};
|
||||
641
src/activities/reader/DictionaryWordSelectActivity.cpp
Normal file
641
src/activities/reader/DictionaryWordSelectActivity.cpp
Normal file
@@ -0,0 +1,641 @@
|
||||
#include "DictionaryWordSelectActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <climits>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#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 DictionaryWordSelectActivity::onEnter() {
|
||||
ActivityWithSubactivity::onEnter();
|
||||
extractWords();
|
||||
mergeHyphenatedWords();
|
||||
if (!rows.empty()) {
|
||||
currentRow = static_cast<int>(rows.size()) / 3;
|
||||
currentWordInRow = 0;
|
||||
}
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::onExit() { ActivityWithSubactivity::onExit(); }
|
||||
|
||||
bool DictionaryWordSelectActivity::isLandscape() const {
|
||||
return orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW ||
|
||||
orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW;
|
||||
}
|
||||
|
||||
bool DictionaryWordSelectActivity::isInverted() const {
|
||||
return orientation == CrossPointSettings::ORIENTATION::INVERTED;
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::extractWords() {
|
||||
words.clear();
|
||||
rows.clear();
|
||||
|
||||
for (const auto& element : page->elements) {
|
||||
// PageLine is the only concrete PageElement type, identified by tag
|
||||
const auto* line = static_cast<const PageLine*>(element.get());
|
||||
|
||||
const auto& block = line->getBlock();
|
||||
if (!block) continue;
|
||||
|
||||
const auto& wordList = block->getWords();
|
||||
const auto& xPosList = block->getWordXpos();
|
||||
|
||||
auto wordIt = wordList.begin();
|
||||
auto xIt = xPosList.begin();
|
||||
|
||||
while (wordIt != wordList.end() && xIt != xPosList.end()) {
|
||||
int16_t screenX = line->xPos + static_cast<int16_t>(*xIt) + marginLeft;
|
||||
int16_t screenY = line->yPos + marginTop;
|
||||
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});
|
||||
}
|
||||
}
|
||||
|
||||
++wordIt;
|
||||
++xIt;
|
||||
}
|
||||
}
|
||||
|
||||
// Group words into rows by Y position
|
||||
if (words.empty()) return;
|
||||
|
||||
int16_t currentY = words[0].screenY;
|
||||
rows.push_back({currentY, {}});
|
||||
|
||||
for (size_t i = 0; i < words.size(); i++) {
|
||||
// Allow small Y tolerance (words on same line may differ by a pixel)
|
||||
if (std::abs(words[i].screenY - currentY) > 2) {
|
||||
currentY = words[i].screenY;
|
||||
rows.push_back({currentY, {}});
|
||||
}
|
||||
words[i].row = static_cast<int16_t>(rows.size() - 1);
|
||||
rows.back().wordIndices.push_back(static_cast<int>(i));
|
||||
}
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::mergeHyphenatedWords() {
|
||||
for (size_t r = 0; r + 1 < rows.size(); r++) {
|
||||
if (rows[r].wordIndices.empty() || rows[r + 1].wordIndices.empty()) continue;
|
||||
|
||||
int lastWordIdx = rows[r].wordIndices.back();
|
||||
const std::string& lastWord = words[lastWordIdx].text;
|
||||
if (lastWord.empty()) continue;
|
||||
|
||||
// Check if word ends with hyphen (regular '-' or soft hyphen U+00AD: 0xC2 0xAD)
|
||||
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) continue;
|
||||
|
||||
int nextWordIdx = rows[r + 1].wordIndices.front();
|
||||
|
||||
// Set bidirectional continuation links for highlighting both parts
|
||||
words[lastWordIdx].continuationIndex = nextWordIdx;
|
||||
words[nextWordIdx].continuationOf = lastWordIdx;
|
||||
|
||||
// Build merged lookup text: remove trailing hyphen and combine
|
||||
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 + words[nextWordIdx].text;
|
||||
words[lastWordIdx].lookupText = merged;
|
||||
words[nextWordIdx].lookupText = merged;
|
||||
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();
|
||||
requestUpdate();
|
||||
}
|
||||
if (pendingExitToReader) {
|
||||
pendingExitToReader = false;
|
||||
exitActivity();
|
||||
onBack();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (words.empty()) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
onBack();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
bool changed = false;
|
||||
const bool landscape = isLandscape();
|
||||
const bool inverted = isInverted();
|
||||
|
||||
// Button mapping depends on physical orientation:
|
||||
// - Portrait: side Up/Down = row nav, face Left/Right = word nav
|
||||
// - Inverted: same axes but reversed directions (device is flipped 180)
|
||||
// - Landscape: face Left/Right = row nav (swapped), side Up/Down = word nav
|
||||
bool rowPrevPressed, rowNextPressed, wordPrevPressed, wordNextPressed;
|
||||
|
||||
if (landscape && orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW) {
|
||||
rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Left);
|
||||
rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Right);
|
||||
wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Down);
|
||||
wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Up);
|
||||
} else if (landscape) {
|
||||
rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Right);
|
||||
rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Left);
|
||||
wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Up);
|
||||
wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Down);
|
||||
} else if (inverted) {
|
||||
rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Down);
|
||||
rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Up);
|
||||
wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Right);
|
||||
wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Left);
|
||||
} else {
|
||||
// Portrait (default)
|
||||
rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Up);
|
||||
rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Down);
|
||||
wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Left);
|
||||
wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Right);
|
||||
}
|
||||
|
||||
const int rowCount = static_cast<int>(rows.size());
|
||||
|
||||
// Helper: find closest word by X position in a target row
|
||||
auto findClosestWord = [&](int targetRow) {
|
||||
int wordIdx = rows[currentRow].wordIndices[currentWordInRow];
|
||||
int currentCenterX = words[wordIdx].screenX + words[wordIdx].width / 2;
|
||||
int bestMatch = 0;
|
||||
int bestDist = INT_MAX;
|
||||
for (int i = 0; i < static_cast<int>(rows[targetRow].wordIndices.size()); i++) {
|
||||
int idx = rows[targetRow].wordIndices[i];
|
||||
int centerX = words[idx].screenX + words[idx].width / 2;
|
||||
int dist = std::abs(centerX - currentCenterX);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestMatch = i;
|
||||
}
|
||||
}
|
||||
return bestMatch;
|
||||
};
|
||||
|
||||
// Move to previous row (wrap to bottom)
|
||||
if (rowPrevPressed) {
|
||||
int targetRow = (currentRow > 0) ? currentRow - 1 : rowCount - 1;
|
||||
currentWordInRow = findClosestWord(targetRow);
|
||||
currentRow = targetRow;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// Move to next row (wrap to top)
|
||||
if (rowNextPressed) {
|
||||
int targetRow = (currentRow < rowCount - 1) ? currentRow + 1 : 0;
|
||||
currentWordInRow = findClosestWord(targetRow);
|
||||
currentRow = targetRow;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// Move to previous word (wrap to end of previous row)
|
||||
if (wordPrevPressed) {
|
||||
if (currentWordInRow > 0) {
|
||||
currentWordInRow--;
|
||||
} else if (rowCount > 1) {
|
||||
currentRow = (currentRow > 0) ? currentRow - 1 : rowCount - 1;
|
||||
currentWordInRow = static_cast<int>(rows[currentRow].wordIndices.size()) - 1;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// Move to next word (wrap to start of next row)
|
||||
if (wordNextPressed) {
|
||||
if (currentWordInRow < static_cast<int>(rows[currentRow].wordIndices.size()) - 1) {
|
||||
currentWordInRow++;
|
||||
} else if (rowCount > 1) {
|
||||
currentRow = (currentRow < rowCount - 1) ? currentRow + 1 : 0;
|
||||
currentWordInRow = 0;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
int wordIdx = rows[currentRow].wordIndices[currentWordInRow];
|
||||
const std::string& rawWord = words[wordIdx].lookupText;
|
||||
std::string cleaned = Dictionary::cleanWord(rawWord);
|
||||
|
||||
if (cleaned.empty()) {
|
||||
{
|
||||
Activity::RenderLock lock(*this);
|
||||
GUI.drawPopup(renderer, "No word");
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
}
|
||||
vTaskDelay(1000 / portTICK_PERIOD_MS);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
Rect popupLayout;
|
||||
{
|
||||
Activity::RenderLock lock(*this);
|
||||
popupLayout = GUI.drawPopup(renderer, "Looking up...");
|
||||
}
|
||||
|
||||
bool cancelled = false;
|
||||
std::string definition = Dictionary::lookup(
|
||||
cleaned,
|
||||
[this, &popupLayout](int percent) {
|
||||
Activity::RenderLock lock(*this);
|
||||
GUI.fillPopupProgress(renderer, popupLayout, percent);
|
||||
},
|
||||
[this, &cancelled]() -> bool {
|
||||
mappedInput.update();
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
cancelled = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (cancelled) {
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
LookupHistory::addWord(cachePath, cleaned);
|
||||
|
||||
if (!definition.empty()) {
|
||||
enterNewActivity(new DictionaryDefinitionActivity(
|
||||
renderer, mappedInput, cleaned, definition, fontId, orientation, [this]() { pendingBackFromDef = true; },
|
||||
[this]() { pendingExitToReader = true; }));
|
||||
return;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
{
|
||||
Activity::RenderLock lock(*this);
|
||||
GUI.drawPopup(renderer, "Not found");
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
}
|
||||
vTaskDelay(1500 / portTICK_PERIOD_MS);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::render(Activity::RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
// Render the page content
|
||||
page->render(renderer, fontId, marginLeft, marginTop);
|
||||
|
||||
if (!words.empty() && currentRow < static_cast<int>(rows.size())) {
|
||||
int wordIdx = rows[currentRow].wordIndices[currentWordInRow];
|
||||
const auto& w = words[wordIdx];
|
||||
|
||||
// Draw inverted highlight behind selected word
|
||||
const int lineHeight = renderer.getLineHeight(fontId);
|
||||
renderer.fillRect(w.screenX - 1, w.screenY - 1, w.width + 2, lineHeight + 2, true);
|
||||
renderer.drawText(fontId, w.screenX, w.screenY, w.text.c_str(), false);
|
||||
|
||||
// Highlight the other half of a hyphenated word (whether selecting first or second part)
|
||||
int otherIdx = (w.continuationOf >= 0) ? w.continuationOf : -1;
|
||||
if (otherIdx < 0 && w.continuationIndex >= 0 && w.continuationIndex != wordIdx) {
|
||||
otherIdx = w.continuationIndex;
|
||||
}
|
||||
if (otherIdx >= 0) {
|
||||
const auto& other = words[otherIdx];
|
||||
renderer.fillRect(other.screenX - 1, other.screenY - 1, other.width + 2, lineHeight + 2, true);
|
||||
renderer.drawText(fontId, other.screenX, other.screenY, other.text.c_str(), false);
|
||||
}
|
||||
}
|
||||
|
||||
drawHints();
|
||||
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::drawHints() {
|
||||
// Draw button hints in portrait orientation (matching physical buttons and theme).
|
||||
// Any hint whose area would overlap the selected word highlight is completely skipped,
|
||||
// leaving the page content underneath visible.
|
||||
const auto origOrientation = renderer.getOrientation();
|
||||
|
||||
// Get portrait dimensions for overlap math
|
||||
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
|
||||
const int portW = renderer.getScreenWidth(); // 480 in portrait
|
||||
const int portH = renderer.getScreenHeight(); // 800 in portrait
|
||||
renderer.setOrientation(origOrientation);
|
||||
|
||||
// Bottom button constants (match LyraTheme::drawButtonHints)
|
||||
constexpr int buttonHeight = 40; // LyraMetrics::values.buttonHintsHeight
|
||||
constexpr int buttonWidth = 80;
|
||||
constexpr int cornerRadius = 6;
|
||||
constexpr int textYOffset = 7;
|
||||
constexpr int smallButtonHeight = 15;
|
||||
constexpr int buttonPositions[] = {58, 146, 254, 342};
|
||||
|
||||
// Side button constants (match LyraTheme::drawSideButtonHints)
|
||||
constexpr int sideButtonWidth = 30; // LyraMetrics::values.sideButtonHintsWidth
|
||||
constexpr int sideButtonHeight = 78;
|
||||
constexpr int sideButtonGap = 5;
|
||||
constexpr int sideTopY = 345; // topHintButtonY
|
||||
const int sideX = portW - sideButtonWidth;
|
||||
const int sideButtonY[2] = {sideTopY, sideTopY + sideButtonHeight + sideButtonGap};
|
||||
|
||||
// Labels for face and side buttons depend on orientation,
|
||||
// because the physical-to-logical mapping rotates with the screen.
|
||||
const char* facePrev; // label for physical Left face button
|
||||
const char* faceNext; // label for physical Right face button
|
||||
const char* sideTop; // label for physical top side button (PageBack)
|
||||
const char* sideBottom; // label for physical bottom side button (PageForward)
|
||||
|
||||
const bool landscape = isLandscape();
|
||||
const bool inverted = isInverted();
|
||||
|
||||
if (landscape && orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW) {
|
||||
facePrev = "Line Up";
|
||||
faceNext = "Line Dn";
|
||||
sideTop = "Word \xC2\xBB";
|
||||
sideBottom = "\xC2\xAB Word";
|
||||
} else if (landscape) { // LANDSCAPE_CCW
|
||||
facePrev = "Line Dn";
|
||||
faceNext = "Line Up";
|
||||
sideTop = "\xC2\xAB Word";
|
||||
sideBottom = "Word \xC2\xBB";
|
||||
} else if (inverted) {
|
||||
facePrev = "Word \xC2\xBB";
|
||||
faceNext = "\xC2\xAB Word";
|
||||
sideTop = "Line Dn";
|
||||
sideBottom = "Line Up";
|
||||
} else { // Portrait (default)
|
||||
facePrev = "\xC2\xAB Word";
|
||||
faceNext = "Word \xC2\xBB";
|
||||
sideTop = "Line Up";
|
||||
sideBottom = "Line Dn";
|
||||
}
|
||||
|
||||
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", facePrev, faceNext);
|
||||
const char* btnLabels[] = {labels.btn1, labels.btn2, labels.btn3, labels.btn4};
|
||||
const char* sideLabels[] = {sideTop, sideBottom};
|
||||
|
||||
// ---- Determine which hints overlap the selected word ----
|
||||
bool hideHint[4] = {false, false, false, false};
|
||||
bool hideSide[2] = {false, false};
|
||||
|
||||
if (!words.empty() && currentRow < static_cast<int>(rows.size())) {
|
||||
const int lineHeight = renderer.getLineHeight(fontId);
|
||||
|
||||
// Collect bounding boxes of the selected word (and its continuation) in current-orientation coords.
|
||||
struct Box {
|
||||
int x, y, w, h;
|
||||
};
|
||||
Box boxes[2];
|
||||
int boxCount = 0;
|
||||
|
||||
int wordIdx = rows[currentRow].wordIndices[currentWordInRow];
|
||||
const auto& sel = words[wordIdx];
|
||||
boxes[0] = {sel.screenX - 1, sel.screenY - 1, sel.width + 2, lineHeight + 2};
|
||||
boxCount = 1;
|
||||
|
||||
int otherIdx = (sel.continuationOf >= 0) ? sel.continuationOf : -1;
|
||||
if (otherIdx < 0 && sel.continuationIndex >= 0 && sel.continuationIndex != wordIdx) {
|
||||
otherIdx = sel.continuationIndex;
|
||||
}
|
||||
if (otherIdx >= 0) {
|
||||
const auto& other = words[otherIdx];
|
||||
boxes[1] = {other.screenX - 1, other.screenY - 1, other.width + 2, lineHeight + 2};
|
||||
boxCount = 2;
|
||||
}
|
||||
|
||||
// Convert each box from the current orientation to portrait coordinates,
|
||||
// then check overlap against both bottom and side button hints.
|
||||
for (int b = 0; b < boxCount; b++) {
|
||||
int px, py, pw, ph;
|
||||
|
||||
if (origOrientation == GfxRenderer::Orientation::Portrait) {
|
||||
px = boxes[b].x;
|
||||
py = boxes[b].y;
|
||||
pw = boxes[b].w;
|
||||
ph = boxes[b].h;
|
||||
} else if (origOrientation == GfxRenderer::Orientation::PortraitInverted) {
|
||||
px = portW - boxes[b].x - boxes[b].w;
|
||||
py = portH - boxes[b].y - boxes[b].h;
|
||||
pw = boxes[b].w;
|
||||
ph = boxes[b].h;
|
||||
} else if (origOrientation == GfxRenderer::Orientation::LandscapeClockwise) {
|
||||
px = boxes[b].y;
|
||||
py = portH - boxes[b].x - boxes[b].w;
|
||||
pw = boxes[b].h;
|
||||
ph = boxes[b].w;
|
||||
} else {
|
||||
px = portW - boxes[b].y - boxes[b].h;
|
||||
py = boxes[b].x;
|
||||
pw = boxes[b].h;
|
||||
ph = boxes[b].w;
|
||||
}
|
||||
|
||||
// Bottom button overlap
|
||||
int hintTop = portH - buttonHeight;
|
||||
if (py + ph > hintTop) {
|
||||
for (int i = 0; i < 4; i++) {
|
||||
if (px + pw > buttonPositions[i] && px < buttonPositions[i] + buttonWidth) {
|
||||
hideHint[i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Side button overlap
|
||||
if (px + pw > sideX) {
|
||||
for (int s = 0; s < 2; s++) {
|
||||
if (py + ph > sideButtonY[s] && py < sideButtonY[s] + sideButtonHeight) {
|
||||
hideSide[s] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Draw all hints in portrait mode ----
|
||||
// Hidden buttons are skipped entirely so the page content underneath stays visible.
|
||||
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
|
||||
|
||||
// Bottom face buttons
|
||||
for (int i = 0; i < 4; i++) {
|
||||
if (hideHint[i]) continue;
|
||||
|
||||
const int x = buttonPositions[i];
|
||||
renderer.fillRect(x, portH - buttonHeight, buttonWidth, buttonHeight, false);
|
||||
|
||||
if (btnLabels[i] != nullptr && btnLabels[i][0] != '\0') {
|
||||
renderer.drawRoundedRect(x, portH - buttonHeight, buttonWidth, buttonHeight, 1, cornerRadius, true, true, false,
|
||||
false, true);
|
||||
const int tw = renderer.getTextWidth(SMALL_FONT_ID, btnLabels[i]);
|
||||
const int tx = x + (buttonWidth - 1 - tw) / 2;
|
||||
renderer.drawText(SMALL_FONT_ID, tx, portH - buttonHeight + textYOffset, btnLabels[i]);
|
||||
} else {
|
||||
renderer.drawRoundedRect(x, portH - smallButtonHeight, buttonWidth, smallButtonHeight, 1, cornerRadius, true,
|
||||
true, false, false, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Side buttons (custom-drawn with background, overlap hiding, truncation, and rotation)
|
||||
const bool useCCW = (orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW);
|
||||
|
||||
for (int i = 0; i < 2; i++) {
|
||||
if (hideSide[i]) continue;
|
||||
if (sideLabels[i] == nullptr || sideLabels[i][0] == '\0') continue;
|
||||
|
||||
// Solid background
|
||||
renderer.fillRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, false);
|
||||
|
||||
// Outline (rounded on inner side, square on screen edge — matches theme)
|
||||
renderer.drawRoundedRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, 1, cornerRadius, true, false,
|
||||
true, false, true);
|
||||
|
||||
// Truncate text if it would overflow the button height
|
||||
const std::string truncated = renderer.truncatedText(SMALL_FONT_ID, sideLabels[i], sideButtonHeight);
|
||||
const int tw = renderer.getTextWidth(SMALL_FONT_ID, truncated.c_str());
|
||||
|
||||
if (useCCW) {
|
||||
// Text reads top-to-bottom (90° CCW rotation): y starts near top of button
|
||||
renderer.drawTextRotated90CCW(SMALL_FONT_ID, sideX, sideButtonY[i] + (sideButtonHeight - tw) / 2,
|
||||
truncated.c_str());
|
||||
} else {
|
||||
// Text reads bottom-to-top (90° CW rotation): y starts near bottom of button
|
||||
renderer.drawTextRotated90CW(SMALL_FONT_ID, sideX, sideButtonY[i] + (sideButtonHeight + tw) / 2,
|
||||
truncated.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
renderer.setOrientation(origOrientation);
|
||||
}
|
||||
72
src/activities/reader/DictionaryWordSelectActivity.h
Normal file
72
src/activities/reader/DictionaryWordSelectActivity.h
Normal file
@@ -0,0 +1,72 @@
|
||||
#pragma once
|
||||
#include <Epub/Page.h>
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../ActivityWithSubactivity.h"
|
||||
|
||||
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::string& nextPageFirstWord = "")
|
||||
: ActivityWithSubactivity("DictionaryWordSelect", renderer, mappedInput),
|
||||
page(std::move(page)),
|
||||
fontId(fontId),
|
||||
marginLeft(marginLeft),
|
||||
marginTop(marginTop),
|
||||
cachePath(cachePath),
|
||||
orientation(orientation),
|
||||
onBack(onBack),
|
||||
nextPageFirstWord(nextPageFirstWord) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(Activity::RenderLock&&) override;
|
||||
|
||||
private:
|
||||
struct WordInfo {
|
||||
std::string text;
|
||||
std::string lookupText;
|
||||
int16_t screenX;
|
||||
int16_t screenY;
|
||||
int16_t width;
|
||||
int16_t row;
|
||||
int continuationIndex;
|
||||
int continuationOf;
|
||||
WordInfo(const std::string& t, int16_t x, int16_t y, int16_t w, int16_t r)
|
||||
: text(t), lookupText(t), screenX(x), screenY(y), width(w), row(r), continuationIndex(-1), continuationOf(-1) {}
|
||||
};
|
||||
|
||||
struct Row {
|
||||
int16_t yPos;
|
||||
std::vector<int> wordIndices;
|
||||
};
|
||||
|
||||
std::unique_ptr<Page> page;
|
||||
int fontId;
|
||||
int marginLeft;
|
||||
int marginTop;
|
||||
std::string cachePath;
|
||||
uint8_t orientation;
|
||||
const std::function<void()> onBack;
|
||||
std::string nextPageFirstWord;
|
||||
|
||||
std::vector<WordInfo> words;
|
||||
std::vector<Row> rows;
|
||||
int currentRow = 0;
|
||||
int currentWordInRow = 0;
|
||||
bool pendingBackFromDef = false;
|
||||
bool pendingExitToReader = false;
|
||||
|
||||
bool isLandscape() const;
|
||||
bool isInverted() const;
|
||||
void extractWords();
|
||||
void mergeHyphenatedWords();
|
||||
void drawHints();
|
||||
};
|
||||
@@ -6,9 +6,11 @@
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
#include <Logging.h>
|
||||
#include <PlaceholderCoverGenerator.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "EpubReaderBookmarkSelectionActivity.h"
|
||||
#include "EpubReaderChapterSelectionActivity.h"
|
||||
#include "EpubReaderPercentSelectionActivity.h"
|
||||
#include "KOReaderCredentialStore.h"
|
||||
@@ -17,14 +19,27 @@
|
||||
#include "RecentBooksStore.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/BookmarkStore.h"
|
||||
#include "util/Dictionary.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()
|
||||
constexpr unsigned long skipChapterMs = 700;
|
||||
constexpr unsigned long goHomeMs = 1000;
|
||||
constexpr unsigned long longPressConfirmMs = 700;
|
||||
constexpr int statusBarMargin = 19;
|
||||
constexpr int progressBarMarginTop = 1;
|
||||
|
||||
// 8x8 1-bit hourglass icon for the indexing status bar indicator.
|
||||
// Format: MSB-first, 0 = black pixel, 1 = white pixel (e-ink convention).
|
||||
constexpr uint8_t kIndexingIcon[] = {0x00, 0x81, 0xC3, 0xE7, 0xE7, 0xC3, 0x81, 0x00};
|
||||
constexpr int kIndexingIconSize = 8;
|
||||
|
||||
int clampPercent(int percent) {
|
||||
if (percent < 0) {
|
||||
return 0;
|
||||
@@ -96,6 +111,67 @@ void EpubReaderActivity::onEnter() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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(epub->getCoverBmpPath(false).c_str())) totalSteps++;
|
||||
if (!Storage.exists(epub->getCoverBmpPath(true).c_str())) totalSteps++;
|
||||
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
|
||||
if (!Storage.exists(epub->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 (!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 (!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 (!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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save current epub as last opened epub and add to recent books
|
||||
APP_STATE.openEpubPath = epub->getPath();
|
||||
APP_STATE.saveToFile();
|
||||
@@ -159,12 +235,26 @@ void EpubReaderActivity::loop() {
|
||||
!mappedInput.wasReleased(MappedInputManager::Button::Back);
|
||||
if (confirmCleared && backCleared) {
|
||||
skipNextButtonCheck = false;
|
||||
ignoreNextConfirmRelease = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter reader menu activity.
|
||||
// Long press CONFIRM opens Table of Contents directly (skip menu)
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= longPressConfirmMs) {
|
||||
ignoreNextConfirmRelease = true;
|
||||
if (epub && epub->getTocItemsCount() > 0) {
|
||||
openChapterSelection(true); // skip the stale release from this long-press
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Short press CONFIRM opens reader menu
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (ignoreNextConfirmRelease) {
|
||||
ignoreNextConfirmRelease = false;
|
||||
return;
|
||||
}
|
||||
const int currentPage = section ? section->currentPage + 1 : 0;
|
||||
const int totalPages = section ? section->pageCount : 0;
|
||||
float bookProgress = 0.0f;
|
||||
@@ -173,10 +263,14 @@ void EpubReaderActivity::loop() {
|
||||
bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f;
|
||||
}
|
||||
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
|
||||
const bool hasDictionary = Dictionary::exists();
|
||||
const bool isBookmarked =
|
||||
BookmarkStore::hasBookmark(epub->getCachePath(), currentSpineIndex, section ? section->currentPage : 0);
|
||||
exitActivity();
|
||||
enterNewActivity(new EpubReaderMenuActivity(
|
||||
this->renderer, this->mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
|
||||
SETTINGS.orientation, [this](const uint8_t orientation) { onReaderMenuBack(orientation); },
|
||||
SETTINGS.orientation, SETTINGS.fontSize, hasDictionary, isBookmarked, epub->getCachePath(),
|
||||
[this](const uint8_t orientation, const uint8_t fontSize) { onReaderMenuBack(orientation, fontSize); },
|
||||
[this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); }));
|
||||
}
|
||||
|
||||
@@ -267,11 +361,15 @@ void EpubReaderActivity::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
void EpubReaderActivity::onReaderMenuBack(const uint8_t orientation) {
|
||||
void EpubReaderActivity::onReaderMenuBack(const uint8_t orientation, const uint8_t fontSize) {
|
||||
exitActivity();
|
||||
// Apply the user-selected orientation when the menu is dismissed.
|
||||
// This ensures the menu can be navigated without immediately rotating the screen.
|
||||
applyOrientation(orientation);
|
||||
// Apply font size change (no-op if unchanged).
|
||||
applyFontSize(fontSize);
|
||||
// Force a half refresh on the next render to clear menu/popup artifacts
|
||||
pagesUntilFullRefresh = 1;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
@@ -338,31 +436,145 @@ void EpubReaderActivity::jumpToPercent(int percent) {
|
||||
}
|
||||
}
|
||||
|
||||
void EpubReaderActivity::openChapterSelection(bool initialSkipRelease) {
|
||||
const int currentP = section ? section->currentPage : 0;
|
||||
const int totalP = section ? section->pageCount : 0;
|
||||
const int spineIdx = currentSpineIndex;
|
||||
const std::string path = epub->getPath();
|
||||
|
||||
enterNewActivity(new EpubReaderChapterSelectionActivity(
|
||||
this->renderer, this->mappedInput, epub, path, spineIdx, currentP, totalP,
|
||||
[this] {
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
},
|
||||
[this](const int newSpineIndex) {
|
||||
if (currentSpineIndex != newSpineIndex) {
|
||||
currentSpineIndex = newSpineIndex;
|
||||
nextPageNumber = 0;
|
||||
section.reset();
|
||||
}
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
},
|
||||
[this](const int newSpineIndex, const int newPage) {
|
||||
if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) {
|
||||
currentSpineIndex = newSpineIndex;
|
||||
nextPageNumber = newPage;
|
||||
section.reset();
|
||||
}
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
},
|
||||
initialSkipRelease));
|
||||
}
|
||||
|
||||
void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) {
|
||||
switch (action) {
|
||||
case EpubReaderMenuActivity::MenuAction::SELECT_CHAPTER: {
|
||||
// Calculate values BEFORE we start destroying things
|
||||
const int currentP = section ? section->currentPage : 0;
|
||||
const int totalP = section ? section->pageCount : 0;
|
||||
const int spineIdx = currentSpineIndex;
|
||||
const std::string path = epub->getPath();
|
||||
case EpubReaderMenuActivity::MenuAction::ADD_BOOKMARK: {
|
||||
const int page = section ? section->currentPage : 0;
|
||||
|
||||
// 1. Close the menu
|
||||
exitActivity();
|
||||
|
||||
// 2. Open the Chapter Selector
|
||||
enterNewActivity(new EpubReaderChapterSelectionActivity(
|
||||
this->renderer, this->mappedInput, epub, path, spineIdx, currentP, totalP,
|
||||
[this] {
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
},
|
||||
[this](const int newSpineIndex) {
|
||||
if (currentSpineIndex != newSpineIndex) {
|
||||
currentSpineIndex = newSpineIndex;
|
||||
nextPageNumber = 0;
|
||||
section.reset();
|
||||
// Extract first full sentence from the current page for the bookmark snippet.
|
||||
// If the first word is lowercase, the page starts mid-sentence — skip to the
|
||||
// next sentence boundary and start collecting from there.
|
||||
std::string snippet;
|
||||
if (section) {
|
||||
auto p = section->loadPageFromSectionFile();
|
||||
if (p) {
|
||||
// Gather all words on the page into a flat list for easier traversal
|
||||
std::vector<std::string> allWords;
|
||||
for (const auto& element : p->elements) {
|
||||
const auto* line = static_cast<const PageLine*>(element.get());
|
||||
if (!line) continue;
|
||||
const auto& block = line->getBlock();
|
||||
if (!block) continue;
|
||||
for (const auto& word : block->getWords()) {
|
||||
allWords.push_back(word);
|
||||
}
|
||||
}
|
||||
|
||||
if (!allWords.empty()) {
|
||||
size_t startIdx = 0;
|
||||
|
||||
// Check if the first word starts with a lowercase letter (mid-sentence)
|
||||
const char firstChar = allWords[0].empty() ? '\0' : allWords[0][0];
|
||||
if (firstChar >= 'a' && firstChar <= 'z') {
|
||||
// Skip past the end of this partial sentence
|
||||
for (size_t i = 0; i < allWords.size(); i++) {
|
||||
if (!allWords[i].empty()) {
|
||||
char last = allWords[i].back();
|
||||
if (last == '.' || last == '!' || last == '?' || last == ':') {
|
||||
startIdx = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If no sentence boundary found, fall back to using everything from the start
|
||||
if (startIdx >= allWords.size()) {
|
||||
startIdx = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect words from startIdx until the next sentence boundary
|
||||
for (size_t i = startIdx; i < allWords.size(); i++) {
|
||||
if (!snippet.empty()) snippet += " ";
|
||||
snippet += allWords[i];
|
||||
if (!allWords[i].empty()) {
|
||||
char last = allWords[i].back();
|
||||
if (last == '.' || last == '!' || last == '?' || last == ':') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BookmarkStore::addBookmark(epub->getCachePath(), currentSpineIndex, page, snippet);
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
GUI.drawPopup(renderer, tr(STR_BOOKMARK_ADDED));
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
}
|
||||
vTaskDelay(750 / portTICK_PERIOD_MS);
|
||||
// Exit the menu and return to reading — the bookmark indicator will show on re-render,
|
||||
// and next menu open will reflect the updated state.
|
||||
exitActivity();
|
||||
pagesUntilFullRefresh = 1;
|
||||
requestUpdate();
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::REMOVE_BOOKMARK: {
|
||||
const int page = section ? section->currentPage : 0;
|
||||
BookmarkStore::removeBookmark(epub->getCachePath(), currentSpineIndex, page);
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
GUI.drawPopup(renderer, tr(STR_BOOKMARK_REMOVED));
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
}
|
||||
vTaskDelay(750 / portTICK_PERIOD_MS);
|
||||
exitActivity();
|
||||
pagesUntilFullRefresh = 1;
|
||||
requestUpdate();
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::GO_TO_BOOKMARK: {
|
||||
auto bookmarks = BookmarkStore::load(epub->getCachePath());
|
||||
|
||||
if (bookmarks.empty()) {
|
||||
// No bookmarks: fall back to Table of Contents if available, otherwise go back
|
||||
if (epub->getTocItemsCount() > 0) {
|
||||
exitActivity();
|
||||
openChapterSelection();
|
||||
}
|
||||
// If no TOC either, just return to reader (menu already closed by callback)
|
||||
break;
|
||||
}
|
||||
|
||||
exitActivity();
|
||||
enterNewActivity(new EpubReaderBookmarkSelectionActivity(
|
||||
this->renderer, this->mappedInput, epub, std::move(bookmarks), epub->getCachePath(),
|
||||
[this] {
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
},
|
||||
@@ -375,7 +587,11 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
}));
|
||||
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::SELECT_CHAPTER: {
|
||||
exitActivity();
|
||||
openChapterSelection();
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::GO_TO_PERCENT: {
|
||||
@@ -402,6 +618,73 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::LOOKUP: {
|
||||
// Gather data we need while holding the render lock
|
||||
int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft;
|
||||
std::unique_ptr<Page> pageForLookup;
|
||||
int readerFontId;
|
||||
std::string bookCachePath;
|
||||
uint8_t currentOrientation;
|
||||
std::string nextPageFirstWord;
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
|
||||
// Compute margins (same logic as render)
|
||||
renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom,
|
||||
&orientedMarginLeft);
|
||||
orientedMarginTop += SETTINGS.screenMargin;
|
||||
orientedMarginLeft += SETTINGS.screenMargin;
|
||||
orientedMarginRight += SETTINGS.screenMargin;
|
||||
orientedMarginBottom += SETTINGS.screenMargin;
|
||||
|
||||
if (SETTINGS.statusBar != CrossPointSettings::STATUS_BAR_MODE::NONE) {
|
||||
auto metrics = UITheme::getInstance().getMetrics();
|
||||
const bool showProgressBar =
|
||||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::BOOK_PROGRESS_BAR ||
|
||||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_BOOK_PROGRESS_BAR ||
|
||||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::CHAPTER_PROGRESS_BAR;
|
||||
orientedMarginBottom += statusBarMargin - SETTINGS.screenMargin +
|
||||
(showProgressBar ? (metrics.bookProgressBarHeight + progressBarMarginTop) : 0);
|
||||
}
|
||||
|
||||
// Load the current page
|
||||
pageForLookup = section ? section->loadPageFromSectionFile() : nullptr;
|
||||
readerFontId = SETTINGS.getReaderFontId();
|
||||
bookCachePath = epub->getCachePath();
|
||||
currentOrientation = SETTINGS.orientation;
|
||||
|
||||
// Get first word of next page for cross-page hyphenation
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Lock released — safe to call enterNewActivity which takes its own lock
|
||||
exitActivity();
|
||||
|
||||
if (pageForLookup) {
|
||||
enterNewActivity(new DictionaryWordSelectActivity(
|
||||
renderer, mappedInput, std::move(pageForLookup), readerFontId, orientedMarginLeft, orientedMarginTop,
|
||||
bookCachePath, currentOrientation, [this]() { pendingSubactivityExit = true; }, nextPageFirstWord));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::LOOKED_UP_WORDS: {
|
||||
exitActivity();
|
||||
enterNewActivity(new LookedUpWordsActivity(
|
||||
renderer, mappedInput, epub->getCachePath(), SETTINGS.getReaderFontId(), SETTINGS.orientation,
|
||||
[this]() { pendingSubactivityExit = true; }, [this]() { pendingSubactivityExit = true; },
|
||||
true)); // initialSkipRelease: consumed the long-press that triggered this
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::GO_HOME: {
|
||||
// Defer go home to avoid race condition with display task
|
||||
pendingGoHome = true;
|
||||
@@ -425,6 +708,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());
|
||||
}
|
||||
}
|
||||
// Defer go home to avoid race condition with display task
|
||||
@@ -454,6 +740,11 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
||||
}
|
||||
break;
|
||||
}
|
||||
// Handled locally in the menu activity (cycle on Confirm, never dispatched here)
|
||||
case EpubReaderMenuActivity::MenuAction::ROTATE_SCREEN:
|
||||
case EpubReaderMenuActivity::MenuAction::TOGGLE_FONT_SIZE:
|
||||
case EpubReaderMenuActivity::MenuAction::LETTERBOX_FILL:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,6 +775,28 @@ void EpubReaderActivity::applyOrientation(const uint8_t orientation) {
|
||||
}
|
||||
}
|
||||
|
||||
void EpubReaderActivity::applyFontSize(const uint8_t fontSize) {
|
||||
if (SETTINGS.fontSize == fontSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Preserve current reading position so we can restore after reflow.
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
if (section) {
|
||||
cachedSpineIndex = currentSpineIndex;
|
||||
cachedChapterTotalPageCount = section->pageCount;
|
||||
nextPageNumber = section->currentPage;
|
||||
}
|
||||
|
||||
SETTINGS.fontSize = fontSize;
|
||||
SETTINGS.saveToFile();
|
||||
|
||||
// Reset section to force re-layout with the new font size.
|
||||
section.reset();
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Failure handling
|
||||
void EpubReaderActivity::render(Activity::RenderLock&& lock) {
|
||||
if (!epub) {
|
||||
@@ -528,14 +841,17 @@ void EpubReaderActivity::render(Activity::RenderLock&& lock) {
|
||||
(showProgressBar ? (metrics.bookProgressBarHeight + progressBarMarginTop) : 0);
|
||||
}
|
||||
|
||||
const uint16_t viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight;
|
||||
const uint16_t viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom;
|
||||
|
||||
if (!section) {
|
||||
loadingSection = true;
|
||||
preIndexedNextSpine = -1;
|
||||
|
||||
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));
|
||||
|
||||
const uint16_t viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight;
|
||||
const uint16_t viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom;
|
||||
|
||||
if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle)) {
|
||||
@@ -548,6 +864,7 @@ void EpubReaderActivity::render(Activity::RenderLock&& lock) {
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, popupFn)) {
|
||||
LOG_ERR("ERS", "Failed to persist page data to SD");
|
||||
section.reset();
|
||||
loadingSection = false;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@@ -580,6 +897,8 @@ void EpubReaderActivity::render(Activity::RenderLock&& lock) {
|
||||
section->currentPage = newPage;
|
||||
pendingPercentJump = false;
|
||||
}
|
||||
|
||||
loadingSection = false;
|
||||
}
|
||||
|
||||
renderer.clearScreen();
|
||||
@@ -606,18 +925,83 @@ void EpubReaderActivity::render(Activity::RenderLock&& lock) {
|
||||
LOG_ERR("ERS", "Failed to load page from SD - clearing section cache");
|
||||
section->clearCache();
|
||||
section.reset();
|
||||
silentIndexingActive = false;
|
||||
requestUpdate(); // Try again after clearing cache
|
||||
// TODO: prevent infinite loop if the page keeps failing to load for some reason
|
||||
return;
|
||||
}
|
||||
|
||||
silentIndexingActive = false;
|
||||
const bool textOnlyPage = !p->hasImages();
|
||||
if (textOnlyPage && SETTINGS.indexingDisplay != CrossPointSettings::INDEXING_DISPLAY::INDEXING_POPUP &&
|
||||
section->pageCount >= 1 &&
|
||||
((section->pageCount == 1 && section->currentPage == 0) ||
|
||||
(section->pageCount >= 2 && section->currentPage == section->pageCount - 2)) &&
|
||||
currentSpineIndex + 1 < epub->getSpineItemsCount() && preIndexedNextSpine != currentSpineIndex + 1) {
|
||||
Section probe(epub, currentSpineIndex + 1, renderer);
|
||||
if (probe.loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle)) {
|
||||
preIndexedNextSpine = currentSpineIndex + 1;
|
||||
} else {
|
||||
silentIndexingActive = true;
|
||||
}
|
||||
}
|
||||
|
||||
const auto start = millis();
|
||||
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||
LOG_DBG("ERS", "Rendered page in %dms", millis() - start);
|
||||
renderer.clearFontCache();
|
||||
|
||||
if (silentIndexingActive) {
|
||||
silentIndexNextChapterIfNeeded(viewportWidth, viewportHeight);
|
||||
requestUpdate();
|
||||
}
|
||||
}
|
||||
saveProgress(currentSpineIndex, section->currentPage, section->pageCount);
|
||||
}
|
||||
|
||||
bool EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportWidth, const uint16_t viewportHeight) {
|
||||
if (preIndexedNextSpine == currentSpineIndex + 1) {
|
||||
silentIndexingActive = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
const bool shouldPreIndex = (section->pageCount == 1 && section->currentPage == 0) ||
|
||||
(section->pageCount >= 2 && section->currentPage == section->pageCount - 2);
|
||||
if (!epub || !section || !shouldPreIndex) {
|
||||
silentIndexingActive = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
const int nextSpineIndex = currentSpineIndex + 1;
|
||||
if (nextSpineIndex < 0 || nextSpineIndex >= epub->getSpineItemsCount()) {
|
||||
silentIndexingActive = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
Section nextSection(epub, nextSpineIndex, renderer);
|
||||
if (nextSection.loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle)) {
|
||||
preIndexedNextSpine = nextSpineIndex;
|
||||
silentIndexingActive = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
LOG_DBG("ERS", "Silently indexing next chapter: %d", nextSpineIndex);
|
||||
if (!nextSection.createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle)) {
|
||||
LOG_ERR("ERS", "Failed silent indexing for chapter: %d", nextSpineIndex);
|
||||
silentIndexingActive = false;
|
||||
return false;
|
||||
}
|
||||
preIndexedNextSpine = nextSpineIndex;
|
||||
silentIndexingActive = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageCount) {
|
||||
FsFile f;
|
||||
if (Storage.openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) {
|
||||
@@ -638,16 +1022,68 @@ 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) {
|
||||
// Force full refresh for pages with images when anti-aliasing is on,
|
||||
// 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 = page->hasImages() && SETTINGS.textAntiAliasing;
|
||||
bool forceFullRefresh = pageHasImages && useAntiAliasing;
|
||||
|
||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
|
||||
// Draw bookmark ribbon indicator in top-right corner if current page is bookmarked
|
||||
if (section && BookmarkStore::hasBookmark(epub->getCachePath(), currentSpineIndex, section->currentPage)) {
|
||||
const int screenWidth = renderer.getScreenWidth();
|
||||
const int bkWidth = 12;
|
||||
const int bkHeight = 22;
|
||||
const int bkX = screenWidth - orientedMarginRight - bkWidth + 2;
|
||||
const int bkY = 0;
|
||||
const int notchDepth = bkHeight / 3;
|
||||
const int centerX = bkX + bkWidth / 2;
|
||||
|
||||
const int xPoints[5] = {bkX, bkX + bkWidth, bkX + bkWidth, centerX, bkX};
|
||||
const int yPoints[5] = {bkY, bkY, bkY + bkHeight, bkY + bkHeight - notchDepth, bkY + bkHeight};
|
||||
renderer.fillPolygon(xPoints, yPoints, 5, true);
|
||||
}
|
||||
|
||||
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||
if (forceFullRefresh || pagesUntilFullRefresh <= 1) {
|
||||
|
||||
// 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--;
|
||||
}
|
||||
@@ -785,4 +1221,14 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in
|
||||
titleMarginLeftAdjusted + orientedMarginLeft + (availableTitleSpace - titleWidth) / 2, textY,
|
||||
title.c_str());
|
||||
}
|
||||
|
||||
if (silentIndexingActive && SETTINGS.statusBar != CrossPointSettings::STATUS_BAR_MODE::NONE) {
|
||||
const int batteryWidth = showBattery ? (showBatteryPercentage ? 50 : 20) : 0;
|
||||
const int indicatorX = orientedMarginLeft + batteryWidth + 8;
|
||||
if (SETTINGS.indexingDisplay == CrossPointSettings::INDEXING_DISPLAY::INDEXING_STATUS_TEXT) {
|
||||
renderer.drawText(SMALL_FONT_ID, indicatorX, textY, tr(STR_INDEXING));
|
||||
} else if (SETTINGS.indexingDisplay == CrossPointSettings::INDEXING_DISPLAY::INDEXING_STATUS_ICON) {
|
||||
renderer.drawIcon(kIndexingIcon, indicatorX, textY - kIndexingIconSize + 2, kIndexingIconSize, kIndexingIconSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
#include <Epub.h>
|
||||
#include <Epub/Section.h>
|
||||
|
||||
#include "DictionaryWordSelectActivity.h"
|
||||
#include "EpubReaderMenuActivity.h"
|
||||
#include "LookedUpWordsActivity.h"
|
||||
#include "activities/ActivityWithSubactivity.h"
|
||||
|
||||
class EpubReaderActivity final : public ActivityWithSubactivity {
|
||||
@@ -18,21 +20,30 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
|
||||
bool pendingPercentJump = false;
|
||||
// Normalized 0.0-1.0 progress within the target spine item, computed from book percentage.
|
||||
float pendingSpineProgress = 0.0f;
|
||||
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
|
||||
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
|
||||
bool ignoreNextConfirmRelease = false; // Suppress short-press after long-press Confirm
|
||||
volatile bool loadingSection = false; // True during the entire !section block (read from main loop)
|
||||
bool silentIndexingActive = false; // True while silently pre-indexing the next chapter
|
||||
int preIndexedNextSpine = -1; // Spine index already pre-indexed (prevents re-render loop)
|
||||
const std::function<void()> onGoBack;
|
||||
const std::function<void()> onGoHome;
|
||||
|
||||
void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight,
|
||||
int orientedMarginBottom, int orientedMarginLeft);
|
||||
void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const;
|
||||
bool silentIndexNextChapterIfNeeded(uint16_t viewportWidth, uint16_t viewportHeight);
|
||||
void saveProgress(int spineIndex, int currentPage, int pageCount);
|
||||
// Jump to a percentage of the book (0-100), mapping it to spine and page.
|
||||
void jumpToPercent(int percent);
|
||||
void onReaderMenuBack(uint8_t orientation);
|
||||
// Open the Table of Contents (chapter selection) as a subactivity.
|
||||
// Pass initialSkipRelease=true when triggered by long-press to consume the stale release.
|
||||
void openChapterSelection(bool initialSkipRelease = false);
|
||||
void onReaderMenuBack(uint8_t orientation, uint8_t fontSize);
|
||||
void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action);
|
||||
void applyOrientation(uint8_t orientation);
|
||||
void applyFontSize(uint8_t fontSize);
|
||||
|
||||
public:
|
||||
explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Epub> epub,
|
||||
@@ -45,4 +56,10 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(Activity::RenderLock&& lock) 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 render (including createSectionFile), during which section is non-null
|
||||
// but the section file is still being built.
|
||||
bool preventAutoSleep() override { return !section || loadingSection; }
|
||||
};
|
||||
|
||||
217
src/activities/reader/EpubReaderBookmarkSelectionActivity.cpp
Normal file
217
src/activities/reader/EpubReaderBookmarkSelectionActivity.cpp
Normal file
@@ -0,0 +1,217 @@
|
||||
#include "EpubReaderBookmarkSelectionActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
int EpubReaderBookmarkSelectionActivity::getTotalItems() const { return static_cast<int>(bookmarks.size()); }
|
||||
|
||||
int EpubReaderBookmarkSelectionActivity::getPageItems() const {
|
||||
constexpr int lineHeight = 30;
|
||||
|
||||
const int screenHeight = renderer.getScreenHeight();
|
||||
const auto orientation = renderer.getOrientation();
|
||||
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
|
||||
const int hintGutterHeight = isPortraitInverted ? 50 : 0;
|
||||
const int startY = 60 + hintGutterHeight;
|
||||
const int availableHeight = screenHeight - startY - lineHeight;
|
||||
return std::max(1, availableHeight / lineHeight);
|
||||
}
|
||||
|
||||
std::string EpubReaderBookmarkSelectionActivity::getBookmarkPrefix(const Bookmark& bookmark) const {
|
||||
std::string label;
|
||||
if (epub) {
|
||||
const int tocIndex = epub->getTocIndexForSpineIndex(bookmark.spineIndex);
|
||||
if (tocIndex >= 0 && tocIndex < epub->getTocItemsCount()) {
|
||||
label = epub->getTocItem(tocIndex).title;
|
||||
} else {
|
||||
label = "Chapter " + std::to_string(bookmark.spineIndex + 1);
|
||||
}
|
||||
} else {
|
||||
label = "Chapter " + std::to_string(bookmark.spineIndex + 1);
|
||||
}
|
||||
if (!bookmark.snippet.empty()) {
|
||||
label += " - " + bookmark.snippet;
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
std::string EpubReaderBookmarkSelectionActivity::getPageSuffix(const Bookmark& bookmark) {
|
||||
return " - Page " + std::to_string(bookmark.pageNumber + 1);
|
||||
}
|
||||
|
||||
void EpubReaderBookmarkSelectionActivity::onEnter() {
|
||||
ActivityWithSubactivity::onEnter();
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void EpubReaderBookmarkSelectionActivity::onExit() { ActivityWithSubactivity::onExit(); }
|
||||
|
||||
void EpubReaderBookmarkSelectionActivity::loop() {
|
||||
if (subActivity) {
|
||||
subActivity->loop();
|
||||
return;
|
||||
}
|
||||
|
||||
const int totalItems = getTotalItems();
|
||||
|
||||
if (totalItems == 0) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
onGoBack();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteConfirmMode) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (ignoreNextConfirmRelease) {
|
||||
ignoreNextConfirmRelease = false;
|
||||
} else {
|
||||
BookmarkStore::removeBookmark(cachePath, bookmarks[pendingDeleteIndex].spineIndex,
|
||||
bookmarks[pendingDeleteIndex].pageNumber);
|
||||
bookmarks.erase(bookmarks.begin() + pendingDeleteIndex);
|
||||
if (selectorIndex >= static_cast<int>(bookmarks.size())) {
|
||||
selectorIndex = std::max(0, static_cast<int>(bookmarks.size()) - 1);
|
||||
}
|
||||
deleteConfirmMode = false;
|
||||
requestUpdate();
|
||||
}
|
||||
}
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
deleteConfirmMode = false;
|
||||
ignoreNextConfirmRelease = false;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
constexpr unsigned long DELETE_HOLD_MS = 700;
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= DELETE_HOLD_MS) {
|
||||
if (totalItems > 0 && selectorIndex >= 0 && selectorIndex < totalItems) {
|
||||
deleteConfirmMode = true;
|
||||
ignoreNextConfirmRelease = true;
|
||||
pendingDeleteIndex = selectorIndex;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const int pageItems = getPageItems();
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (selectorIndex >= 0 && selectorIndex < totalItems) {
|
||||
const auto& b = bookmarks[selectorIndex];
|
||||
onSelectBookmark(b.spineIndex, b.pageNumber);
|
||||
} else {
|
||||
onGoBack();
|
||||
}
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
onGoBack();
|
||||
}
|
||||
|
||||
buttonNavigator.onNextRelease([this, totalItems] {
|
||||
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPreviousRelease([this, totalItems] {
|
||||
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onNextContinuous([this, totalItems, pageItems] {
|
||||
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] {
|
||||
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems);
|
||||
requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
void EpubReaderBookmarkSelectionActivity::render(Activity::RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto orientation = renderer.getOrientation();
|
||||
const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise;
|
||||
const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise;
|
||||
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
|
||||
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? 30 : 0;
|
||||
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
|
||||
const int contentWidth = pageWidth - hintGutterWidth;
|
||||
const int hintGutterHeight = isPortraitInverted ? 50 : 0;
|
||||
const int contentY = hintGutterHeight;
|
||||
const int pageItems = getPageItems();
|
||||
const int totalItems = getTotalItems();
|
||||
|
||||
const int titleX =
|
||||
contentX + (contentWidth - renderer.getTextWidth(UI_12_FONT_ID, "Go to Bookmark", EpdFontFamily::BOLD)) / 2;
|
||||
renderer.drawText(UI_12_FONT_ID, titleX, 15 + contentY, "Go to Bookmark", true, EpdFontFamily::BOLD);
|
||||
|
||||
if (totalItems == 0) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, 100 + contentY, "No bookmarks", true);
|
||||
} else {
|
||||
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
|
||||
renderer.fillRect(contentX, 60 + contentY + (selectorIndex % pageItems) * 30 - 2, contentWidth - 1, 30);
|
||||
|
||||
const int maxLabelWidth = contentWidth - 40 - contentX - 20;
|
||||
|
||||
for (int i = 0; i < pageItems; i++) {
|
||||
int itemIndex = pageStartIndex + i;
|
||||
if (itemIndex >= totalItems) break;
|
||||
const int displayY = 60 + contentY + i * 30;
|
||||
const bool isSelected = (itemIndex == selectorIndex);
|
||||
|
||||
const std::string suffix = getPageSuffix(bookmarks[itemIndex]);
|
||||
const int suffixWidth = renderer.getTextWidth(UI_10_FONT_ID, suffix.c_str());
|
||||
|
||||
const std::string prefix = getBookmarkPrefix(bookmarks[itemIndex]);
|
||||
const std::string truncatedPrefix =
|
||||
renderer.truncatedText(UI_10_FONT_ID, prefix.c_str(), maxLabelWidth - suffixWidth);
|
||||
|
||||
const std::string label = truncatedPrefix + suffix;
|
||||
|
||||
renderer.drawText(UI_10_FONT_ID, contentX + 20, displayY, label.c_str(), !isSelected);
|
||||
}
|
||||
}
|
||||
|
||||
if (deleteConfirmMode && pendingDeleteIndex < static_cast<int>(bookmarks.size())) {
|
||||
const std::string suffix = getPageSuffix(bookmarks[pendingDeleteIndex]);
|
||||
std::string msg = "Delete bookmark" + suffix + "?";
|
||||
|
||||
constexpr int margin = 15;
|
||||
constexpr int popupY = 200;
|
||||
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;
|
||||
|
||||
renderer.fillRect(x - 2, popupY - 2, w + 4, h + 4, true);
|
||||
renderer.fillRect(x, popupY, w, h, false);
|
||||
|
||||
const int textX = x + (w - textWidth) / 2;
|
||||
const int textY = popupY + margin - 2;
|
||||
renderer.drawText(UI_12_FONT_ID, textX, textY, msg.c_str(), true, EpdFontFamily::BOLD);
|
||||
|
||||
const auto labels = mappedInput.mapLabels("Cancel", "Delete", "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
} else {
|
||||
if (!bookmarks.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,
|
||||
deleteHint);
|
||||
}
|
||||
|
||||
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", "Up", "Down");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
}
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
43
src/activities/reader/EpubReaderBookmarkSelectionActivity.h
Normal file
43
src/activities/reader/EpubReaderBookmarkSelectionActivity.h
Normal file
@@ -0,0 +1,43 @@
|
||||
#pragma once
|
||||
#include <Epub.h>
|
||||
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include "../ActivityWithSubactivity.h"
|
||||
#include "util/BookmarkStore.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
class EpubReaderBookmarkSelectionActivity final : public ActivityWithSubactivity {
|
||||
std::shared_ptr<Epub> epub;
|
||||
std::vector<Bookmark> bookmarks;
|
||||
std::string cachePath;
|
||||
ButtonNavigator buttonNavigator;
|
||||
int selectorIndex = 0;
|
||||
bool deleteConfirmMode = false;
|
||||
bool ignoreNextConfirmRelease = false;
|
||||
int pendingDeleteIndex = 0;
|
||||
const std::function<void()> onGoBack;
|
||||
const std::function<void(int newSpineIndex, int newPage)> onSelectBookmark;
|
||||
|
||||
int getPageItems() const;
|
||||
int getTotalItems() const;
|
||||
std::string getBookmarkPrefix(const Bookmark& bookmark) const;
|
||||
static std::string getPageSuffix(const Bookmark& bookmark);
|
||||
|
||||
public:
|
||||
explicit EpubReaderBookmarkSelectionActivity(
|
||||
GfxRenderer& renderer, MappedInputManager& mappedInput, const std::shared_ptr<Epub>& epub,
|
||||
std::vector<Bookmark> bookmarks, const std::string& cachePath, const std::function<void()>& onGoBack,
|
||||
const std::function<void(int newSpineIndex, int newPage)>& onSelectBookmark)
|
||||
: ActivityWithSubactivity("EpubReaderBookmarkSelection", renderer, mappedInput),
|
||||
epub(epub),
|
||||
bookmarks(std::move(bookmarks)),
|
||||
cachePath(cachePath),
|
||||
onGoBack(onGoBack),
|
||||
onSelectBookmark(onSelectBookmark) {}
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(Activity::RenderLock&&) override;
|
||||
};
|
||||
@@ -53,11 +53,15 @@ void EpubReaderChapterSelectionActivity::loop() {
|
||||
const int totalItems = getTotalItems();
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
const auto newSpineIndex = epub->getSpineIndexForTocIndex(selectorIndex);
|
||||
if (newSpineIndex == -1) {
|
||||
onGoBack();
|
||||
if (ignoreNextConfirmRelease) {
|
||||
ignoreNextConfirmRelease = false;
|
||||
} else {
|
||||
onSelectSpineIndex(newSpineIndex);
|
||||
const auto newSpineIndex = epub->getSpineIndexForTocIndex(selectorIndex);
|
||||
if (newSpineIndex == -1) {
|
||||
onGoBack();
|
||||
} else {
|
||||
onSelectSpineIndex(newSpineIndex);
|
||||
}
|
||||
}
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
onGoBack();
|
||||
|
||||
@@ -14,6 +14,7 @@ class EpubReaderChapterSelectionActivity final : public ActivityWithSubactivity
|
||||
int currentPage = 0;
|
||||
int totalPagesInSpine = 0;
|
||||
int selectorIndex = 0;
|
||||
bool ignoreNextConfirmRelease = false;
|
||||
|
||||
const std::function<void()> onGoBack;
|
||||
const std::function<void(int newSpineIndex)> onSelectSpineIndex;
|
||||
@@ -32,13 +33,15 @@ class EpubReaderChapterSelectionActivity final : public ActivityWithSubactivity
|
||||
const int currentSpineIndex, const int currentPage,
|
||||
const int totalPagesInSpine, const std::function<void()>& onGoBack,
|
||||
const std::function<void(int newSpineIndex)>& onSelectSpineIndex,
|
||||
const std::function<void(int newSpineIndex, int newPage)>& onSyncPosition)
|
||||
const std::function<void(int newSpineIndex, int newPage)>& onSyncPosition,
|
||||
bool initialSkipRelease = false)
|
||||
: ActivityWithSubactivity("EpubReaderChapterSelection", renderer, mappedInput),
|
||||
epub(epub),
|
||||
epubPath(epubPath),
|
||||
currentSpineIndex(currentSpineIndex),
|
||||
currentPage(currentPage),
|
||||
totalPagesInSpine(totalPagesInSpine),
|
||||
ignoreNextConfirmRelease(initialSkipRelease),
|
||||
onGoBack(onGoBack),
|
||||
onSelectSpineIndex(onSelectSpineIndex),
|
||||
onSyncPosition(onSyncPosition) {}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
@@ -20,6 +21,55 @@ void EpubReaderMenuActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Orientation sub-menu mode ---
|
||||
if (orientationSelectMode) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (ignoreNextConfirmRelease) {
|
||||
ignoreNextConfirmRelease = false;
|
||||
} else {
|
||||
pendingOrientation = static_cast<uint8_t>(orientationSelectIndex);
|
||||
orientationSelectMode = false;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
orientationSelectMode = false;
|
||||
ignoreNextConfirmRelease = false;
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
buttonNavigator.onNext([this] {
|
||||
orientationSelectIndex =
|
||||
ButtonNavigator::nextIndex(orientationSelectIndex, static_cast<int>(orientationLabels.size()));
|
||||
requestUpdate();
|
||||
});
|
||||
buttonNavigator.onPrevious([this] {
|
||||
orientationSelectIndex =
|
||||
ButtonNavigator::previousIndex(orientationSelectIndex, static_cast<int>(orientationLabels.size()));
|
||||
requestUpdate();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Long-press detection (before release checks) ---
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= LONG_PRESS_MS) {
|
||||
const auto selectedAction = menuItems[selectedIndex].action;
|
||||
if (selectedAction == MenuAction::LOOKUP) {
|
||||
ignoreNextConfirmRelease = true;
|
||||
auto cb = onAction;
|
||||
cb(MenuAction::LOOKED_UP_WORDS);
|
||||
return;
|
||||
}
|
||||
if (selectedAction == MenuAction::ROTATE_SCREEN) {
|
||||
orientationSelectMode = true;
|
||||
ignoreNextConfirmRelease = true;
|
||||
orientationSelectIndex = pendingOrientation;
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle navigation
|
||||
buttonNavigator.onNext([this] {
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(menuItems.size()));
|
||||
@@ -31,12 +81,37 @@ void EpubReaderMenuActivity::loop() {
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
// Use local variables for items we need to check after potential deletion
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (ignoreNextConfirmRelease) {
|
||||
ignoreNextConfirmRelease = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const auto selectedAction = menuItems[selectedIndex].action;
|
||||
if (selectedAction == MenuAction::ROTATE_SCREEN) {
|
||||
// Cycle orientation preview locally; actual rotation happens on menu exit.
|
||||
pendingOrientation = (pendingOrientation + 1) % orientationLabels.size();
|
||||
// Toggle between the two preferred orientations.
|
||||
// If currently in a portrait-category orientation (Portrait/Inverted), switch to preferredLandscape.
|
||||
// If currently in a landscape-category orientation (CW/CCW), switch to preferredPortrait.
|
||||
const bool isCurrentlyPortrait =
|
||||
(pendingOrientation == CrossPointSettings::PORTRAIT || pendingOrientation == CrossPointSettings::INVERTED);
|
||||
if (isCurrentlyPortrait) {
|
||||
pendingOrientation = SETTINGS.preferredLandscape;
|
||||
} else {
|
||||
pendingOrientation = SETTINGS.preferredPortrait;
|
||||
}
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (selectedAction == MenuAction::TOGGLE_FONT_SIZE) {
|
||||
pendingFontSize = (pendingFontSize + 1) % CrossPointSettings::FONT_SIZE_COUNT;
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (selectedAction == MenuAction::LETTERBOX_FILL) {
|
||||
// Cycle through: Default -> Dithered -> Solid -> None -> Default ...
|
||||
int idx = (letterboxFillToIndex() + 1) % LETTERBOX_FILL_OPTION_COUNT;
|
||||
pendingLetterboxFill = indexToLetterboxFill(idx);
|
||||
saveLetterboxFill();
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
@@ -50,9 +125,9 @@ void EpubReaderMenuActivity::loop() {
|
||||
// 3. CRITICAL: Return immediately. 'this' is likely deleted now.
|
||||
return;
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
// Return the pending orientation to the parent so it can apply on exit.
|
||||
onBack(pendingOrientation);
|
||||
return; // Also return here just in case
|
||||
// Return the pending orientation and font size to the parent so it can apply on exit.
|
||||
onBack(pendingOrientation, pendingFontSize);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +187,41 @@ void EpubReaderMenuActivity::render(Activity::RenderLock&&) {
|
||||
const auto width = renderer.getTextWidth(UI_10_FONT_ID, value);
|
||||
renderer.drawText(UI_10_FONT_ID, contentX + contentWidth - 20 - width, displayY, value, !isSelected);
|
||||
}
|
||||
if (menuItems[i].action == MenuAction::TOGGLE_FONT_SIZE) {
|
||||
const char* value = I18N.get(fontSizeLabels[pendingFontSize]);
|
||||
const auto width = renderer.getTextWidth(UI_10_FONT_ID, value);
|
||||
renderer.drawText(UI_10_FONT_ID, contentX + contentWidth - 20 - width, displayY, value, !isSelected);
|
||||
}
|
||||
if (menuItems[i].action == MenuAction::LETTERBOX_FILL) {
|
||||
// Render current letterbox fill value on the right edge of the content area.
|
||||
const char* value = I18N.get(letterboxFillLabels[letterboxFillToIndex()]);
|
||||
const auto width = renderer.getTextWidth(UI_10_FONT_ID, value);
|
||||
renderer.drawText(UI_10_FONT_ID, contentX + contentWidth - 20 - width, displayY, value, !isSelected);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Orientation sub-menu overlay ---
|
||||
if (orientationSelectMode) {
|
||||
constexpr int popupMargin = 15;
|
||||
constexpr int popupLineHeight = 28;
|
||||
const int optionCount = static_cast<int>(orientationLabels.size());
|
||||
const int popupH = popupMargin * 2 + popupLineHeight * optionCount;
|
||||
const int popupW = contentWidth - 60;
|
||||
const int popupX = contentX + (contentWidth - popupW) / 2;
|
||||
const int popupY = 180 + hintGutterHeight;
|
||||
|
||||
renderer.fillRect(popupX - 2, popupY - 2, popupW + 4, popupH + 4, true);
|
||||
renderer.fillRect(popupX, popupY, popupW, popupH, false);
|
||||
|
||||
for (int i = 0; i < optionCount; ++i) {
|
||||
const int optY = popupY + popupMargin + i * popupLineHeight;
|
||||
const bool isSel = (i == orientationSelectIndex);
|
||||
if (isSel) {
|
||||
renderer.fillRect(popupX + 2, optY, popupW - 4, popupLineHeight, true);
|
||||
}
|
||||
const char* label = I18N.get(orientationLabels[i]);
|
||||
renderer.drawText(UI_10_FONT_ID, popupX + popupMargin, optY, label, !isSel);
|
||||
}
|
||||
}
|
||||
|
||||
// Footer / Hints
|
||||
|
||||
@@ -7,25 +7,50 @@
|
||||
#include <vector>
|
||||
|
||||
#include "../ActivityWithSubactivity.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "util/BookSettings.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
class EpubReaderMenuActivity final : public ActivityWithSubactivity {
|
||||
public:
|
||||
// Menu actions available from the reader menu.
|
||||
enum class MenuAction { SELECT_CHAPTER, GO_TO_PERCENT, ROTATE_SCREEN, GO_HOME, SYNC, DELETE_CACHE };
|
||||
enum class MenuAction {
|
||||
ADD_BOOKMARK,
|
||||
REMOVE_BOOKMARK,
|
||||
LOOKUP,
|
||||
LOOKED_UP_WORDS,
|
||||
ROTATE_SCREEN,
|
||||
TOGGLE_FONT_SIZE,
|
||||
LETTERBOX_FILL,
|
||||
SELECT_CHAPTER,
|
||||
GO_TO_BOOKMARK,
|
||||
GO_TO_PERCENT,
|
||||
GO_HOME,
|
||||
SYNC,
|
||||
DELETE_CACHE,
|
||||
};
|
||||
|
||||
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
|
||||
const int currentPage, const int totalPages, const int bookProgressPercent,
|
||||
const uint8_t currentOrientation, const std::function<void(uint8_t)>& onBack,
|
||||
const uint8_t currentOrientation, const uint8_t currentFontSize,
|
||||
const bool hasDictionary, const bool isBookmarked, const std::string& bookCachePath,
|
||||
const std::function<void(uint8_t, uint8_t)>& onBack,
|
||||
const std::function<void(MenuAction)>& onAction)
|
||||
: ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput),
|
||||
menuItems(buildMenuItems(hasDictionary, isBookmarked)),
|
||||
title(title),
|
||||
pendingOrientation(currentOrientation),
|
||||
pendingFontSize(currentFontSize),
|
||||
bookCachePath(bookCachePath),
|
||||
currentPage(currentPage),
|
||||
totalPages(totalPages),
|
||||
bookProgressPercent(bookProgressPercent),
|
||||
onBack(onBack),
|
||||
onAction(onAction) {}
|
||||
onAction(onAction) {
|
||||
// Load per-book settings to initialize the letterbox fill override
|
||||
auto bookSettings = BookSettings::load(bookCachePath);
|
||||
pendingLetterboxFill = bookSettings.letterboxFillOverride;
|
||||
}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
@@ -38,25 +63,76 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
|
||||
StrId labelId;
|
||||
};
|
||||
|
||||
// Fixed menu layout (order matters for up/down navigation).
|
||||
const std::vector<MenuItem> menuItems = {{MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER},
|
||||
{MenuAction::ROTATE_SCREEN, StrId::STR_ORIENTATION},
|
||||
{MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT},
|
||||
{MenuAction::GO_HOME, StrId::STR_GO_HOME_BUTTON},
|
||||
{MenuAction::SYNC, StrId::STR_SYNC_PROGRESS},
|
||||
{MenuAction::DELETE_CACHE, StrId::STR_DELETE_CACHE}};
|
||||
std::vector<MenuItem> menuItems;
|
||||
|
||||
int selectedIndex = 0;
|
||||
|
||||
ButtonNavigator buttonNavigator;
|
||||
std::string title = "Reader Menu";
|
||||
uint8_t pendingOrientation = 0;
|
||||
uint8_t pendingFontSize = 0;
|
||||
const std::vector<StrId> orientationLabels = {StrId::STR_PORTRAIT, StrId::STR_LANDSCAPE_CW, StrId::STR_INVERTED,
|
||||
StrId::STR_LANDSCAPE_CCW};
|
||||
const std::vector<StrId> fontSizeLabels = {StrId::STR_SMALL, StrId::STR_MEDIUM, StrId::STR_LARGE, StrId::STR_X_LARGE};
|
||||
std::string bookCachePath;
|
||||
// Letterbox fill override: 0xFF = Default (use global), 0 = Dithered, 1 = Solid, 2 = None
|
||||
uint8_t pendingLetterboxFill = BookSettings::USE_GLOBAL;
|
||||
static constexpr int LETTERBOX_FILL_OPTION_COUNT = 4; // Default + 3 modes
|
||||
const std::vector<StrId> letterboxFillLabels = {StrId::STR_DEFAULT_OPTION, StrId::STR_DITHERED, StrId::STR_SOLID,
|
||||
StrId::STR_NONE_OPT};
|
||||
int currentPage = 0;
|
||||
int totalPages = 0;
|
||||
int bookProgressPercent = 0;
|
||||
|
||||
const std::function<void(uint8_t)> onBack;
|
||||
// Long-press state
|
||||
bool ignoreNextConfirmRelease = false;
|
||||
static constexpr unsigned long LONG_PRESS_MS = 700;
|
||||
|
||||
// Orientation sub-menu state (entered via long-press on Toggle Portrait/Landscape)
|
||||
bool orientationSelectMode = false;
|
||||
int orientationSelectIndex = 0;
|
||||
|
||||
const std::function<void(uint8_t, uint8_t)> onBack;
|
||||
const std::function<void(MenuAction)> onAction;
|
||||
|
||||
// Map the internal override value to an index into letterboxFillLabels.
|
||||
int letterboxFillToIndex() const {
|
||||
if (pendingLetterboxFill == BookSettings::USE_GLOBAL) return 0; // "Default"
|
||||
return pendingLetterboxFill + 1; // 0->1 (Dithered), 1->2 (Solid), 2->3 (None)
|
||||
}
|
||||
|
||||
// Map an index from letterboxFillLabels back to an override value.
|
||||
static uint8_t indexToLetterboxFill(int index) {
|
||||
if (index == 0) return BookSettings::USE_GLOBAL;
|
||||
return static_cast<uint8_t>(index - 1);
|
||||
}
|
||||
|
||||
// Save the current letterbox fill override to the book's settings file.
|
||||
void saveLetterboxFill() const {
|
||||
auto bookSettings = BookSettings::load(bookCachePath);
|
||||
bookSettings.letterboxFillOverride = pendingLetterboxFill;
|
||||
BookSettings::save(bookCachePath, bookSettings);
|
||||
}
|
||||
|
||||
static std::vector<MenuItem> buildMenuItems(bool hasDictionary, bool isBookmarked) {
|
||||
std::vector<MenuItem> items;
|
||||
if (isBookmarked) {
|
||||
items.push_back({MenuAction::REMOVE_BOOKMARK, StrId::STR_REMOVE_BOOKMARK});
|
||||
} else {
|
||||
items.push_back({MenuAction::ADD_BOOKMARK, StrId::STR_ADD_BOOKMARK});
|
||||
}
|
||||
if (hasDictionary) {
|
||||
items.push_back({MenuAction::LOOKUP, StrId::STR_LOOKUP_WORD});
|
||||
}
|
||||
items.push_back({MenuAction::ROTATE_SCREEN, StrId::STR_TOGGLE_ORIENTATION});
|
||||
items.push_back({MenuAction::TOGGLE_FONT_SIZE, StrId::STR_TOGGLE_FONT_SIZE});
|
||||
items.push_back({MenuAction::LETTERBOX_FILL, StrId::STR_OVERRIDE_LETTERBOX_FILL});
|
||||
items.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_TABLE_OF_CONTENTS});
|
||||
items.push_back({MenuAction::GO_TO_BOOKMARK, StrId::STR_GO_TO_BOOKMARK});
|
||||
items.push_back({MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT});
|
||||
items.push_back({MenuAction::GO_HOME, StrId::STR_CLOSE_BOOK});
|
||||
items.push_back({MenuAction::SYNC, StrId::STR_SYNC_PROGRESS});
|
||||
items.push_back({MenuAction::DELETE_CACHE, StrId::STR_DELETE_CACHE});
|
||||
return items;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
#include <I18n.h>
|
||||
#include <Logging.h>
|
||||
#include <WiFi.h>
|
||||
#include <esp_sntp.h>
|
||||
|
||||
#include "KOReaderCredentialStore.h"
|
||||
#include "KOReaderDocumentId.h"
|
||||
@@ -12,34 +11,7 @@
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
void syncTimeWithNTP() {
|
||||
// Stop SNTP if already running (can't reconfigure while running)
|
||||
if (esp_sntp_enabled()) {
|
||||
esp_sntp_stop();
|
||||
}
|
||||
|
||||
// Configure SNTP
|
||||
esp_sntp_setoperatingmode(ESP_SNTP_OPMODE_POLL);
|
||||
esp_sntp_setservername(0, "pool.ntp.org");
|
||||
esp_sntp_init();
|
||||
|
||||
// Wait for time to sync (with timeout)
|
||||
int retry = 0;
|
||||
const int maxRetries = 50; // 5 seconds max
|
||||
while (sntp_get_sync_status() != SNTP_SYNC_STATUS_COMPLETED && retry < maxRetries) {
|
||||
vTaskDelay(100 / portTICK_PERIOD_MS);
|
||||
retry++;
|
||||
}
|
||||
|
||||
if (retry < maxRetries) {
|
||||
LOG_DBG("KOSync", "NTP time synced");
|
||||
} else {
|
||||
LOG_DBG("KOSync", "NTP sync timeout, using fallback");
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
#include "util/TimeSync.h"
|
||||
|
||||
void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) {
|
||||
exitActivity();
|
||||
@@ -59,8 +31,8 @@ void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) {
|
||||
}
|
||||
requestUpdate();
|
||||
|
||||
// Sync time with NTP before making API requests
|
||||
syncTimeWithNTP();
|
||||
// Wait for NTP sync before making API requests (blocks up to 5s)
|
||||
TimeSync::waitForNtpSync();
|
||||
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
@@ -205,8 +177,8 @@ void KOReaderSyncActivity::onEnter() {
|
||||
xTaskCreate(
|
||||
[](void* param) {
|
||||
auto* self = static_cast<KOReaderSyncActivity*>(param);
|
||||
// Sync time first
|
||||
syncTimeWithNTP();
|
||||
// Wait for NTP sync before making API requests
|
||||
TimeSync::waitForNtpSync();
|
||||
{
|
||||
RenderLock lock(*self);
|
||||
self->statusMessage = tr(STR_CALC_HASH);
|
||||
|
||||
290
src/activities/reader/LookedUpWordsActivity.cpp
Normal file
290
src/activities/reader/LookedUpWordsActivity.cpp
Normal file
@@ -0,0 +1,290 @@
|
||||
#include "LookedUpWordsActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#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::onEnter() {
|
||||
ActivityWithSubactivity::onEnter();
|
||||
words = LookupHistory::load(cachePath);
|
||||
std::reverse(words.begin(), words.end());
|
||||
// Append the "Delete Dictionary Cache" sentinel entry
|
||||
words.push_back("\xE2\x80\x94 " + std::string(tr(STR_DELETE_DICT_CACHE)));
|
||||
deleteDictCacheIndex = static_cast<int>(words.size()) - 1;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void LookedUpWordsActivity::onExit() { ActivityWithSubactivity::onExit(); }
|
||||
|
||||
void LookedUpWordsActivity::loop() {
|
||||
if (subActivity) {
|
||||
subActivity->loop();
|
||||
if (pendingBackFromDef) {
|
||||
pendingBackFromDef = false;
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
}
|
||||
if (pendingExitToReader) {
|
||||
pendingExitToReader = false;
|
||||
exitActivity();
|
||||
onDone();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Empty list has only the sentinel entry; if even that's gone, just go back.
|
||||
if (words.empty()) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
onBack();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete confirmation mode: wait for confirm (delete) or back (cancel)
|
||||
if (deleteConfirmMode) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (ignoreNextConfirmRelease) {
|
||||
// Ignore the release from the initial long press
|
||||
ignoreNextConfirmRelease = false;
|
||||
} else {
|
||||
// Confirm delete
|
||||
LookupHistory::removeWord(cachePath, words[pendingDeleteIndex]);
|
||||
words.erase(words.begin() + pendingDeleteIndex);
|
||||
// Adjust sentinel index since we removed an item before it
|
||||
if (deleteDictCacheIndex > pendingDeleteIndex) {
|
||||
deleteDictCacheIndex--;
|
||||
}
|
||||
if (selectedIndex >= static_cast<int>(words.size())) {
|
||||
selectedIndex = std::max(0, static_cast<int>(words.size()) - 1);
|
||||
}
|
||||
deleteConfirmMode = false;
|
||||
requestUpdate();
|
||||
}
|
||||
}
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
deleteConfirmMode = false;
|
||||
ignoreNextConfirmRelease = false;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect long press on Confirm to trigger delete (only for real word entries, not sentinel)
|
||||
constexpr unsigned long DELETE_HOLD_MS = 700;
|
||||
if (selectedIndex != deleteDictCacheIndex && mappedInput.isPressed(MappedInputManager::Button::Confirm) &&
|
||||
mappedInput.getHeldTime() >= DELETE_HOLD_MS) {
|
||||
deleteConfirmMode = true;
|
||||
ignoreNextConfirmRelease = true;
|
||||
pendingDeleteIndex = selectedIndex;
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
const int totalItems = static_cast<int>(words.size());
|
||||
const int pageItems = getPageItems();
|
||||
|
||||
buttonNavigator.onNextRelease([this, totalItems] {
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, totalItems);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPreviousRelease([this, totalItems] {
|
||||
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, totalItems);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onNextContinuous([this, totalItems, pageItems] {
|
||||
selectedIndex = ButtonNavigator::nextPageIndex(selectedIndex, totalItems, pageItems);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] {
|
||||
selectedIndex = ButtonNavigator::previousPageIndex(selectedIndex, totalItems, pageItems);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
// Consume stale release from long-press navigation into this activity
|
||||
if (ignoreNextConfirmRelease) {
|
||||
ignoreNextConfirmRelease = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle the "Delete Dictionary Cache" sentinel entry
|
||||
if (selectedIndex == deleteDictCacheIndex) {
|
||||
if (Dictionary::cacheExists()) {
|
||||
Dictionary::deleteCache();
|
||||
{
|
||||
Activity::RenderLock lock(*this);
|
||||
GUI.drawPopup(renderer, tr(STR_DICT_CACHE_DELETED));
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
}
|
||||
} else {
|
||||
{
|
||||
Activity::RenderLock lock(*this);
|
||||
GUI.drawPopup(renderer, tr(STR_NO_CACHE_TO_DELETE));
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
}
|
||||
}
|
||||
vTaskDelay(1500 / portTICK_PERIOD_MS);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string& headword = words[selectedIndex];
|
||||
|
||||
Rect popupLayout;
|
||||
{
|
||||
Activity::RenderLock lock(*this);
|
||||
popupLayout = GUI.drawPopup(renderer, "Looking up...");
|
||||
}
|
||||
std::string definition = Dictionary::lookup(headword, [this, &popupLayout](int percent) {
|
||||
Activity::RenderLock lock(*this);
|
||||
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;
|
||||
}
|
||||
|
||||
{
|
||||
Activity::RenderLock lock(*this);
|
||||
GUI.drawPopup(renderer, "Not found");
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
}
|
||||
vTaskDelay(1500 / portTICK_PERIOD_MS);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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::render(Activity::RenderLock&&) {
|
||||
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 pageWidth = renderer.getScreenWidth();
|
||||
const int pageHeight = renderer.getScreenHeight();
|
||||
|
||||
// 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;
|
||||
|
||||
// The list always has at least the sentinel entry
|
||||
const bool hasRealWords = (deleteDictCacheIndex > 0);
|
||||
|
||||
if (words.empty()) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, contentTop + 20, "No words looked up yet");
|
||||
} else {
|
||||
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())) {
|
||||
// Draw delete confirmation overlay
|
||||
const std::string& word = words[pendingDeleteIndex];
|
||||
std::string displayWord = word;
|
||||
if (displayWord.size() > 20) {
|
||||
displayWord.erase(17);
|
||||
displayWord += "...";
|
||||
}
|
||||
std::string msg = "Delete '" + displayWord + "'?";
|
||||
|
||||
constexpr int margin = 15;
|
||||
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 = contentX + (renderer.getScreenWidth() - hintGutterWidth - w) / 2;
|
||||
|
||||
renderer.fillRect(x - 2, popupY - 2, w + 4, h + 4, true);
|
||||
renderer.fillRect(x, popupY, w, h, false);
|
||||
|
||||
const int textX = x + (w - textWidth) / 2;
|
||||
const int textY = popupY + margin - 2;
|
||||
renderer.drawText(UI_12_FONT_ID, textX, textY, msg.c_str(), true, EpdFontFamily::BOLD);
|
||||
|
||||
// Button hints for delete mode
|
||||
const auto labels = mappedInput.mapLabels("Cancel", "Delete", "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
} else {
|
||||
// "Hold select to delete" hint above button hints (only when real words exist)
|
||||
if (hasRealWords) {
|
||||
const char* deleteHint = "Hold select to delete";
|
||||
const int hintWidth = renderer.getTextWidth(SMALL_FONT_ID, deleteHint);
|
||||
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", "Up", "Down");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
}
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
50
src/activities/reader/LookedUpWordsActivity.h
Normal file
50
src/activities/reader/LookedUpWordsActivity.h
Normal file
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../ActivityWithSubactivity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
class LookedUpWordsActivity final : public ActivityWithSubactivity {
|
||||
public:
|
||||
explicit LookedUpWordsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& cachePath,
|
||||
int readerFontId, uint8_t orientation, const std::function<void()>& onBack,
|
||||
const std::function<void()>& onDone, bool initialSkipRelease = false)
|
||||
: ActivityWithSubactivity("LookedUpWords", renderer, mappedInput),
|
||||
cachePath(cachePath),
|
||||
readerFontId(readerFontId),
|
||||
orientation(orientation),
|
||||
onBack(onBack),
|
||||
onDone(onDone),
|
||||
ignoreNextConfirmRelease(initialSkipRelease) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(Activity::RenderLock&&) override;
|
||||
|
||||
private:
|
||||
std::string cachePath;
|
||||
int readerFontId;
|
||||
uint8_t orientation;
|
||||
const std::function<void()> onBack;
|
||||
const std::function<void()> onDone;
|
||||
|
||||
std::vector<std::string> words;
|
||||
int selectedIndex = 0;
|
||||
bool pendingBackFromDef = false;
|
||||
bool pendingExitToReader = false;
|
||||
ButtonNavigator buttonNavigator;
|
||||
|
||||
// Delete confirmation state
|
||||
bool deleteConfirmMode = false;
|
||||
bool ignoreNextConfirmRelease = false;
|
||||
int pendingDeleteIndex = 0;
|
||||
|
||||
// Sentinel index: the "Delete Dictionary Cache" entry at the end of the list.
|
||||
// -1 if not present (shouldn't happen when dictionary exists).
|
||||
int deleteDictCacheIndex = -1;
|
||||
|
||||
int getPageItems() const;
|
||||
};
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
#include <PlaceholderCoverGenerator.h>
|
||||
#include <Serialization.h>
|
||||
#include <Utf8.h>
|
||||
|
||||
@@ -51,12 +52,51 @@ void TxtReaderActivity::onEnter() {
|
||||
|
||||
txt->setupCacheDir();
|
||||
|
||||
// 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
|
||||
auto filePath = txt->getPath();
|
||||
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
|
||||
requestUpdate();
|
||||
|
||||
@@ -51,4 +51,7 @@ class TxtReaderActivity final : public ActivityWithSubactivity {
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(Activity::RenderLock&&) 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,7 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
#include <PlaceholderCoverGenerator.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
@@ -37,6 +38,48 @@ void XtcReaderActivity::onEnter() {
|
||||
// Load saved progress
|
||||
loadProgress();
|
||||
|
||||
// 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(xtc->getCoverBmpPath().c_str())) totalSteps++;
|
||||
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
|
||||
if (!Storage.exists(xtc->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(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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save current XTC as last opened book and add to recent books
|
||||
APP_STATE.openEpubPath = xtc->getPath();
|
||||
APP_STATE.saveToFile();
|
||||
|
||||
@@ -142,7 +142,6 @@ void CalibreSettingsActivity::render(Activity::RenderLock&&) {
|
||||
},
|
||||
true);
|
||||
|
||||
// Draw help text at bottom
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ class ClearCacheActivity final : public ActivityWithSubactivity {
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
bool skipLoopDelay() override { return true; } // Prevent power-saving mode
|
||||
void render(Activity::RenderLock&&) override;
|
||||
bool skipLoopDelay() override { return true; }
|
||||
|
||||
private:
|
||||
enum State { WARNING, CLEARING, SUCCESS, FAILED };
|
||||
|
||||
@@ -168,7 +168,6 @@ void KOReaderSettingsActivity::render(Activity::RenderLock&&) {
|
||||
},
|
||||
true);
|
||||
|
||||
// Draw help text at bottom
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
|
||||
@@ -33,6 +33,6 @@ class OtaUpdateActivity : public ActivityWithSubactivity {
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(Activity::RenderLock&&) override;
|
||||
bool skipLoopDelay() override { return true; }
|
||||
bool preventAutoSleep() override { return state == CHECKING_FOR_UPDATE || state == UPDATE_IN_PROGRESS; }
|
||||
bool skipLoopDelay() override { return true; } // Prevent power-saving mode
|
||||
};
|
||||
|
||||
157
src/activities/settings/SetTimeActivity.cpp
Normal file
157
src/activities/settings/SetTimeActivity.cpp
Normal file
@@ -0,0 +1,157 @@
|
||||
#include "SetTimeActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
#include <sys/time.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
void SetTimeActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
// Initialize from current system time if it's been set (year > 2000)
|
||||
time_t now = time(nullptr);
|
||||
struct tm* t = localtime(&now);
|
||||
if (t != nullptr && t->tm_year > 100) {
|
||||
hour = t->tm_hour;
|
||||
minute = t->tm_min;
|
||||
} else {
|
||||
hour = 12;
|
||||
minute = 0;
|
||||
}
|
||||
|
||||
selectedField = 0;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void SetTimeActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void SetTimeActivity::loop() {
|
||||
// Back button: discard and exit
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm button: apply time and exit
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
applyTime();
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
// Left/Right: switch between hour and minute fields
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Left)) {
|
||||
selectedField = 0;
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Right)) {
|
||||
selectedField = 1;
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Up/Down: increment/decrement the selected field
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Up)) {
|
||||
if (selectedField == 0) {
|
||||
hour = (hour + 1) % 24;
|
||||
} else {
|
||||
minute = (minute + 1) % 60;
|
||||
}
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Down)) {
|
||||
if (selectedField == 0) {
|
||||
hour = (hour + 23) % 24;
|
||||
} else {
|
||||
minute = (minute + 59) % 60;
|
||||
}
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void SetTimeActivity::render(Activity::RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const int lineHeight12 = renderer.getLineHeight(UI_12_FONT_ID);
|
||||
|
||||
// Title
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 20, tr(STR_SET_TIME), true, EpdFontFamily::BOLD);
|
||||
|
||||
// Format hour and minute strings
|
||||
char hourStr[4];
|
||||
char minuteStr[4];
|
||||
snprintf(hourStr, sizeof(hourStr), "%02d", hour);
|
||||
snprintf(minuteStr, sizeof(minuteStr), "%02d", minute);
|
||||
|
||||
const int colonWidth = renderer.getTextWidth(UI_12_FONT_ID, " : ");
|
||||
const int digitWidth = renderer.getTextWidth(UI_12_FONT_ID, "00");
|
||||
const int totalWidth = digitWidth * 2 + colonWidth;
|
||||
const int startX = (pageWidth - totalWidth) / 2;
|
||||
const int timeY = 80;
|
||||
|
||||
// Draw selection highlight behind the selected field
|
||||
constexpr int highlightPad = 6;
|
||||
if (selectedField == 0) {
|
||||
renderer.fillRoundedRect(startX - highlightPad, timeY - 4, digitWidth + highlightPad * 2, lineHeight12 + 8, 6,
|
||||
Color::LightGray);
|
||||
} else {
|
||||
renderer.fillRoundedRect(startX + digitWidth + colonWidth - highlightPad, timeY - 4, digitWidth + highlightPad * 2,
|
||||
lineHeight12 + 8, 6, Color::LightGray);
|
||||
}
|
||||
|
||||
// Draw the time digits and colon
|
||||
renderer.drawText(UI_12_FONT_ID, startX, timeY, hourStr, true);
|
||||
renderer.drawText(UI_12_FONT_ID, startX + digitWidth, timeY, " : ", true);
|
||||
renderer.drawText(UI_12_FONT_ID, startX + digitWidth + colonWidth, timeY, minuteStr, true);
|
||||
|
||||
// Draw up/down arrows above and below the selected field
|
||||
const int arrowX = (selectedField == 0) ? startX + digitWidth / 2 : startX + digitWidth + colonWidth + digitWidth / 2;
|
||||
const int arrowUpY = timeY - 20;
|
||||
const int arrowDownY = timeY + lineHeight12 + 12;
|
||||
// Up arrow (simple triangle using lines)
|
||||
constexpr int arrowSize = 6;
|
||||
for (int row = 0; row < arrowSize; row++) {
|
||||
renderer.drawLine(arrowX - row, arrowUpY + row, arrowX + row, arrowUpY + row);
|
||||
}
|
||||
// Down arrow
|
||||
for (int row = 0; row < arrowSize; row++) {
|
||||
renderer.drawLine(arrowX - row, arrowDownY + arrowSize - 1 - row, arrowX + row, arrowDownY + arrowSize - 1 - row);
|
||||
}
|
||||
|
||||
// Button hints
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SAVE), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
|
||||
void SetTimeActivity::applyTime() {
|
||||
time_t now = time(nullptr);
|
||||
struct tm newTime = {};
|
||||
struct tm* current = localtime(&now);
|
||||
if (current != nullptr && current->tm_year > 100) {
|
||||
newTime = *current;
|
||||
} else {
|
||||
// If time was never set, use a reasonable date (2025-01-01)
|
||||
newTime.tm_year = 125; // years since 1900
|
||||
newTime.tm_mon = 0;
|
||||
newTime.tm_mday = 1;
|
||||
}
|
||||
newTime.tm_hour = hour;
|
||||
newTime.tm_min = minute;
|
||||
newTime.tm_sec = 0;
|
||||
time_t newEpoch = mktime(&newTime);
|
||||
struct timeval tv = {.tv_sec = newEpoch, .tv_usec = 0};
|
||||
settimeofday(&tv, nullptr);
|
||||
}
|
||||
26
src/activities/settings/SetTimeActivity.h
Normal file
26
src/activities/settings/SetTimeActivity.h
Normal file
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
|
||||
class SetTimeActivity final : public Activity {
|
||||
public:
|
||||
explicit SetTimeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::function<void()>& onBack)
|
||||
: Activity("SetTime", renderer, mappedInput), onBack(onBack) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(Activity::RenderLock&&) override;
|
||||
|
||||
private:
|
||||
const std::function<void()> onBack;
|
||||
|
||||
// 0 = editing hours, 1 = editing minutes
|
||||
uint8_t selectedField = 0;
|
||||
int hour = 12;
|
||||
int minute = 0;
|
||||
|
||||
void applyTime();
|
||||
};
|
||||
101
src/activities/settings/SetTimezoneOffsetActivity.cpp
Normal file
101
src/activities/settings/SetTimezoneOffsetActivity.cpp
Normal file
@@ -0,0 +1,101 @@
|
||||
#include "SetTimezoneOffsetActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
void SetTimezoneOffsetActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
offsetHours = SETTINGS.timezoneOffsetHours;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void SetTimezoneOffsetActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void SetTimezoneOffsetActivity::loop() {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
SETTINGS.timezoneOffsetHours = offsetHours;
|
||||
SETTINGS.saveToFile();
|
||||
// Apply timezone immediately
|
||||
setenv("TZ", SETTINGS.getTimezonePosixStr(), 1);
|
||||
tzset();
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Up)) {
|
||||
if (offsetHours < 14) {
|
||||
offsetHours++;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Down)) {
|
||||
if (offsetHours > -12) {
|
||||
offsetHours--;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void SetTimezoneOffsetActivity::render(Activity::RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const int lineHeight12 = renderer.getLineHeight(UI_12_FONT_ID);
|
||||
|
||||
// Title
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 20, tr(STR_SET_UTC_OFFSET), true, EpdFontFamily::BOLD);
|
||||
|
||||
// Format the offset string
|
||||
char offsetStr[16];
|
||||
if (offsetHours >= 0) {
|
||||
snprintf(offsetStr, sizeof(offsetStr), "UTC+%d", offsetHours);
|
||||
} else {
|
||||
snprintf(offsetStr, sizeof(offsetStr), "UTC%d", offsetHours);
|
||||
}
|
||||
|
||||
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, offsetStr);
|
||||
const int startX = (pageWidth - textWidth) / 2;
|
||||
const int valueY = 80;
|
||||
|
||||
// Draw selection highlight
|
||||
constexpr int highlightPad = 10;
|
||||
renderer.fillRoundedRect(startX - highlightPad, valueY - 4, textWidth + highlightPad * 2, lineHeight12 + 8, 6,
|
||||
Color::LightGray);
|
||||
|
||||
// Draw the offset text
|
||||
renderer.drawText(UI_12_FONT_ID, startX, valueY, offsetStr, true);
|
||||
|
||||
// Draw up/down arrows
|
||||
const int arrowX = pageWidth / 2;
|
||||
const int arrowUpY = valueY - 20;
|
||||
const int arrowDownY = valueY + lineHeight12 + 12;
|
||||
constexpr int arrowSize = 6;
|
||||
for (int row = 0; row < arrowSize; row++) {
|
||||
renderer.drawLine(arrowX - row, arrowUpY + row, arrowX + row, arrowUpY + row);
|
||||
}
|
||||
for (int row = 0; row < arrowSize; row++) {
|
||||
renderer.drawLine(arrowX - row, arrowDownY + arrowSize - 1 - row, arrowX + row, arrowDownY + arrowSize - 1 - row);
|
||||
}
|
||||
|
||||
// Button hints
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SAVE), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
21
src/activities/settings/SetTimezoneOffsetActivity.h
Normal file
21
src/activities/settings/SetTimezoneOffsetActivity.h
Normal file
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
|
||||
class SetTimezoneOffsetActivity final : public Activity {
|
||||
public:
|
||||
explicit SetTimezoneOffsetActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::function<void()>& onBack)
|
||||
: Activity("SetTZOffset", renderer, mappedInput), onBack(onBack) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(Activity::RenderLock&&) override;
|
||||
|
||||
private:
|
||||
const std::function<void()> onBack;
|
||||
int8_t offsetHours = 0;
|
||||
};
|
||||
@@ -3,6 +3,9 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdlib>
|
||||
|
||||
#include "ButtonRemapActivity.h"
|
||||
#include "CalibreSettingsActivity.h"
|
||||
#include "ClearCacheActivity.h"
|
||||
@@ -11,19 +14,23 @@
|
||||
#include "LanguageSelectActivity.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "OtaUpdateActivity.h"
|
||||
#include "SetTimeActivity.h"
|
||||
#include "SetTimezoneOffsetActivity.h"
|
||||
#include "SettingsList.h"
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
const StrId SettingsActivity::categoryNames[categoryCount] = {StrId::STR_CAT_DISPLAY, StrId::STR_CAT_READER,
|
||||
StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM};
|
||||
StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM,
|
||||
StrId::STR_CAT_CLOCK};
|
||||
|
||||
void SettingsActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
// Build per-category vectors from the shared settings list
|
||||
displaySettings.clear();
|
||||
clockSettings.clear();
|
||||
readerSettings.clear();
|
||||
controlsSettings.clear();
|
||||
systemSettings.clear();
|
||||
@@ -32,6 +39,8 @@ void SettingsActivity::onEnter() {
|
||||
if (setting.category == StrId::STR_NONE_OPT) continue;
|
||||
if (setting.category == StrId::STR_CAT_DISPLAY) {
|
||||
displaySettings.push_back(std::move(setting));
|
||||
} else if (setting.category == StrId::STR_CAT_CLOCK) {
|
||||
clockSettings.push_back(std::move(setting));
|
||||
} else if (setting.category == StrId::STR_CAT_READER) {
|
||||
readerSettings.push_back(std::move(setting));
|
||||
} else if (setting.category == StrId::STR_CAT_CONTROLS) {
|
||||
@@ -43,6 +52,7 @@ void SettingsActivity::onEnter() {
|
||||
}
|
||||
|
||||
// Append device-only ACTION items
|
||||
rebuildClockActions();
|
||||
controlsSettings.insert(controlsSettings.begin(),
|
||||
SettingInfo::Action(StrId::STR_REMAP_FRONT_BUTTONS, SettingAction::RemapFrontButtons));
|
||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_WIFI_NETWORKS, SettingAction::Network));
|
||||
@@ -134,6 +144,9 @@ void SettingsActivity::loop() {
|
||||
case 3:
|
||||
currentSettings = &systemSettings;
|
||||
break;
|
||||
case 4:
|
||||
currentSettings = &clockSettings;
|
||||
break;
|
||||
}
|
||||
settingsCount = static_cast<int>(currentSettings->size());
|
||||
}
|
||||
@@ -154,6 +167,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) {
|
||||
@@ -199,6 +215,12 @@ void SettingsActivity::toggleCurrentSetting() {
|
||||
case SettingAction::Language:
|
||||
enterSubActivity(new LanguageSelectActivity(renderer, mappedInput, onComplete));
|
||||
break;
|
||||
case SettingAction::SetTime:
|
||||
enterSubActivity(new SetTimeActivity(renderer, mappedInput, onComplete));
|
||||
break;
|
||||
case SettingAction::SetTimezoneOffset:
|
||||
enterSubActivity(new SetTimezoneOffsetActivity(renderer, mappedInput, onComplete));
|
||||
break;
|
||||
case SettingAction::None:
|
||||
// Do nothing
|
||||
break;
|
||||
@@ -208,6 +230,37 @@ void SettingsActivity::toggleCurrentSetting() {
|
||||
}
|
||||
|
||||
SETTINGS.saveToFile();
|
||||
|
||||
// Apply timezone whenever settings change (idempotent, cheap)
|
||||
setenv("TZ", SETTINGS.getTimezonePosixStr(), 1);
|
||||
tzset();
|
||||
|
||||
// Rebuild clock actions (show/hide "Set UTC Offset" based on timezone selection)
|
||||
rebuildClockActions();
|
||||
}
|
||||
|
||||
void SettingsActivity::rebuildClockActions() {
|
||||
// Remove any existing ACTION items from clockSettings (keep enum settings from getSettingsList)
|
||||
clockSettings.erase(std::remove_if(clockSettings.begin(), clockSettings.end(),
|
||||
[](const SettingInfo& s) { return s.type == SettingType::ACTION; }),
|
||||
clockSettings.end());
|
||||
|
||||
// Always add Set Time
|
||||
clockSettings.push_back(SettingInfo::Action(StrId::STR_SET_TIME, SettingAction::SetTime));
|
||||
|
||||
// Only add Set UTC Offset when timezone is set to Custom
|
||||
if (SETTINGS.timezone == CrossPointSettings::TZ_CUSTOM) {
|
||||
clockSettings.push_back(SettingInfo::Action(StrId::STR_SET_UTC_OFFSET, SettingAction::SetTimezoneOffset));
|
||||
}
|
||||
|
||||
// Update settingsCount if we're currently viewing the clock category
|
||||
if (currentSettings == &clockSettings) {
|
||||
settingsCount = static_cast<int>(clockSettings.size());
|
||||
// Clamp selection to avoid pointing past the end of the list
|
||||
if (selectedSettingIndex > settingsCount) {
|
||||
selectedSettingIndex = settingsCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SettingsActivity::render(Activity::RenderLock&&) {
|
||||
@@ -246,6 +299,11 @@ void SettingsActivity::render(Activity::RenderLock&&) {
|
||||
} else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
|
||||
const uint8_t value = SETTINGS.*(setting.valuePtr);
|
||||
valueText = I18N.get(setting.enumValues[value]);
|
||||
} else if (setting.type == SettingType::ENUM && setting.valueGetter) {
|
||||
const uint8_t value = setting.valueGetter();
|
||||
if (value < setting.enumValues.size()) {
|
||||
valueText = I18N.get(setting.enumValues[value]);
|
||||
}
|
||||
} else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {
|
||||
valueText = std::to_string(SETTINGS.*(setting.valuePtr));
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ enum class SettingAction {
|
||||
ClearCache,
|
||||
CheckForUpdates,
|
||||
Language,
|
||||
SetTime,
|
||||
SetTimezoneOffset,
|
||||
};
|
||||
|
||||
struct SettingInfo {
|
||||
@@ -142,6 +144,7 @@ class SettingsActivity final : public ActivityWithSubactivity {
|
||||
|
||||
// Per-category settings derived from shared list + device-only actions
|
||||
std::vector<SettingInfo> displaySettings;
|
||||
std::vector<SettingInfo> clockSettings;
|
||||
std::vector<SettingInfo> readerSettings;
|
||||
std::vector<SettingInfo> controlsSettings;
|
||||
std::vector<SettingInfo> systemSettings;
|
||||
@@ -149,11 +152,12 @@ class SettingsActivity final : public ActivityWithSubactivity {
|
||||
|
||||
const std::function<void()> onGoHome;
|
||||
|
||||
static constexpr int categoryCount = 4;
|
||||
static constexpr int categoryCount = 5;
|
||||
static const StrId categoryNames[categoryCount];
|
||||
|
||||
void enterCategory(int categoryIndex);
|
||||
void toggleCurrentSetting();
|
||||
void rebuildClockActions();
|
||||
|
||||
public:
|
||||
explicit SettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
#include "MappedInputManager.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "components/themes/BaseTheme.h"
|
||||
#include "components/themes/lyra/Lyra3CoversTheme.h"
|
||||
#include "components/themes/lyra/LyraTheme.h"
|
||||
#include "util/StringUtils.h"
|
||||
|
||||
@@ -42,8 +41,8 @@ void UITheme::setTheme(CrossPointSettings::UI_THEME type) {
|
||||
break;
|
||||
case CrossPointSettings::UI_THEME::LYRA_3_COVERS:
|
||||
LOG_DBG("UI", "Using Lyra 3 Covers theme");
|
||||
currentTheme = std::make_unique<Lyra3CoversTheme>();
|
||||
currentMetrics = &Lyra3CoversMetrics::values;
|
||||
currentTheme = std::make_unique<LyraTheme>();
|
||||
currentMetrics = &LyraMetrics::values;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -89,4 +88,4 @@ UIIcon UITheme::getFileIcon(std::string filename) {
|
||||
return Image;
|
||||
}
|
||||
return File;
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "components/themes/BaseTheme.h"
|
||||
|
||||
class MappedInputManager;
|
||||
|
||||
class UITheme {
|
||||
// Static instance
|
||||
static UITheme instance;
|
||||
@@ -25,8 +28,13 @@ class UITheme {
|
||||
|
||||
private:
|
||||
const ThemeMetrics* currentMetrics;
|
||||
std::unique_ptr<BaseTheme> currentTheme;
|
||||
std::unique_ptr<const BaseTheme> currentTheme;
|
||||
};
|
||||
|
||||
// Known theme thumbnail heights to prerender when opening a book for the first time.
|
||||
// These correspond to homeCoverHeight values across all themes (Lyra=226, Base=400).
|
||||
static constexpr int PRERENDER_THUMB_HEIGHTS[] = {226, 400};
|
||||
static constexpr int PRERENDER_THUMB_HEIGHTS_COUNT = 2;
|
||||
|
||||
// Helper macro to access current theme
|
||||
#define GUI UITheme::getInstance().getTheme()
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
#include <Utf8.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
|
||||
#include "Battery.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "I18n.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "components/UITheme.h"
|
||||
@@ -267,6 +269,28 @@ void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* t
|
||||
Rect{batteryX, rect.y + 5, BaseMetrics::values.batteryWidth, BaseMetrics::values.batteryHeight},
|
||||
showBatteryPercentage);
|
||||
|
||||
// Draw clock on the left side (symmetric with battery on the right)
|
||||
if (SETTINGS.clockFormat != CrossPointSettings::CLOCK_OFF) {
|
||||
time_t now = time(nullptr);
|
||||
struct tm* t = localtime(&now);
|
||||
if (t != nullptr && t->tm_year > 100) {
|
||||
char timeBuf[16];
|
||||
if (SETTINGS.clockFormat == CrossPointSettings::CLOCK_24H) {
|
||||
snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d", t->tm_hour, t->tm_min);
|
||||
} else {
|
||||
int hour12 = t->tm_hour % 12;
|
||||
if (hour12 == 0) hour12 = 12;
|
||||
snprintf(timeBuf, sizeof(timeBuf), "%d:%02d %s", hour12, t->tm_min, t->tm_hour >= 12 ? "PM" : "AM");
|
||||
}
|
||||
int clockFont = SMALL_FONT_ID;
|
||||
if (SETTINGS.clockSize == CrossPointSettings::CLOCK_SIZE_MEDIUM)
|
||||
clockFont = UI_10_FONT_ID;
|
||||
else if (SETTINGS.clockSize == CrossPointSettings::CLOCK_SIZE_LARGE)
|
||||
clockFont = UI_12_FONT_ID;
|
||||
renderer.drawText(clockFont, rect.x + 12, rect.y + 5, timeBuf, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (title) {
|
||||
int padding = rect.width - batteryX + BaseMetrics::values.batteryWidth;
|
||||
auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, title,
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
#include "Lyra3CoversTheme.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
#include "RecentBooksStore.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "components/icons/cover.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
// Internal constants
|
||||
namespace {
|
||||
constexpr int hPaddingInSelection = 8;
|
||||
constexpr int cornerRadius = 6;
|
||||
int coverWidth = 0;
|
||||
} // namespace
|
||||
|
||||
void Lyra3CoversTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
|
||||
const int selectorIndex, bool& coverRendered, bool& coverBufferStored,
|
||||
bool& bufferRestored, std::function<bool()> storeCoverBuffer) const {
|
||||
const int tileWidth = (rect.width - 2 * Lyra3CoversMetrics::values.contentSidePadding) / 3;
|
||||
const int tileHeight = rect.height;
|
||||
const int bookTitleHeight = tileHeight - Lyra3CoversMetrics::values.homeCoverHeight - hPaddingInSelection;
|
||||
const int tileY = rect.y;
|
||||
const bool hasContinueReading = !recentBooks.empty();
|
||||
|
||||
// Draw book card regardless, fill with message based on `hasContinueReading`
|
||||
// Draw cover image as background if available (inside the box)
|
||||
// Only load from SD on first render, then use stored buffer
|
||||
if (hasContinueReading) {
|
||||
if (!coverRendered) {
|
||||
for (int i = 0;
|
||||
i < std::min(static_cast<int>(recentBooks.size()), Lyra3CoversMetrics::values.homeRecentBooksCount); i++) {
|
||||
std::string coverPath = recentBooks[i].coverBmpPath;
|
||||
bool hasCover = true;
|
||||
int tileX = Lyra3CoversMetrics::values.contentSidePadding + tileWidth * i;
|
||||
if (coverPath.empty()) {
|
||||
hasCover = false;
|
||||
} else {
|
||||
const std::string coverBmpPath =
|
||||
UITheme::getCoverThumbPath(coverPath, Lyra3CoversMetrics::values.homeCoverHeight);
|
||||
|
||||
// First time: load cover from SD and render
|
||||
FsFile file;
|
||||
if (Storage.openFileForRead("HOME", coverBmpPath, file)) {
|
||||
Bitmap bitmap(file);
|
||||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||||
float coverHeight = static_cast<float>(bitmap.getHeight());
|
||||
float coverWidth = static_cast<float>(bitmap.getWidth());
|
||||
float ratio = coverWidth / coverHeight;
|
||||
const float tileRatio = static_cast<float>(tileWidth - 2 * hPaddingInSelection) /
|
||||
static_cast<float>(Lyra3CoversMetrics::values.homeCoverHeight);
|
||||
float cropX = 1.0f - (tileRatio / ratio);
|
||||
|
||||
renderer.drawBitmap(bitmap, tileX + hPaddingInSelection, tileY + hPaddingInSelection,
|
||||
tileWidth - 2 * hPaddingInSelection, Lyra3CoversMetrics::values.homeCoverHeight,
|
||||
cropX);
|
||||
} else {
|
||||
hasCover = false;
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasCover) {
|
||||
// Render empty cover
|
||||
renderer.drawRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection,
|
||||
tileWidth - 2 * hPaddingInSelection, Lyra3CoversMetrics::values.homeCoverHeight, true);
|
||||
renderer.fillRect(tileX + hPaddingInSelection,
|
||||
tileY + hPaddingInSelection + (Lyra3CoversMetrics::values.homeCoverHeight / 3),
|
||||
tileWidth - 2 * hPaddingInSelection, 2 * Lyra3CoversMetrics::values.homeCoverHeight / 3,
|
||||
true);
|
||||
renderer.drawIcon(CoverIcon, tileX + hPaddingInSelection + 24, tileY + hPaddingInSelection + 24, 32, 32);
|
||||
}
|
||||
}
|
||||
|
||||
coverBufferStored = storeCoverBuffer();
|
||||
coverRendered = true;
|
||||
}
|
||||
|
||||
for (int i = 0; i < std::min(static_cast<int>(recentBooks.size()), Lyra3CoversMetrics::values.homeRecentBooksCount);
|
||||
i++) {
|
||||
bool bookSelected = (selectorIndex == i);
|
||||
|
||||
int tileX = Lyra3CoversMetrics::values.contentSidePadding + tileWidth * i;
|
||||
auto title =
|
||||
renderer.truncatedText(UI_10_FONT_ID, recentBooks[i].title.c_str(), tileWidth - 2 * hPaddingInSelection);
|
||||
|
||||
if (bookSelected) {
|
||||
// Draw selection box
|
||||
renderer.fillRoundedRect(tileX, tileY, tileWidth, hPaddingInSelection, cornerRadius, true, true, false, false,
|
||||
Color::LightGray);
|
||||
renderer.fillRectDither(tileX, tileY + hPaddingInSelection, hPaddingInSelection,
|
||||
Lyra3CoversMetrics::values.homeCoverHeight, Color::LightGray);
|
||||
renderer.fillRectDither(tileX + tileWidth - hPaddingInSelection, tileY + hPaddingInSelection,
|
||||
hPaddingInSelection, Lyra3CoversMetrics::values.homeCoverHeight, Color::LightGray);
|
||||
renderer.fillRoundedRect(tileX, tileY + Lyra3CoversMetrics::values.homeCoverHeight + hPaddingInSelection,
|
||||
tileWidth, bookTitleHeight, cornerRadius, false, false, true, true, Color::LightGray);
|
||||
}
|
||||
renderer.drawText(UI_10_FONT_ID, tileX + hPaddingInSelection,
|
||||
tileY + tileHeight - bookTitleHeight + hPaddingInSelection + 5, title.c_str(), true);
|
||||
}
|
||||
} else {
|
||||
drawEmptyRecents(renderer, rect);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "components/themes/lyra/LyraTheme.h"
|
||||
|
||||
class GfxRenderer;
|
||||
|
||||
// Lyra theme metrics (zero runtime cost)
|
||||
namespace Lyra3CoversMetrics {
|
||||
constexpr ThemeMetrics values = {.batteryWidth = 16,
|
||||
.batteryHeight = 12,
|
||||
.topPadding = 5,
|
||||
.batteryBarHeight = 40,
|
||||
.headerHeight = 84,
|
||||
.verticalSpacing = 16,
|
||||
.contentSidePadding = 20,
|
||||
.listRowHeight = 40,
|
||||
.listWithSubtitleRowHeight = 60,
|
||||
.menuRowHeight = 64,
|
||||
.menuSpacing = 8,
|
||||
.tabSpacing = 8,
|
||||
.tabBarHeight = 40,
|
||||
.scrollBarWidth = 4,
|
||||
.scrollBarRightOffset = 5,
|
||||
.homeTopPadding = 56,
|
||||
.homeCoverHeight = 226,
|
||||
.homeCoverTileHeight = 287,
|
||||
.homeRecentBooksCount = 3,
|
||||
.buttonHintsHeight = 40,
|
||||
.sideButtonHintsWidth = 30,
|
||||
.progressBarHeight = 16,
|
||||
.bookProgressBarHeight = 4};
|
||||
}
|
||||
|
||||
class Lyra3CoversTheme : public LyraTheme {
|
||||
public:
|
||||
void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
|
||||
const int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored,
|
||||
std::function<bool()> storeCoverBuffer) const override;
|
||||
};
|
||||
@@ -3,11 +3,15 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
#include <Utf8.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Battery.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "components/icons/book.h"
|
||||
@@ -39,7 +43,6 @@ constexpr int maxListValueWidth = 200;
|
||||
constexpr int mainMenuIconSize = 32;
|
||||
constexpr int listIconSize = 24;
|
||||
constexpr int mainMenuColumns = 2;
|
||||
int coverWidth = 0;
|
||||
|
||||
const uint8_t* iconForName(UIIcon icon, int size) {
|
||||
if (size == 24) {
|
||||
@@ -173,6 +176,28 @@ void LyraTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* t
|
||||
Rect{batteryX, rect.y + 5, LyraMetrics::values.batteryWidth, LyraMetrics::values.batteryHeight},
|
||||
showBatteryPercentage);
|
||||
|
||||
// Draw clock on the left side (symmetric with battery on the right)
|
||||
if (SETTINGS.clockFormat != CrossPointSettings::CLOCK_OFF) {
|
||||
time_t now = time(nullptr);
|
||||
struct tm* t = localtime(&now);
|
||||
if (t != nullptr && t->tm_year > 100) {
|
||||
char timeBuf[16];
|
||||
if (SETTINGS.clockFormat == CrossPointSettings::CLOCK_24H) {
|
||||
snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d", t->tm_hour, t->tm_min);
|
||||
} else {
|
||||
int hour12 = t->tm_hour % 12;
|
||||
if (hour12 == 0) hour12 = 12;
|
||||
snprintf(timeBuf, sizeof(timeBuf), "%d:%02d %s", hour12, t->tm_min, t->tm_hour >= 12 ? "PM" : "AM");
|
||||
}
|
||||
int clockFont = SMALL_FONT_ID;
|
||||
if (SETTINGS.clockSize == CrossPointSettings::CLOCK_SIZE_MEDIUM)
|
||||
clockFont = UI_10_FONT_ID;
|
||||
else if (SETTINGS.clockSize == CrossPointSettings::CLOCK_SIZE_LARGE)
|
||||
clockFont = UI_12_FONT_ID;
|
||||
renderer.drawText(clockFont, rect.x + 12, rect.y + 5, timeBuf, true);
|
||||
}
|
||||
}
|
||||
|
||||
int maxTitleWidth =
|
||||
rect.width - LyraMetrics::values.contentSidePadding * 2 - (subtitle != nullptr ? maxSubtitleWidth : 0);
|
||||
|
||||
@@ -411,84 +436,190 @@ void LyraTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* top
|
||||
void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
|
||||
const int selectorIndex, bool& coverRendered, bool& coverBufferStored,
|
||||
bool& bufferRestored, std::function<bool()> storeCoverBuffer) const {
|
||||
const int tileWidth = rect.width - 2 * LyraMetrics::values.contentSidePadding;
|
||||
const int bookCount = std::min(static_cast<int>(recentBooks.size()), LyraMetrics::values.homeRecentBooksCount);
|
||||
const int tileHeight = rect.height;
|
||||
const int tileY = rect.y;
|
||||
const bool hasContinueReading = !recentBooks.empty();
|
||||
if (coverWidth == 0) {
|
||||
coverWidth = LyraMetrics::values.homeCoverHeight * 0.6;
|
||||
const int coverHeight = LyraMetrics::values.homeCoverHeight;
|
||||
|
||||
if (bookCount == 0) {
|
||||
drawEmptyRecents(renderer, rect);
|
||||
return;
|
||||
}
|
||||
|
||||
// Draw book card regardless, fill with message based on `hasContinueReading`
|
||||
// Draw cover image as background if available (inside the box)
|
||||
// Only load from SD on first render, then use stored buffer
|
||||
if (hasContinueReading) {
|
||||
RecentBook book = recentBooks[0];
|
||||
if (!coverRendered) {
|
||||
std::string coverPath = book.coverBmpPath;
|
||||
bool hasCover = true;
|
||||
int tileX = LyraMetrics::values.contentSidePadding;
|
||||
if (coverPath.empty()) {
|
||||
hasCover = false;
|
||||
} else {
|
||||
const std::string coverBmpPath = UITheme::getCoverThumbPath(coverPath, LyraMetrics::values.homeCoverHeight);
|
||||
auto wrapText = [&renderer](int fontId, const std::string& text, int maxWidth,
|
||||
int maxLines) -> std::vector<std::string> {
|
||||
std::vector<std::string> words;
|
||||
words.reserve(8);
|
||||
size_t pos = 0;
|
||||
while (pos < text.size()) {
|
||||
while (pos < text.size() && text[pos] == ' ') ++pos;
|
||||
if (pos >= text.size()) break;
|
||||
const size_t start = pos;
|
||||
while (pos < text.size() && text[pos] != ' ') ++pos;
|
||||
words.emplace_back(text.substr(start, pos - start));
|
||||
}
|
||||
|
||||
// First time: load cover from SD and render
|
||||
FsFile file;
|
||||
if (Storage.openFileForRead("HOME", coverBmpPath, file)) {
|
||||
Bitmap bitmap(file);
|
||||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||||
coverWidth = bitmap.getWidth();
|
||||
renderer.drawBitmap(bitmap, tileX + hPaddingInSelection, tileY + hPaddingInSelection, coverWidth,
|
||||
LyraMetrics::values.homeCoverHeight);
|
||||
} else {
|
||||
hasCover = false;
|
||||
}
|
||||
file.close();
|
||||
const int spaceWidth = renderer.getSpaceWidth(fontId);
|
||||
std::vector<std::string> lines;
|
||||
std::string currentLine;
|
||||
for (auto& word : words) {
|
||||
if (static_cast<int>(lines.size()) >= maxLines) {
|
||||
lines.back().append("...");
|
||||
while (!lines.back().empty() && renderer.getTextWidth(fontId, lines.back().c_str()) > maxWidth) {
|
||||
lines.back().resize(lines.back().size() - 3);
|
||||
utf8RemoveLastChar(lines.back());
|
||||
lines.back().append("...");
|
||||
}
|
||||
break;
|
||||
}
|
||||
int wordWidth = renderer.getTextWidth(fontId, word.c_str());
|
||||
while (wordWidth > maxWidth && !word.empty()) {
|
||||
utf8RemoveLastChar(word);
|
||||
std::string withEllipsis = word + "...";
|
||||
wordWidth = renderer.getTextWidth(fontId, withEllipsis.c_str());
|
||||
if (wordWidth <= maxWidth) {
|
||||
word = withEllipsis;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasCover) {
|
||||
// Render empty cover
|
||||
renderer.drawRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection, coverWidth,
|
||||
LyraMetrics::values.homeCoverHeight, true);
|
||||
renderer.fillRect(tileX + hPaddingInSelection,
|
||||
tileY + hPaddingInSelection + (LyraMetrics::values.homeCoverHeight / 3), coverWidth,
|
||||
2 * LyraMetrics::values.homeCoverHeight / 3, true);
|
||||
renderer.drawIcon(CoverIcon, tileX + hPaddingInSelection + 24, tileY + hPaddingInSelection + 24, 32, 32);
|
||||
int newLineWidth = renderer.getTextWidth(fontId, currentLine.c_str());
|
||||
if (newLineWidth > 0) newLineWidth += spaceWidth;
|
||||
newLineWidth += wordWidth;
|
||||
if (newLineWidth > maxWidth && !currentLine.empty()) {
|
||||
lines.push_back(currentLine);
|
||||
currentLine = word;
|
||||
} else {
|
||||
if (!currentLine.empty()) currentLine.append(" ");
|
||||
currentLine.append(word);
|
||||
}
|
||||
}
|
||||
if (!currentLine.empty() && static_cast<int>(lines.size()) < maxLines) {
|
||||
lines.push_back(currentLine);
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
|
||||
auto& storage = HalStorage::getInstance();
|
||||
auto renderCoverBitmap = [&renderer, &storage, coverHeight](const std::string& coverBmpPath, int slotX, int slotY,
|
||||
int slotWidth) {
|
||||
FsFile file;
|
||||
if (storage.openFileForRead("HOME", coverBmpPath, file)) {
|
||||
Bitmap bitmap(file);
|
||||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||||
float bmpW = static_cast<float>(bitmap.getWidth());
|
||||
float bmpH = static_cast<float>(bitmap.getHeight());
|
||||
float ratio = bmpW / bmpH;
|
||||
int naturalWidth = static_cast<int>(coverHeight * ratio);
|
||||
|
||||
if (naturalWidth >= slotWidth) {
|
||||
float slotRatio = static_cast<float>(slotWidth) / static_cast<float>(coverHeight);
|
||||
float cropX = 1.0f - (slotRatio / ratio);
|
||||
renderer.drawBitmap(bitmap, slotX, slotY, slotWidth, coverHeight, cropX);
|
||||
} else {
|
||||
int offsetX = (slotWidth - naturalWidth) / 2;
|
||||
renderer.drawBitmap(bitmap, slotX + offsetX, slotY, naturalWidth, coverHeight, 0.0f);
|
||||
}
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
};
|
||||
|
||||
if (bookCount == 1) {
|
||||
const bool bookSelected = (selectorIndex == 0);
|
||||
const int cardX = LyraMetrics::values.contentSidePadding;
|
||||
const int cardWidth = rect.width - 2 * LyraMetrics::values.contentSidePadding;
|
||||
const int coverSlotWidth = static_cast<int>(coverHeight * 0.65f);
|
||||
const int textGap = hPaddingInSelection * 2;
|
||||
const int textAreaX = cardX + hPaddingInSelection + coverSlotWidth + textGap;
|
||||
const int textAreaWidth = cardWidth - hPaddingInSelection * 2 - coverSlotWidth - textGap;
|
||||
|
||||
if (!coverRendered) {
|
||||
renderer.drawRect(cardX + hPaddingInSelection, tileY + hPaddingInSelection, coverSlotWidth, coverHeight);
|
||||
if (!recentBooks[0].coverBmpPath.empty()) {
|
||||
const std::string coverBmpPath = UITheme::getCoverThumbPath(recentBooks[0].coverBmpPath, coverHeight);
|
||||
renderCoverBitmap(coverBmpPath, cardX + hPaddingInSelection, tileY + hPaddingInSelection, coverSlotWidth);
|
||||
}
|
||||
coverBufferStored = storeCoverBuffer();
|
||||
coverRendered = true;
|
||||
}
|
||||
|
||||
bool bookSelected = (selectorIndex == 0);
|
||||
|
||||
int tileX = LyraMetrics::values.contentSidePadding;
|
||||
int textWidth = tileWidth - 2 * hPaddingInSelection - LyraMetrics::values.verticalSpacing - coverWidth;
|
||||
|
||||
if (bookSelected) {
|
||||
// Draw selection box
|
||||
renderer.fillRoundedRect(tileX, tileY, tileWidth, hPaddingInSelection, cornerRadius, true, true, false, false,
|
||||
renderer.fillRoundedRect(cardX, tileY, cardWidth, hPaddingInSelection, cornerRadius, true, true, false, false,
|
||||
Color::LightGray);
|
||||
renderer.fillRectDither(tileX, tileY + hPaddingInSelection, hPaddingInSelection,
|
||||
LyraMetrics::values.homeCoverHeight, Color::LightGray);
|
||||
renderer.fillRectDither(tileX + hPaddingInSelection + coverWidth, tileY + hPaddingInSelection,
|
||||
tileWidth - hPaddingInSelection - coverWidth, LyraMetrics::values.homeCoverHeight,
|
||||
Color::LightGray);
|
||||
renderer.fillRoundedRect(tileX, tileY + LyraMetrics::values.homeCoverHeight + hPaddingInSelection, tileWidth,
|
||||
hPaddingInSelection, cornerRadius, false, false, true, true, Color::LightGray);
|
||||
renderer.fillRectDither(cardX, tileY + hPaddingInSelection, hPaddingInSelection, coverHeight, Color::LightGray);
|
||||
renderer.fillRectDither(cardX + cardWidth - hPaddingInSelection, tileY + hPaddingInSelection, hPaddingInSelection,
|
||||
coverHeight, Color::LightGray);
|
||||
renderer.fillRectDither(cardX + hPaddingInSelection + coverSlotWidth, tileY + hPaddingInSelection,
|
||||
cardWidth - hPaddingInSelection * 2 - coverSlotWidth, coverHeight, Color::LightGray);
|
||||
const int bottomY = tileY + hPaddingInSelection + coverHeight;
|
||||
const int bottomH = tileHeight - hPaddingInSelection - coverHeight;
|
||||
if (bottomH > 0) {
|
||||
renderer.fillRoundedRect(cardX, bottomY, cardWidth, bottomH, cornerRadius, false, false, true, true,
|
||||
Color::LightGray);
|
||||
}
|
||||
}
|
||||
|
||||
auto titleLines = wrapText(UI_12_FONT_ID, recentBooks[0].title, textAreaWidth, 5);
|
||||
const int titleLineHeight = renderer.getLineHeight(UI_12_FONT_ID);
|
||||
int textY = tileY + hPaddingInSelection + 3;
|
||||
for (const auto& line : titleLines) {
|
||||
renderer.drawText(UI_12_FONT_ID, textAreaX, textY, line.c_str(), true);
|
||||
textY += titleLineHeight;
|
||||
}
|
||||
|
||||
if (!recentBooks[0].author.empty()) {
|
||||
textY += 4;
|
||||
auto author = renderer.truncatedText(UI_10_FONT_ID, recentBooks[0].author.c_str(), textAreaWidth);
|
||||
renderer.drawText(UI_10_FONT_ID, textAreaX, textY, author.c_str(), true);
|
||||
}
|
||||
|
||||
auto title = renderer.truncatedText(UI_12_FONT_ID, book.title.c_str(), textWidth, EpdFontFamily::BOLD);
|
||||
auto author = renderer.truncatedText(UI_10_FONT_ID, book.author.c_str(), textWidth);
|
||||
auto bookTitleHeight = renderer.getTextHeight(UI_12_FONT_ID);
|
||||
renderer.drawText(UI_12_FONT_ID, tileX + hPaddingInSelection + coverWidth + LyraMetrics::values.verticalSpacing,
|
||||
tileY + tileHeight / 2 - bookTitleHeight, title.c_str(), true, EpdFontFamily::BOLD);
|
||||
renderer.drawText(UI_10_FONT_ID, tileX + hPaddingInSelection + coverWidth + LyraMetrics::values.verticalSpacing,
|
||||
tileY + tileHeight / 2 + 5, author.c_str(), true);
|
||||
} else {
|
||||
drawEmptyRecents(renderer, rect);
|
||||
const int tileWidth = (rect.width - 2 * LyraMetrics::values.contentSidePadding) / bookCount;
|
||||
const int bottomSectionHeight = tileHeight - coverHeight - hPaddingInSelection;
|
||||
|
||||
if (!coverRendered) {
|
||||
for (int i = 0; i < bookCount; i++) {
|
||||
int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i;
|
||||
int drawWidth = tileWidth - 2 * hPaddingInSelection;
|
||||
renderer.drawRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection, drawWidth, coverHeight);
|
||||
if (!recentBooks[i].coverBmpPath.empty()) {
|
||||
const std::string coverBmpPath = UITheme::getCoverThumbPath(recentBooks[i].coverBmpPath, coverHeight);
|
||||
renderCoverBitmap(coverBmpPath, tileX + hPaddingInSelection, tileY + hPaddingInSelection, drawWidth);
|
||||
}
|
||||
}
|
||||
coverBufferStored = storeCoverBuffer();
|
||||
coverRendered = true;
|
||||
}
|
||||
|
||||
for (int i = 0; i < bookCount; i++) {
|
||||
bool bookSelected = (selectorIndex == i);
|
||||
int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i;
|
||||
const int maxTextWidth = tileWidth - 2 * hPaddingInSelection;
|
||||
|
||||
if (bookSelected) {
|
||||
renderer.fillRoundedRect(tileX, tileY, tileWidth, hPaddingInSelection, cornerRadius, true, true, false, false,
|
||||
Color::LightGray);
|
||||
renderer.fillRectDither(tileX, tileY + hPaddingInSelection, hPaddingInSelection, coverHeight, Color::LightGray);
|
||||
renderer.fillRectDither(tileX + tileWidth - hPaddingInSelection, tileY + hPaddingInSelection,
|
||||
hPaddingInSelection, coverHeight, Color::LightGray);
|
||||
renderer.fillRoundedRect(tileX, tileY + coverHeight + hPaddingInSelection, tileWidth, bottomSectionHeight,
|
||||
cornerRadius, false, false, true, true, Color::LightGray);
|
||||
}
|
||||
|
||||
auto titleLines = wrapText(UI_10_FONT_ID, recentBooks[i].title, maxTextWidth, 2);
|
||||
const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
|
||||
|
||||
int textY = tileY + coverHeight + hPaddingInSelection + 4;
|
||||
for (const auto& line : titleLines) {
|
||||
renderer.drawText(UI_10_FONT_ID, tileX + hPaddingInSelection, textY, line.c_str(), true);
|
||||
textY += lineHeight;
|
||||
}
|
||||
|
||||
if (!recentBooks[i].author.empty()) {
|
||||
auto author = renderer.truncatedText(SMALL_FONT_ID, recentBooks[i].author.c_str(), maxTextWidth);
|
||||
renderer.drawText(SMALL_FONT_ID, tileX + hPaddingInSelection, textY + 2, author.c_str(), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,10 +689,8 @@ Rect LyraTheme::drawPopup(const GfxRenderer& renderer, const char* message) cons
|
||||
void LyraTheme::fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const {
|
||||
constexpr int barHeight = 4;
|
||||
|
||||
// Twice the margin in drawPopup to match text width
|
||||
const int barWidth = layout.width - popupMarginX * 2;
|
||||
const int barX = layout.x + (layout.width - barWidth) / 2;
|
||||
// Center inside the margin of drawPopup. The - 1 is added to account for the - 2 in drawPopup.
|
||||
const int barY = layout.y + layout.height - popupMarginY / 2 - barHeight / 2 - 1;
|
||||
|
||||
int fillWidth = barWidth * progress / 100;
|
||||
|
||||
@@ -23,8 +23,8 @@ constexpr ThemeMetrics values = {.batteryWidth = 16,
|
||||
.scrollBarRightOffset = 5,
|
||||
.homeTopPadding = 56,
|
||||
.homeCoverHeight = 226,
|
||||
.homeCoverTileHeight = 242,
|
||||
.homeRecentBooksCount = 1,
|
||||
.homeCoverTileHeight = 318,
|
||||
.homeRecentBooksCount = 3,
|
||||
.buttonHintsHeight = 40,
|
||||
.sideButtonHintsWidth = 30,
|
||||
.progressBarHeight = 16,
|
||||
|
||||
61
src/main.cpp
61
src/main.cpp
@@ -11,7 +11,9 @@
|
||||
#include <SPI.h>
|
||||
#include <builtinFonts/all.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
|
||||
#include "Battery.h"
|
||||
#include "CrossPointSettings.h"
|
||||
@@ -35,19 +37,23 @@
|
||||
|
||||
HalDisplay display;
|
||||
HalGPIO gpio;
|
||||
HalPowerManager powerManager;
|
||||
MappedInputManager mappedInputManager(gpio);
|
||||
GfxRenderer renderer(display);
|
||||
FontDecompressor fontDecompressor;
|
||||
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);
|
||||
@@ -66,7 +72,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);
|
||||
@@ -91,7 +99,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);
|
||||
@@ -116,6 +126,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);
|
||||
@@ -262,25 +273,32 @@ void setupDisplayAndFonts() {
|
||||
renderer.begin();
|
||||
LOG_DBG("MAIN", "Display initialized");
|
||||
|
||||
// Initialize font decompressor for compressed reader fonts
|
||||
if (!fontDecompressor.init()) {
|
||||
LOG_ERR("MAIN", "Font decompressor init failed");
|
||||
}
|
||||
renderer.setFontDecompressor(&fontDecompressor);
|
||||
#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);
|
||||
@@ -315,6 +333,11 @@ void setup() {
|
||||
}
|
||||
|
||||
SETTINGS.loadFromFile();
|
||||
|
||||
// Apply saved timezone setting on boot
|
||||
setenv("TZ", SETTINGS.getTimezonePosixStr(), 1);
|
||||
tzset();
|
||||
|
||||
I18N.loadSettings();
|
||||
KOREADER_STORE.loadFromFile();
|
||||
UITheme::getInstance().reload();
|
||||
@@ -341,6 +364,18 @@ void setup() {
|
||||
// First serial output only here to avoid timing inconsistencies for power button press duration verification
|
||||
LOG_DBG("MAIN", "Starting CrossPoint version " CROSSPOINT_VERSION);
|
||||
|
||||
// Log RTC time to verify persistence across deep sleep
|
||||
{
|
||||
time_t now = time(nullptr);
|
||||
struct tm* t = localtime(&now);
|
||||
if (t != nullptr && t->tm_year > 100) {
|
||||
LOG_DBG("MAIN", "RTC time: %04d-%02d-%02d %02d:%02d:%02d", t->tm_year + 1900, t->tm_mon + 1, t->tm_mday,
|
||||
t->tm_hour, t->tm_min, t->tm_sec);
|
||||
} else {
|
||||
LOG_DBG("MAIN", "RTC time not set (epoch)");
|
||||
}
|
||||
}
|
||||
|
||||
setupDisplayAndFonts();
|
||||
|
||||
exitActivity();
|
||||
@@ -419,6 +454,20 @@ void loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh screen when the displayed minute changes (clock in header)
|
||||
if (SETTINGS.clockFormat != CrossPointSettings::CLOCK_OFF && currentActivity) {
|
||||
static int lastRenderedMinute = -1;
|
||||
time_t now = time(nullptr);
|
||||
struct tm* t = localtime(&now);
|
||||
if (t != nullptr && t->tm_year > 100) {
|
||||
const int currentMinute = t->tm_hour * 60 + t->tm_min;
|
||||
if (lastRenderedMinute >= 0 && currentMinute != lastRenderedMinute) {
|
||||
currentActivity->requestUpdate();
|
||||
}
|
||||
lastRenderedMinute = currentMinute;
|
||||
}
|
||||
}
|
||||
|
||||
const unsigned long activityStartTime = millis();
|
||||
if (currentActivity) {
|
||||
currentActivity->loop();
|
||||
@@ -433,12 +482,18 @@ 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
|
||||
if (currentActivity && currentActivity->skipLoopDelay()) {
|
||||
powerManager.setPowerSaving(false); // Make sure we're at full performance when skipLoopDelay is requested
|
||||
yield(); // Give FreeRTOS a chance to run tasks, but return immediately
|
||||
yield(); // Give FreeRTOS a chance to run tasks, but return immediately
|
||||
} else {
|
||||
if (millis() - lastActivityTime >= HalPowerManager::IDLE_POWER_SAVING_MS) {
|
||||
// If we've been inactive for a while, increase the delay to save power
|
||||
|
||||
60
src/util/BookSettings.cpp
Normal file
60
src/util/BookSettings.cpp
Normal file
@@ -0,0 +1,60 @@
|
||||
#include "BookSettings.h"
|
||||
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t BOOK_SETTINGS_VERSION = 1;
|
||||
constexpr uint8_t BOOK_SETTINGS_COUNT = 1; // Number of persisted fields
|
||||
} // namespace
|
||||
|
||||
std::string BookSettings::filePath(const std::string& cachePath) { return cachePath + "/book_settings.bin"; }
|
||||
|
||||
BookSettings BookSettings::load(const std::string& cachePath) {
|
||||
BookSettings settings;
|
||||
FsFile f;
|
||||
if (!Storage.openFileForRead("BST", filePath(cachePath), f)) {
|
||||
return settings;
|
||||
}
|
||||
|
||||
uint8_t version;
|
||||
serialization::readPod(f, version);
|
||||
if (version != BOOK_SETTINGS_VERSION) {
|
||||
f.close();
|
||||
return settings;
|
||||
}
|
||||
|
||||
uint8_t fieldCount;
|
||||
serialization::readPod(f, fieldCount);
|
||||
|
||||
// Read fields that exist (supports older files with fewer fields)
|
||||
uint8_t fieldsRead = 0;
|
||||
do {
|
||||
serialization::readPod(f, settings.letterboxFillOverride);
|
||||
if (++fieldsRead >= fieldCount) break;
|
||||
// New fields added here for forward compatibility
|
||||
} while (false);
|
||||
|
||||
f.close();
|
||||
LOG_DBG("BST", "Loaded book settings from %s (letterboxFill=%d)", filePath(cachePath).c_str(),
|
||||
settings.letterboxFillOverride);
|
||||
return settings;
|
||||
}
|
||||
|
||||
bool BookSettings::save(const std::string& cachePath, const BookSettings& settings) {
|
||||
FsFile f;
|
||||
if (!Storage.openFileForWrite("BST", filePath(cachePath), f)) {
|
||||
LOG_ERR("BST", "Could not save book settings!");
|
||||
return false;
|
||||
}
|
||||
|
||||
serialization::writePod(f, BOOK_SETTINGS_VERSION);
|
||||
serialization::writePod(f, BOOK_SETTINGS_COUNT);
|
||||
serialization::writePod(f, settings.letterboxFillOverride);
|
||||
// New fields added here
|
||||
f.close();
|
||||
|
||||
LOG_DBG("BST", "Saved book settings to %s", filePath(cachePath).c_str());
|
||||
return true;
|
||||
}
|
||||
31
src/util/BookSettings.h
Normal file
31
src/util/BookSettings.h
Normal file
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
|
||||
// Per-book settings stored in the book's cache directory.
|
||||
// Fields default to sentinel values (0xFF) meaning "use global setting".
|
||||
class BookSettings {
|
||||
public:
|
||||
// 0xFF = use global default; otherwise one of SLEEP_SCREEN_LETTERBOX_FILL values (0-2).
|
||||
uint8_t letterboxFillOverride = USE_GLOBAL;
|
||||
|
||||
static constexpr uint8_t USE_GLOBAL = 0xFF;
|
||||
|
||||
// Returns the effective letterbox fill mode: the per-book override if set,
|
||||
// otherwise the global setting from CrossPointSettings.
|
||||
uint8_t getEffectiveLetterboxFill() const {
|
||||
if (letterboxFillOverride != USE_GLOBAL &&
|
||||
letterboxFillOverride < CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL_COUNT) {
|
||||
return letterboxFillOverride;
|
||||
}
|
||||
return SETTINGS.sleepScreenLetterboxFill;
|
||||
}
|
||||
|
||||
static BookSettings load(const std::string& cachePath);
|
||||
static bool save(const std::string& cachePath, const BookSettings& settings);
|
||||
|
||||
private:
|
||||
static std::string filePath(const std::string& cachePath);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user