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

This commit is contained in:
jpirnay
2026-03-08 17:58:33 +01:00
9 changed files with 134 additions and 47 deletions
+37 -16
View File
@@ -21,9 +21,8 @@ std::string stripHtml(const std::string& html) {
for (size_t i = 0; i < html.size(); ++i) {
const char c = html[i];
if (c == '<') {
// Only treat as a tag if followed by a tag-like character; otherwise keep literal '<'
size_t j = i + 1;
while (j < html.size() && html[j] == ' ') ++j;
// Only treat as a tag if immediately followed (no space skip) by a tag-like character
const size_t j = i + 1;
if (j < html.size() &&
(isalpha(static_cast<unsigned char>(html[j])) || html[j] == '/' || html[j] == '!' || html[j] == '?')) {
inTag = true;
@@ -33,7 +32,11 @@ std::string stripHtml(const std::string& html) {
result += c;
}
} else if (c == '>') {
inTag = false;
if (inTag) {
inTag = false;
} else {
result += c;
}
} else if (!inTag) {
if (c == '&') {
// Decode common HTML entities not covered by Expat
@@ -196,7 +199,10 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
}
if (self->state == IN_METADATA && strcmp(name, "dc:description") == 0) {
self->state = IN_BOOK_DESCRIPTION;
// Only capture the first dc:description element; subsequent ones are alternate/localized variants
if (self->description.empty()) {
self->state = IN_BOOK_DESCRIPTION;
}
return;
}
@@ -254,25 +260,34 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
if (strcmp(metaName, "cover") == 0) {
self->coverItemId = metaContent;
} else if (strcmp(metaName, "calibre:series") == 0 && self->series.empty()) {
self->series = metaContent;
self->series = trim(std::string(metaContent, std::min(strlen(metaContent), size_t{MAX_DESCRIPTION_LENGTH})));
} else if (strcmp(metaName, "calibre:series_index") == 0 && self->seriesIndex.empty()) {
self->seriesIndex = metaContent;
self->seriesIndex =
trim(std::string(metaContent, std::min(strlen(metaContent), size_t{MAX_DESCRIPTION_LENGTH})));
}
}
// EPUB 3 collection metadata:
// <meta property="belongs-to-collection">Series Name</meta>
// <meta property="belongs-to-collection">Series Name</meta> (character data)
// <meta property="belongs-to-collection" content="Series Name"/> (attribute, some generators)
// <meta property="group-position">1</meta>
if (metaProperty) {
if (strcmp(metaProperty, "belongs-to-collection") == 0 && self->series.empty()) {
self->series.clear();
self->state = IN_BOOK_SERIES;
return;
if (metaContent) {
self->series = trim(std::string(metaContent, std::min(strlen(metaContent), size_t{MAX_DESCRIPTION_LENGTH})));
} else {
self->state = IN_BOOK_SERIES;
return;
}
}
if (strcmp(metaProperty, "group-position") == 0 && self->seriesIndex.empty()) {
self->seriesIndex.clear();
self->state = IN_BOOK_SERIES_INDEX;
return;
if (metaContent) {
self->seriesIndex =
trim(std::string(metaContent, std::min(strlen(metaContent), size_t{MAX_DESCRIPTION_LENGTH})));
} else {
self->state = IN_BOOK_SERIES_INDEX;
return;
}
}
}
@@ -457,12 +472,18 @@ void XMLCALL ContentOpfParser::characterData(void* userData, const XML_Char* s,
}
if (self->state == IN_BOOK_SERIES) {
self->series.append(s, len);
if (self->series.size() < MAX_DESCRIPTION_LENGTH) {
const size_t remaining = MAX_DESCRIPTION_LENGTH - self->series.size();
self->series.append(s, std::min(static_cast<size_t>(len), remaining));
}
return;
}
if (self->state == IN_BOOK_SERIES_INDEX) {
self->seriesIndex.append(s, len);
if (self->seriesIndex.size() < MAX_DESCRIPTION_LENGTH) {
const size_t remaining = MAX_DESCRIPTION_LENGTH - self->seriesIndex.size();
self->seriesIndex.append(s, std::min(static_cast<size_t>(len), remaining));
}
return;
}
}
+17 -3
View File
@@ -36,17 +36,31 @@ static void writeString(FsFile& file, const std::string& s) {
file.write(reinterpret_cast<const uint8_t*>(s.data()), len);
}
static void readString(std::istream& is, std::string& s) {
constexpr uint32_t MAX_STRING_LENGTH = 4096;
static bool readString(std::istream& is, std::string& s) {
uint32_t len;
readPod(is, len);
if (len > MAX_STRING_LENGTH) {
is.seekg(len, std::ios::cur); // skip payload to keep stream aligned
return false;
}
s.resize(len);
is.read(&s[0], len);
return true;
}
static void readString(FsFile& file, std::string& s) {
static bool readString(FsFile& file, std::string& s) {
uint32_t len;
readPod(file, len);
if (len > MAX_STRING_LENGTH) {
if (!file.seekCur(static_cast<int64_t>(len))) { // skip payload to keep file position aligned
return false;
}
return false;
}
s.resize(len);
file.read(&s[0], len);
file.read(reinterpret_cast<uint8_t*>(&s[0]), len);
return true;
}
} // namespace serialization
+39 -18
View File
@@ -41,13 +41,14 @@ void RecentBooksStore::addBook(const std::string& path, const std::string& title
}
void RecentBooksStore::updateBook(const std::string& path, const std::string& title, const std::string& author,
const std::string& coverBmpPath) {
const std::string& series, const std::string& coverBmpPath) {
auto it =
std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; });
if (it != recentBooks.end()) {
RecentBook& book = *it;
book.title = title;
book.author = author;
book.series = series;
book.coverBmpPath = coverBmpPath;
saveToFile();
}
@@ -121,38 +122,57 @@ bool RecentBooksStore::loadFromBinaryFile() {
// Old version, just read paths
uint8_t count;
serialization::readPod(inputFile, count);
recentBooks.clear();
recentBooks.reserve(count);
std::vector<RecentBook> tmpRecentBooks;
tmpRecentBooks.reserve(count);
for (uint8_t i = 0; i < count; i++) {
std::string path;
serialization::readString(inputFile, path);
if (!serialization::readString(inputFile, path)) {
LOG_ERR("RBS", "Corrupt recent.bin: string too long at entry %u", i);
inputFile.close();
return false;
}
// load book to get missing data
RecentBook book = getDataFromBook(path);
if (book.title.empty() && book.author.empty() && version == 2) {
// Fall back to loading what we can from the store
std::string title, author;
serialization::readString(inputFile, title);
serialization::readString(inputFile, author);
recentBooks.push_back({path, title, author, "", ""});
if (version == 2) {
// v2 always stores title and author after path; consume them regardless
// of whether live metadata was found, to keep the stream aligned.
std::string storedTitle, storedAuthor;
if (!serialization::readString(inputFile, storedTitle) || !serialization::readString(inputFile, storedAuthor)) {
LOG_ERR("RBS", "Corrupt recent.bin: string too long at entry %u", i);
inputFile.close();
return false;
}
// Prefer live metadata; fall back to stored when live is unavailable.
const std::string& title = !book.title.empty() ? book.title : storedTitle;
const std::string& author = !book.title.empty() ? book.author : storedAuthor;
if (!title.empty()) {
tmpRecentBooks.push_back({path, title, author, "", ""});
}
} else {
recentBooks.push_back(book);
// v1: no stored title/author bytes
if (!book.title.empty()) {
tmpRecentBooks.push_back(book);
}
}
}
recentBooks = std::move(tmpRecentBooks);
} else if (version == 3) {
uint8_t count;
serialization::readPod(inputFile, count);
recentBooks.clear();
recentBooks.reserve(count);
std::vector<RecentBook> tmpRecentBooks;
tmpRecentBooks.reserve(count);
uint8_t omitted = 0;
for (uint8_t i = 0; i < count; i++) {
std::string path, title, author, coverBmpPath;
serialization::readString(inputFile, path);
serialization::readString(inputFile, title);
serialization::readString(inputFile, author);
serialization::readString(inputFile, coverBmpPath);
if (!serialization::readString(inputFile, path) || !serialization::readString(inputFile, title) ||
!serialization::readString(inputFile, author) || !serialization::readString(inputFile, coverBmpPath)) {
LOG_ERR("RBS", "Corrupt recent.bin: string too long at entry %u", i);
inputFile.close();
return false;
}
// Omit books with missing title (e.g. saved before metadata was available)
if (title.empty()) {
@@ -160,8 +180,9 @@ bool RecentBooksStore::loadFromBinaryFile() {
continue;
}
recentBooks.push_back({path, title, author, "", coverBmpPath});
tmpRecentBooks.push_back({path, title, author, "", coverBmpPath});
}
recentBooks = std::move(tmpRecentBooks);
if (omitted > 0) {
inputFile.close();
+1 -1
View File
@@ -36,7 +36,7 @@ class RecentBooksStore {
const std::string& coverBmpPath);
void updateBook(const std::string& path, const std::string& title, const std::string& author,
const std::string& coverBmpPath);
const std::string& series, const std::string& coverBmpPath);
// Get the list of recent books (most recent first)
const std::vector<RecentBook>& getBooks() const { return recentBooks; }
+2 -2
View File
@@ -74,7 +74,7 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
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, "");
RECENT_BOOKS.updateBook(book.path, book.title, book.author, book.series, "");
book.coverBmpPath = "";
}
coverRendered = false;
@@ -91,7 +91,7 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
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, "");
RECENT_BOOKS.updateBook(book.path, book.title, book.author, book.series, "");
book.coverBmpPath = "";
}
coverRendered = false;
+21 -2
View File
@@ -1,11 +1,13 @@
#include "RecentBooksActivity.h"
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <I18n.h>
#include <algorithm>
#include "BookInfoActivity.h"
#include "MappedInputManager.h"
#include "RecentBooksStore.h"
#include "components/UITheme.h"
@@ -59,6 +61,17 @@ void RecentBooksActivity::loop() {
onGoHome();
}
if (mappedInput.wasReleased(MappedInputManager::Button::Right)) {
if (!recentBooks.empty() && selectorIndex < static_cast<int>(recentBooks.size())) {
const std::string& path = recentBooks[selectorIndex].path;
if (FsHelpers::hasEpubExtension(path) || FsHelpers::hasXtcExtension(path)) {
startActivityForResult(std::make_unique<BookInfoActivity>(renderer, mappedInput, path),
[this](const ActivityResult&) { requestUpdate(); });
return;
}
}
}
int listSize = static_cast<int>(recentBooks.size());
buttonNavigator.onNextRelease([this, listSize] {
@@ -103,7 +116,7 @@ void RecentBooksActivity::render(RenderLock&&) {
[this](int index) { return recentBooks[index].title; },
[this](int index) {
const auto& book = recentBooks[index];
if (!book.author.empty() && !book.series.empty()) return book.author + " \u2022 " + book.series;
if (!book.author.empty() && !book.series.empty()) return book.author + "\n" + book.series;
if (!book.series.empty()) return book.series;
return book.author;
},
@@ -111,8 +124,14 @@ void RecentBooksActivity::render(RenderLock&&) {
}
// Help text
const auto labels = mappedInput.mapLabels(tr(STR_HOME), tr(STR_OPEN), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
const bool hasInfo = !recentBooks.empty() && selectorIndex < recentBooks.size() &&
(FsHelpers::hasEpubExtension(recentBooks[selectorIndex].path) ||
FsHelpers::hasXtcExtension(recentBooks[selectorIndex].path));
const auto labels = mappedInput.mapLabels(tr(STR_HOME), tr(STR_OPEN), "", hasInfo ? tr(STR_INFO) : "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
// Side buttons (Up/Down) navigate; show their hints on the side
GUI.drawSideButtonHints(renderer, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
renderer.displayBuffer();
}
+5 -1
View File
@@ -235,8 +235,12 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
renderer.drawText(font, rect.x + BaseMetrics::values.contentSidePadding, itemY, item.c_str(), i != selectedIndex);
if (rowSubtitle != nullptr) {
// Draw subtitle
// Draw subtitle; if the text is newline-separated (author\nseries), join with • for single-line display
std::string subtitleText = rowSubtitle(i);
const auto nl = subtitleText.find('\n');
if (nl != std::string::npos) {
subtitleText = subtitleText.substr(0, nl) + " \u2022 " + subtitleText.substr(nl + 1);
}
auto subtitle = renderer.truncatedText(UI_10_FONT_ID, subtitleText.c_str(), textWidth);
renderer.drawText(UI_10_FONT_ID, rect.x + BaseMetrics::values.contentSidePadding, itemY + 30, subtitle.c_str(),
i != selectedIndex);
+11 -3
View File
@@ -318,10 +318,18 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
}
if (rowSubtitle != nullptr) {
// Draw subtitle
std::string subtitleText = rowSubtitle(i);
auto subtitle = renderer.truncatedText(SMALL_FONT_ID, subtitleText.c_str(), rowTextWidth);
renderer.drawText(SMALL_FONT_ID, textX, itemY + 30, subtitle.c_str(), true);
const auto nl = subtitleText.find('\n');
if (nl != std::string::npos) {
// Two-line subtitle: first line (author) at +24, second line (series) at +40
auto line1 = renderer.truncatedText(SMALL_FONT_ID, subtitleText.substr(0, nl).c_str(), rowTextWidth);
renderer.drawText(SMALL_FONT_ID, textX, itemY + 24, line1.c_str(), true);
auto line2 = renderer.truncatedText(SMALL_FONT_ID, subtitleText.substr(nl + 1).c_str(), rowTextWidth);
renderer.drawText(SMALL_FONT_ID, textX, itemY + 40, line2.c_str(), true);
} else {
auto subtitle = renderer.truncatedText(SMALL_FONT_ID, subtitleText.c_str(), rowTextWidth);
renderer.drawText(SMALL_FONT_ID, textX, itemY + 30, subtitle.c_str(), true);
}
}
// Draw value
+1 -1
View File
@@ -14,7 +14,7 @@ constexpr ThemeMetrics values = {.batteryWidth = 16,
.verticalSpacing = 16,
.contentSidePadding = 20,
.listRowHeight = 40,
.listWithSubtitleRowHeight = 60,
.listWithSubtitleRowHeight = 68,
.menuRowHeight = 64,
.menuSpacing = 8,
.tabSpacing = 8,