Add character-offset bookmarks for FreeInkBook
Implements layout-independent bookmarks using character offsets within chapters instead of percentage-based positions. This allows bookmarks to restore to the exact position regardless of font size or orientation changes. Adds TOC navigation helpers, book progress calculation based on spine item sizes, and a NONE BidiBaseDir option for pre-reordered text. Legacy bookmarks without character offsets fall back to percentage-based positioning.
This commit is contained in:
@@ -11,4 +11,11 @@ struct BookmarkEntry {
|
||||
uint16_t computedSpineIndex = 0; // Spine index at the time of bookmarking
|
||||
uint16_t computedChapterPageCount = 0; // Total page count of the chapter at the time of bookmarking
|
||||
uint16_t computedChapterProgress = 0; // Number of pages into the chapter at the time of bookmarking
|
||||
|
||||
// FreeInkBook locator: chapter character offset of the bookmarked page.
|
||||
// Layout-parameter independent, so it restores exactly at any font size or
|
||||
// orientation. Entries written before the engine swap lack it (hasCharStart
|
||||
// false) and fall back to the percentage fields above.
|
||||
uint32_t charStart = 0;
|
||||
bool hasCharStart = false;
|
||||
};
|
||||
@@ -424,6 +424,7 @@ bool JsonSettingsIO::saveBookmarks(const std::vector<BookmarkEntry>& bookmarks,
|
||||
obj["si"] = bookmark.computedSpineIndex;
|
||||
obj["pc"] = bookmark.computedChapterPageCount;
|
||||
obj["pp"] = bookmark.computedChapterProgress;
|
||||
if (bookmark.hasCharStart) obj["cs"] = bookmark.charStart;
|
||||
}
|
||||
|
||||
String json;
|
||||
@@ -451,6 +452,8 @@ bool JsonSettingsIO::loadBookmarks(std::vector<BookmarkEntry>& bookmarks, const
|
||||
bookmark.computedSpineIndex = obj["si"] | static_cast<uint16_t>(0);
|
||||
bookmark.computedChapterPageCount = obj["pc"] | static_cast<uint16_t>(0);
|
||||
bookmark.computedChapterProgress = obj["pp"] | static_cast<uint16_t>(0);
|
||||
bookmark.hasCharStart = !obj["cs"].isNull();
|
||||
bookmark.charStart = obj["cs"] | static_cast<uint32_t>(0);
|
||||
}
|
||||
|
||||
LOG_DBG("BKM", "Loaded %zu bookmarks from file", bookmarks.size());
|
||||
|
||||
@@ -47,6 +47,9 @@ struct ProgressChangeResult {
|
||||
std::string xpath;
|
||||
float percentage = 0.0f;
|
||||
bool hasSavedProgress = false;
|
||||
// FreeInkBook locator (chapter character offset); exact when present.
|
||||
uint32_t charStart = 0;
|
||||
bool hasCharStart = false;
|
||||
};
|
||||
|
||||
enum class NetworkMode;
|
||||
|
||||
@@ -53,7 +53,8 @@ class ProgressSink : public freeink::book::PageSink {
|
||||
|
||||
} // namespace
|
||||
|
||||
bool BookPaginator::open(const std::string& path, const std::string& cacheDir, GfxRenderer& renderer) {
|
||||
bool BookPaginator::open(const std::string& path, const std::string& cacheDir, GfxRenderer& renderer,
|
||||
const bool forcePlainText) {
|
||||
close();
|
||||
|
||||
bookBuf_ = makeUniqueNoThrow<uint8_t[]>(kBookArenaSize);
|
||||
@@ -78,7 +79,7 @@ bool BookPaginator::open(const std::string& path, const std::string& cacheDir, G
|
||||
}
|
||||
|
||||
const size_t len = path.size();
|
||||
isTxt_ = len > 4 && strcasecmp(path.c_str() + len - 4, ".txt") == 0;
|
||||
isTxt_ = forcePlainText || (len > 4 && strcasecmp(path.c_str() + len - 4, ".txt") == 0);
|
||||
|
||||
if (!isTxt_) {
|
||||
// Container open + book stylesheet need parse scratch; both are
|
||||
@@ -269,9 +270,7 @@ uint32_t BookPaginator::fontFingerprint() const {
|
||||
return hash;
|
||||
}
|
||||
|
||||
uint32_t BookPaginator::generation() const {
|
||||
return freeink::book::layoutGenerationHash(params_, fontFingerprint());
|
||||
}
|
||||
uint32_t BookPaginator::generation() const { return freeink::book::layoutGenerationHash(params_, fontFingerprint()); }
|
||||
|
||||
freeink::book::BookStatus BookPaginator::ensureChapter(const uint16_t spineIndex, const BuildProgress& progress) {
|
||||
const uint32_t gen = generation();
|
||||
@@ -348,6 +347,84 @@ int BookPaginator::spineIndexForHref(const char* href) const {
|
||||
return -1;
|
||||
}
|
||||
|
||||
BookPaginator::TocItem BookPaginator::tocItem(const size_t index) const {
|
||||
TocItem out{"", nullptr, -1, 0};
|
||||
const freeink::book::TocEntry* entry = isTxt_ ? nullptr : book_.tocEntry(index);
|
||||
if (entry == nullptr) return out;
|
||||
out.title = entry->title;
|
||||
out.fragment = entry->fragment;
|
||||
out.depth = entry->depth;
|
||||
out.spineIndex = spineIndexForHref(entry->href);
|
||||
return out;
|
||||
}
|
||||
|
||||
int BookPaginator::tocIndexForSpine(const int spineIndex) const {
|
||||
// The chapter's title is the last TOC entry at or before this spine item
|
||||
// (a spine item without its own entry belongs to the preceding heading).
|
||||
int best = -1;
|
||||
int bestSpine = -1;
|
||||
for (size_t t = 0; t < tocCount(); ++t) {
|
||||
const int s = tocItem(t).spineIndex;
|
||||
if (s < 0 || s > spineIndex) continue;
|
||||
if (s >= bestSpine) {
|
||||
bestSpine = s;
|
||||
best = static_cast<int>(t);
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// Spine weights use the uncompressed sizes already in the ZIP catalog — the
|
||||
// same "bigger chapters cover more of the book" heuristic the legacy engine
|
||||
// used, with zero extra state.
|
||||
float BookPaginator::bookProgress(const int spineIndex, const float chapterFraction) const {
|
||||
if (isTxt_) return chapterFraction;
|
||||
uint64_t before = 0;
|
||||
uint64_t current = 0;
|
||||
uint64_t total = 0;
|
||||
for (size_t s = 0; s < book_.spineCount(); ++s) {
|
||||
const ManifestItem* item = book_.spineItem(s);
|
||||
const freeink::book::ZipEntry* e = item != nullptr ? book_.zip().find(item->href) : nullptr;
|
||||
const uint32_t size = e != nullptr ? e->uncompressedSize : 0;
|
||||
if (static_cast<int>(s) < spineIndex) before += size;
|
||||
if (static_cast<int>(s) == spineIndex) current = size;
|
||||
total += size;
|
||||
}
|
||||
if (total == 0) return 0.0f;
|
||||
const float f = chapterFraction < 0.0f ? 0.0f : (chapterFraction > 1.0f ? 1.0f : chapterFraction);
|
||||
return (static_cast<float>(before) + f * static_cast<float>(current)) / static_cast<float>(total);
|
||||
}
|
||||
|
||||
int BookPaginator::spineForBookFraction(const float bookFraction, float* chapterFractionOut) const {
|
||||
if (chapterFractionOut != nullptr) *chapterFractionOut = 0.0f;
|
||||
if (isTxt_ || book_.spineCount() == 0) {
|
||||
if (chapterFractionOut != nullptr) *chapterFractionOut = bookFraction;
|
||||
return 0;
|
||||
}
|
||||
uint64_t total = 0;
|
||||
for (size_t s = 0; s < book_.spineCount(); ++s) {
|
||||
const ManifestItem* item = book_.spineItem(s);
|
||||
const freeink::book::ZipEntry* e = item != nullptr ? book_.zip().find(item->href) : nullptr;
|
||||
total += e != nullptr ? e->uncompressedSize : 0;
|
||||
}
|
||||
const float f = bookFraction < 0.0f ? 0.0f : (bookFraction > 1.0f ? 1.0f : bookFraction);
|
||||
const uint64_t target = static_cast<uint64_t>(f * static_cast<float>(total));
|
||||
uint64_t cumulative = 0;
|
||||
for (size_t s = 0; s < book_.spineCount(); ++s) {
|
||||
const ManifestItem* item = book_.spineItem(s);
|
||||
const freeink::book::ZipEntry* e = item != nullptr ? book_.zip().find(item->href) : nullptr;
|
||||
const uint32_t size = e != nullptr ? e->uncompressedSize : 0;
|
||||
if (target < cumulative + size || s + 1 == book_.spineCount()) {
|
||||
if (chapterFractionOut != nullptr && size > 0) {
|
||||
*chapterFractionOut = static_cast<float>(target - cumulative) / static_cast<float>(size);
|
||||
}
|
||||
return static_cast<int>(s);
|
||||
}
|
||||
cumulative += size;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int BookPaginator::fontIdForRunSize(const uint16_t sizePx) const {
|
||||
const uint16_t q = adapters_[0].quantize(sizePx);
|
||||
for (uint8_t i = 0; i < ladderCount_; ++i) {
|
||||
|
||||
@@ -47,15 +47,38 @@ class BookPaginator {
|
||||
|
||||
// Opens the container and builds the font chain. `cacheDir` is the
|
||||
// per-book directory (".crosspoint/epub_<hash>"). Plain-text files open
|
||||
// as a one-chapter book with no container.
|
||||
bool open(const std::string& path, const std::string& cacheDir, GfxRenderer& renderer);
|
||||
// as a one-chapter book with no container — detected by .txt extension, or
|
||||
// forced via `forcePlainText` (markdown files read as plain text).
|
||||
bool open(const std::string& path, const std::string& cacheDir, GfxRenderer& renderer, bool forcePlainText = false);
|
||||
void close();
|
||||
bool isOpen() const { return open_; }
|
||||
bool isTxt() const { return isTxt_; }
|
||||
|
||||
freeink::book::Book& book() { return book_; }
|
||||
freeink::book::BookSource* bookSource() { return &source_; }
|
||||
size_t spineCount() const { return isTxt_ ? 1 : book_.spineCount(); }
|
||||
const char* language() const;
|
||||
const char* title() const { return isTxt_ ? "" : book_.metadata().title; }
|
||||
const char* author() const { return isTxt_ ? "" : book_.metadata().author; }
|
||||
|
||||
// --- TOC (flattened, resolved to spine indices) --------------------------
|
||||
struct TocItem {
|
||||
const char* title;
|
||||
const char* fragment; // anchor within the chapter, or nullptr
|
||||
int spineIndex; // -1 when the href is not a spine item
|
||||
uint8_t depth;
|
||||
};
|
||||
size_t tocCount() const { return isTxt_ ? 0 : book_.tocCount(); }
|
||||
TocItem tocItem(size_t index) const;
|
||||
// First TOC entry pointing at `spineIndex` or an earlier chapter (the
|
||||
// chapter's display title); -1 when the TOC has no such entry.
|
||||
int tocIndexForSpine(int spineIndex) const;
|
||||
|
||||
// --- whole-book progress (uncompressed spine byte weights) ---------------
|
||||
// Fraction of the book at (spine, fraction-within-chapter), 0..1.
|
||||
float bookProgress(int spineIndex, float chapterFraction) const;
|
||||
// Inverse: which spine (and where inside it) a whole-book fraction lands.
|
||||
int spineForBookFraction(float bookFraction, float* chapterFractionOut) const;
|
||||
|
||||
// Refreshes LayoutParams from SETTINGS and the given content box. Must be
|
||||
// called before ensureChapter() and after any settings/orientation change;
|
||||
|
||||
@@ -51,8 +51,7 @@ int16_t CpFontAdapter::ascent(const uint16_t sizePx) {
|
||||
return family != nullptr ? static_cast<int16_t>(family->getData(style_)->ascender) : 0;
|
||||
}
|
||||
|
||||
int16_t CpFontAdapter::kerning(const uint32_t left, const uint32_t right, const uint16_t sizePx,
|
||||
uint8_t) {
|
||||
int16_t CpFontAdapter::kerning(const uint32_t left, const uint32_t right, const uint16_t sizePx, uint8_t) {
|
||||
if (utf8IsCombiningMark(left) || utf8IsCombiningMark(right)) return 0;
|
||||
const EpdFontFamily* family = familyFor(sizePx);
|
||||
if (family == nullptr) return 0;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,100 +1,79 @@
|
||||
#pragma once
|
||||
#include <Epub.h>
|
||||
#include <Epub/FootnoteEntry.h>
|
||||
#include <Epub/Section.h>
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "BookPaginator.h"
|
||||
#include "BookmarkEntry.h"
|
||||
#include "EndOfBookOptions.h"
|
||||
#include "EpubReaderMenuActivity.h"
|
||||
#include "ProgressMapper.h"
|
||||
#include "activities/Activity.h"
|
||||
|
||||
// EPUB reading UI over the FreeInkBook engine (BookPaginator). Position is
|
||||
// tracked as (spineIndex, charStart) — the chapter character offset of the
|
||||
// current page — which survives every layout-parameter change; the page
|
||||
// number is derived per generation via pageForChar().
|
||||
class EpubReaderActivity final : public Activity {
|
||||
std::shared_ptr<Epub> epub;
|
||||
std::unique_ptr<Section> section = nullptr;
|
||||
std::string path_;
|
||||
std::string cacheDir_;
|
||||
BookPaginator paginator;
|
||||
|
||||
int currentSpineIndex = 0;
|
||||
int nextPageNumber = 0;
|
||||
std::optional<uint16_t> pendingPageJump;
|
||||
// Set when navigating to a footnote href with a fragment (e.g. #note1).
|
||||
// Cleared on the next render after the new section loads and resolves it to a page.
|
||||
uint32_t currentPage = 0;
|
||||
// charStart of the page currently shown — the anchor that re-derives the
|
||||
// page after any re-pagination (settings/orientation change).
|
||||
uint32_t lastCharStart = 0;
|
||||
// The generation the open chapter was paginated for; a mismatch in render()
|
||||
// (settings changed while a menu was up) triggers reopen + reanchor.
|
||||
uint32_t openGeneration = 0;
|
||||
bool chapterOpen = false;
|
||||
|
||||
// Pending landing position, applied once the target chapter's cache is
|
||||
// open (charStart wins over fraction; anchor wins over both).
|
||||
std::optional<uint32_t> pendingCharStart;
|
||||
std::optional<float> pendingChapterFraction;
|
||||
bool pendingLastPage = false;
|
||||
std::string pendingAnchor;
|
||||
|
||||
int pagesUntilFullRefresh = 0;
|
||||
int cachedSpineIndex = 0;
|
||||
int cachedChapterTotalPageCount = 0;
|
||||
unsigned long lastPageTurnTime = 0UL;
|
||||
unsigned long pageTurnDuration = 0UL;
|
||||
// Signals that the next render should reposition within the newly loaded section
|
||||
// based on a cross-book percentage jump.
|
||||
bool pendingPercentJump = false;
|
||||
// Normalized 0.0-1.0 progress within the target spine item, computed from book percentage.
|
||||
float pendingSpineProgress = 0.0f;
|
||||
bool pendingScreenshot = false;
|
||||
bool pendingSyncSaveError = false;
|
||||
bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit
|
||||
bool automaticPageTurnActive = false;
|
||||
bool showBookmarkMessage = false;
|
||||
bool ignoreNextConfirmRelease = false;
|
||||
bool currentPageBookmarked = false;
|
||||
bool bookmarkRemoved = false; // true when last toggle removed (controls popup text)
|
||||
bool buildPopupShown = false; // indexing popup drawn for the current build
|
||||
std::vector<BookmarkEntry> cachedBookmarks;
|
||||
// Tracks whether this book is currently removed from Recent Books by the
|
||||
// removeReadBooksFromRecents feature (set at End-of-Book, cleared if paged back in).
|
||||
bool recentsEntryRemoved = false;
|
||||
unsigned long bookmarkMessageTime = 0UL;
|
||||
// Set when the reader is left at end-of-book and SETTINGS.moveFinishedToReadFolder is on.
|
||||
// Consumed in onExit() to relocate the finished book into /Read/.
|
||||
bool pendingReadFolderMove = false;
|
||||
// Next-book suggestion menu for the End-of-Book screen
|
||||
EndOfBookOptions endOfBookOptions;
|
||||
|
||||
// Footnote support
|
||||
std::vector<FootnoteEntry> currentPageFootnotes;
|
||||
struct SavedPosition {
|
||||
int spineIndex;
|
||||
int pageNumber;
|
||||
uint32_t charStart;
|
||||
};
|
||||
static constexpr int MAX_FOOTNOTE_DEPTH = 3;
|
||||
SavedPosition savedPositions[MAX_FOOTNOTE_DEPTH] = {};
|
||||
int footnoteDepth = 0;
|
||||
|
||||
void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight,
|
||||
int orientedMarginBottom, int orientedMarginLeft);
|
||||
// Opens (paginating if needed) the chapter for currentSpineIndex under the
|
||||
// current generation and resolves pending landing state into currentPage.
|
||||
bool ensureChapterAndPosition();
|
||||
void renderPage(const freeink::book::Page& page, int statusBarSpace);
|
||||
void renderStatusBar() const;
|
||||
// Pages laid out per incremental-build pump: on the render path (catching up to the page
|
||||
// being shown) and per loop() tick (background build of a large chapter). Kept small so a
|
||||
// background build chunk never noticeably delays input or a pending render.
|
||||
static constexpr int BUILD_PAGES_PER_CHUNK = 8;
|
||||
static constexpr int BACKGROUND_BUILD_PAGES_PER_TICK = 2;
|
||||
// How many pages to keep laid out ahead of the reader for a still-building section. A page
|
||||
// turn is ~1s on e-ink and a page builds in ~30ms, so the reader can't out-click the builder
|
||||
// -- a tiny buffer is enough. The background build stops once the watermark is this far
|
||||
// ahead and resumes as the reader advances; building unbounded instead locked up input by
|
||||
// monopolizing the RenderLock. A giant single-spine book therefore never finalizes its .bin
|
||||
// in one sitting -- instant reopen comes from Section::suspendBuild() persisting the pages
|
||||
// already laid out as a partial file on exit/sleep.
|
||||
static constexpr int BUILD_WINDOW_AHEAD = 5;
|
||||
// Show the indexing popup when an initial build must lay out more than this many pages up front
|
||||
// (a deep resume/jump into a not-yet-built section), so it isn't a silent wait. Kept independent
|
||||
// of the small look-ahead window so ordinary landings stay popup-free.
|
||||
static constexpr int BUILD_POPUP_PAGE_THRESHOLD = 20;
|
||||
// Also show the popup when first building a spine larger than this (uncompressed bytes): its
|
||||
// whole HTML must be inflated before page 1 can lay out (the giant single-spine case), which is
|
||||
// a multi-second wait. Normal chapters are well under this and stay popup-free.
|
||||
static constexpr size_t BUILD_POPUP_BYTE_THRESHOLD = 96 * 1024;
|
||||
// Remap the cached relative reading position once the section's real page count is known
|
||||
// (used after a settings change re-paginates a chapter). Returns true if currentPage moved.
|
||||
// No-op while the section is still building or when the pagination is unchanged (plain resume).
|
||||
bool applyDeferredReposition();
|
||||
bool saveProgress(int spineIndex, int currentPage, int pageCount);
|
||||
// Jump to a percentage of the book (0-100), mapping it to spine and page.
|
||||
bool saveProgress();
|
||||
float currentBookFraction() const;
|
||||
void jumpToPercent(int percent);
|
||||
void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action);
|
||||
// Opens the reader menu for the current position (short-press Confirm)
|
||||
void openReaderMenu();
|
||||
// Returns true if sync acted (launched, or surfaced a save error); false if it was a no-op
|
||||
// because no KOReader credentials are stored.
|
||||
bool launchKOReaderSync();
|
||||
void applyOrientation(uint8_t orientation);
|
||||
void toggleAutoPageTurn(uint8_t selectedPageTurnOption);
|
||||
@@ -102,19 +81,19 @@ class EpubReaderActivity final : public Activity {
|
||||
void loadCachedBookmarks();
|
||||
void addBookmark();
|
||||
void updateBookmarkFlag();
|
||||
std::string currentPageText();
|
||||
|
||||
// Footnote navigation
|
||||
void navigateToHref(const std::string& href, bool savePosition = false);
|
||||
void restoreSavedPosition();
|
||||
|
||||
public:
|
||||
explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Epub> epub)
|
||||
: Activity("EpubReader", renderer, mappedInput), epub(std::move(epub)) {}
|
||||
explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string path)
|
||||
: Activity("EpubReader", renderer, mappedInput), path_(std::move(path)) {}
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&& lock) override;
|
||||
bool isReaderActivity() const override { return true; }
|
||||
ScreenshotInfo getScreenshotInfo() const override;
|
||||
CrossPointPosition getCurrentPosition() const;
|
||||
};
|
||||
|
||||
@@ -25,10 +25,6 @@ constexpr int LINE_HEIGHT = 60;
|
||||
void EpubReaderBookmarksActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
if (!epub) {
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string path = BookmarkUtil::getBookmarkPath(epubPath);
|
||||
if (Storage.exists(path.c_str())) {
|
||||
String json = Storage.readFile(path.c_str());
|
||||
@@ -111,8 +107,14 @@ void EpubReaderBookmarksActivity::loop() {
|
||||
result.xpath = bookmark.xpath;
|
||||
result.percentage = bookmark.percentage;
|
||||
result.hasSavedProgress = true;
|
||||
if (bookmark.computedChapterPageCount > 0 && bookmark.computedChapterProgress < bookmark.computedChapterPageCount &&
|
||||
bookmark.computedSpineIndex < epub->getSpineItemsCount()) {
|
||||
if (bookmark.hasCharStart && bookmark.computedSpineIndex < paginator.spineCount()) {
|
||||
// FreeInkBook locator: exact landing at any layout settings.
|
||||
result.spineIndex = bookmark.computedSpineIndex;
|
||||
result.charStart = bookmark.charStart;
|
||||
result.hasCharStart = true;
|
||||
} else if (bookmark.computedChapterPageCount > 0 &&
|
||||
bookmark.computedChapterProgress < bookmark.computedChapterPageCount &&
|
||||
bookmark.computedSpineIndex < paginator.spineCount()) {
|
||||
result.spineIndex = bookmark.computedSpineIndex;
|
||||
result.page = bookmark.computedChapterProgress;
|
||||
result.totalPages = bookmark.computedChapterPageCount;
|
||||
@@ -192,8 +194,8 @@ void EpubReaderBookmarksActivity::render(RenderLock&&) {
|
||||
};
|
||||
const auto getBookmarkSubtitle = [this](int index) {
|
||||
auto bookmark = bookmarks.at(confirmingDelete >= DELETE_MODE_DISPLAY ? selectorIndex : index);
|
||||
auto tocIndex = epub->getTocIndexForSpineIndex(bookmark.computedSpineIndex);
|
||||
auto tocTitle = (tocIndex >= 0) ? (epub->getTocItem(tocIndex)).title : tr(STR_UNNAMED);
|
||||
const int tocIndex = paginator.tocIndexForSpine(bookmark.computedSpineIndex);
|
||||
const std::string tocTitle = (tocIndex >= 0) ? paginator.tocItem(tocIndex).title : tr(STR_UNNAMED);
|
||||
std::string subtitle = std::to_string((int)(std::clamp(bookmark.percentage, 0.0f, 1.0f) * 100.0f + 0.5f)) + "% - ";
|
||||
if (bookmark.computedChapterPageCount > 0) {
|
||||
subtitle += std::to_string(bookmark.computedChapterProgress + 1) + "/" +
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
#pragma once
|
||||
#include <Epub.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include "../../BookmarkEntry.h"
|
||||
#include "../Activity.h"
|
||||
#include "BookPaginator.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
class EpubReaderBookmarksActivity final : public Activity {
|
||||
std::shared_ptr<Epub> epub;
|
||||
BookPaginator& paginator;
|
||||
std::string epubPath;
|
||||
ButtonNavigator buttonNavigator;
|
||||
int selectorIndex = 0;
|
||||
@@ -16,9 +13,9 @@ class EpubReaderBookmarksActivity final : public Activity {
|
||||
int confirmingDelete = 0; // 0 = hide dialog, 1 = show dialog, 2 = allow confirmation to delete
|
||||
|
||||
public:
|
||||
explicit EpubReaderBookmarksActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::shared_ptr<Epub>& epub, const std::string& epubPath)
|
||||
: Activity("EpubReaderBookmarks", renderer, mappedInput), epub(epub), epubPath(epubPath) {}
|
||||
explicit EpubReaderBookmarksActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, BookPaginator& paginator,
|
||||
const std::string& epubPath)
|
||||
: Activity("EpubReaderBookmarks", renderer, mappedInput), paginator(paginator), epubPath(epubPath) {}
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
|
||||
@@ -7,21 +7,16 @@
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
int EpubReaderChapterSelectionActivity::getTotalItems() const { return epub->getTocItemsCount(); }
|
||||
int EpubReaderChapterSelectionActivity::getTotalItems() const { return static_cast<int>(paginator.tocCount()); }
|
||||
|
||||
void EpubReaderChapterSelectionActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
if (!epub) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectorIndex = epub->getTocIndexForSpineIndex(currentSpineIndex);
|
||||
selectorIndex = paginator.tocIndexForSpine(currentSpineIndex);
|
||||
if (selectorIndex == -1) {
|
||||
selectorIndex = 0;
|
||||
}
|
||||
|
||||
// Trigger first update
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
@@ -32,14 +27,14 @@ void EpubReaderChapterSelectionActivity::loop() {
|
||||
const int totalItems = getTotalItems();
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
const auto tocItem = epub->getTocItem(selectorIndex);
|
||||
const auto tocItem = paginator.tocItem(selectorIndex);
|
||||
if (tocItem.spineIndex == -1) {
|
||||
ActivityResult result;
|
||||
result.isCancelled = true;
|
||||
setResult(std::move(result));
|
||||
finish();
|
||||
} else {
|
||||
setResult(ChapterResult{tocItem.spineIndex, tocItem.anchor});
|
||||
setResult(ChapterResult{tocItem.spineIndex, tocItem.fragment != nullptr ? tocItem.fragment : ""});
|
||||
finish();
|
||||
}
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
@@ -85,8 +80,8 @@ void EpubReaderChapterSelectionActivity::render(RenderLock&&) {
|
||||
const int totalItems = getTotalItems();
|
||||
GUI.drawList(renderer, Rect{screen.x, contentTop, screen.width, contentHeight}, totalItems, selectorIndex,
|
||||
[this](int index) {
|
||||
auto item = epub->getTocItem(index);
|
||||
std::string indent((item.level - 1) * 2, ' ');
|
||||
const auto item = paginator.tocItem(index);
|
||||
std::string indent(static_cast<size_t>(item.depth) * 2, ' ');
|
||||
return indent + item.title;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,32 +1,22 @@
|
||||
#pragma once
|
||||
#include <Epub.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include "BookPaginator.h"
|
||||
#include "activities/Activity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
class EpubReaderChapterSelectionActivity final : public Activity {
|
||||
std::shared_ptr<Epub> epub;
|
||||
std::string epubPath;
|
||||
BookPaginator& paginator;
|
||||
ButtonNavigator buttonNavigator;
|
||||
int currentSpineIndex = 0;
|
||||
int selectorIndex = 0;
|
||||
|
||||
// Number of items that fit on a page, derived from logical screen height.
|
||||
// This adapts automatically when switching between portrait and landscape.
|
||||
int getPageItems() const;
|
||||
|
||||
// Total TOC items count
|
||||
int getTotalItems() const;
|
||||
|
||||
public:
|
||||
explicit EpubReaderChapterSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::shared_ptr<Epub>& epub, const std::string& epubPath,
|
||||
const int currentSpineIndex)
|
||||
BookPaginator& paginator, const int currentSpineIndex)
|
||||
: Activity("EpubReaderChapterSelection", renderer, mappedInput),
|
||||
epub(epub),
|
||||
epubPath(epubPath),
|
||||
paginator(paginator),
|
||||
currentSpineIndex(currentSpineIndex) {}
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
|
||||
@@ -1,31 +1,77 @@
|
||||
#pragma once
|
||||
|
||||
#include <Epub.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
#include "ProgressFile.h"
|
||||
|
||||
namespace EpubReaderUtils {
|
||||
|
||||
// Persists reader progress for an EPUB to its cache directory. Returns true on success.
|
||||
inline bool saveProgress(const Epub& epub, int spineIndex, int pageNumber, int pageCount) {
|
||||
if (spineIndex < 0 || spineIndex > 0xFFFF || pageNumber < 0 || pageNumber > 0xFFFF || pageCount < 0 ||
|
||||
pageCount > 0xFFFF) {
|
||||
LOG_ERR("ERS", "Progress values out of range: spine=%d page=%d count=%d", spineIndex, pageNumber, pageCount);
|
||||
// Reader progress, FreeInkBook locator model. `charStart` (chapter character
|
||||
// offset) is layout-parameter independent — it restores exactly across font,
|
||||
// margin, spacing, and orientation changes. When a position is known only as
|
||||
// a fraction of the chapter (KOReader remote sync, legacy migration), it is
|
||||
// carried as `fractionQ16` with charStart == kNoCharStart and resolved
|
||||
// against totalChars() once the chapter's page cache is open.
|
||||
struct Progress {
|
||||
uint16_t spineIndex = 0;
|
||||
uint32_t charStart = 0;
|
||||
uint32_t fractionQ16 = 0; // chapter fraction in Q16, used when charStart == kNoCharStart
|
||||
bool valid = false;
|
||||
};
|
||||
|
||||
constexpr uint32_t kNoCharStart = 0xFFFFFFFFu;
|
||||
|
||||
// progress.bin v2: 'F','2', u16 spine, u32 charStart, u32 fractionQ16 (12 B,
|
||||
// little-endian). Legacy v1 files (4 or 6 B: u16 spine, u16 page[, u16
|
||||
// pageCount]) migrate on read to a chapter fraction — a sentence-accurate
|
||||
// landing that becomes exact the first time v2 progress is saved.
|
||||
inline bool saveProgress(const std::string& cachePath, const uint16_t spineIndex, const uint32_t charStart,
|
||||
const uint32_t fractionQ16 = 0) {
|
||||
uint8_t data[12];
|
||||
data[0] = 'F';
|
||||
data[1] = '2';
|
||||
data[2] = spineIndex & 0xFF;
|
||||
data[3] = (spineIndex >> 8) & 0xFF;
|
||||
for (int i = 0; i < 4; ++i) data[4 + i] = (charStart >> (8 * i)) & 0xFF;
|
||||
for (int i = 0; i < 4; ++i) data[8 + i] = (fractionQ16 >> (8 * i)) & 0xFF;
|
||||
if (!ProgressFile::writeAtomic(cachePath, data, sizeof(data))) {
|
||||
return false;
|
||||
}
|
||||
uint8_t data[6];
|
||||
data[0] = spineIndex & 0xFF;
|
||||
data[1] = (spineIndex >> 8) & 0xFF;
|
||||
data[2] = pageNumber & 0xFF;
|
||||
data[3] = (pageNumber >> 8) & 0xFF;
|
||||
data[4] = pageCount & 0xFF;
|
||||
data[5] = (pageCount >> 8) & 0xFF;
|
||||
if (!ProgressFile::writeAtomic(epub.getCachePath(), data, sizeof(data))) {
|
||||
return false;
|
||||
}
|
||||
LOG_DBG("ERS", "Progress saved: spine=%d page=%d", spineIndex, pageNumber);
|
||||
LOG_DBG("ERS", "Progress saved: spine=%u char=%u", spineIndex, static_cast<unsigned>(charStart));
|
||||
return true;
|
||||
}
|
||||
|
||||
inline Progress loadProgress(const std::string& cachePath) {
|
||||
Progress p;
|
||||
HalFile f;
|
||||
if (!Storage.openFileForRead("ERS", cachePath + "/progress.bin", f)) {
|
||||
return p;
|
||||
}
|
||||
uint8_t data[12];
|
||||
const int n = f.read(data, sizeof(data));
|
||||
if (n == 12 && data[0] == 'F' && data[1] == '2') {
|
||||
p.spineIndex = data[2] | (data[3] << 8);
|
||||
p.charStart = 0;
|
||||
p.fractionQ16 = 0;
|
||||
for (int i = 0; i < 4; ++i) p.charStart |= static_cast<uint32_t>(data[4 + i]) << (8 * i);
|
||||
for (int i = 0; i < 4; ++i) p.fractionQ16 |= static_cast<uint32_t>(data[8 + i]) << (8 * i);
|
||||
p.valid = true;
|
||||
return p;
|
||||
}
|
||||
if (n == 4 || n == 6) { // legacy (spine, page[, pageCount]) — migrate to a fraction
|
||||
p.spineIndex = data[0] | (data[1] << 8);
|
||||
const uint16_t page = data[2] | (data[3] << 8);
|
||||
const uint16_t pageCount = n == 6 ? (data[4] | (data[5] << 8)) : 0;
|
||||
p.charStart = kNoCharStart;
|
||||
p.fractionQ16 =
|
||||
(pageCount > 0 && page != UINT16_MAX && page < pageCount) ? (static_cast<uint32_t>(page) << 16) / pageCount : 0;
|
||||
p.valid = true;
|
||||
LOG_INF("ERS", "Migrated legacy progress: spine=%u page=%u/%u", p.spineIndex, page, pageCount);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
} // namespace EpubReaderUtils
|
||||
|
||||
@@ -73,13 +73,11 @@ class SdCacheStorage : public freeink::book::CacheStorage {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool write(const void* data, uint32_t len) override {
|
||||
return write_.isOpen() && write_.write(data, len) == len;
|
||||
}
|
||||
bool write(const void* data, uint32_t len) override { return write_.isOpen() && write_.write(data, len) == len; }
|
||||
|
||||
bool endWrite() override {
|
||||
if (!write_.isOpen()) return false;
|
||||
write_.close(); // must close before rename (DESTRUCTOR_CLOSES_FILE covers scope exit only)
|
||||
write_.close(); // must close before rename (DESTRUCTOR_CLOSES_FILE covers scope exit only)
|
||||
Storage.remove(commitPath_); // may not exist; rename below is the commit point
|
||||
if (!Storage.rename(path(kTempName), commitPath_)) {
|
||||
LOG_ERR("FIBCACHE", "commit rename failed: %s", commitPath_);
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
#include "FreeInkPageRenderer.h"
|
||||
|
||||
#include <FontCacheManager.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <Memory.h>
|
||||
#include <render/ImageRenderer.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
|
||||
#include "BookPaginator.h"
|
||||
#include "CrossPointSettings.h"
|
||||
|
||||
using freeink::book::Page;
|
||||
using freeink::book::PageImage;
|
||||
using freeink::book::PageTextRun;
|
||||
|
||||
namespace {
|
||||
|
||||
// Decode scratch for one image: inflate window + PNG/JPEG decoder state.
|
||||
constexpr size_t kImageScratchSize = 72 * 1024;
|
||||
|
||||
// 4x4 Bayer matrix (0..15) for quantizing the two mid-gray levels in BW mode.
|
||||
constexpr uint8_t kBayer4[4][4] = {{0, 8, 2, 10}, {12, 4, 14, 6}, {3, 11, 1, 9}, {15, 7, 13, 5}};
|
||||
|
||||
EpdFontFamily::Style styleFor(const uint8_t flags) {
|
||||
// Only the face-selecting bits: the engine pre-shifts sub/sup baselines and
|
||||
// pre-sizes their runs, and underline is drawn as a rect below.
|
||||
uint8_t s = EpdFontFamily::REGULAR;
|
||||
if (flags & freeink::book::StyleBold) s |= EpdFontFamily::BOLD;
|
||||
if (flags & freeink::book::StyleItalic) s |= EpdFontFamily::ITALIC;
|
||||
return static_cast<EpdFontFamily::Style>(s);
|
||||
}
|
||||
|
||||
// --- 2-bit image cache -----------------------------------------------------
|
||||
//
|
||||
// File: u16 width, u16 height, then rows packed 4 pixels/byte, MSB-first,
|
||||
// values in screen convention (0 = black .. 3 = white).
|
||||
|
||||
void imageCachePath(const std::string& cacheDir, const PageImage& img, char* out, const size_t outCap) {
|
||||
snprintf(out, outCap, "%s/i%08x_%ux%u.g2", cacheDir.c_str(), freeink::book::ZipCatalog::hashPath(img.href), img.width,
|
||||
img.height);
|
||||
}
|
||||
|
||||
struct G2Writer {
|
||||
HalFile file;
|
||||
uint8_t rowBuf[512]; // packed row, up to 2048 px wide
|
||||
uint16_t width = 0;
|
||||
bool failed = false;
|
||||
|
||||
static bool onRow(void* user, const uint16_t y, const uint8_t* gray, const uint16_t width) {
|
||||
(void)y;
|
||||
auto* self = static_cast<G2Writer*>(user);
|
||||
if (self->failed || width != self->width || (width + 3u) / 4u > sizeof(self->rowBuf)) {
|
||||
self->failed = true;
|
||||
return false;
|
||||
}
|
||||
const uint16_t rowBytes = (width + 3) / 4;
|
||||
memset(self->rowBuf, 0, rowBytes);
|
||||
for (uint16_t i = 0; i < width; ++i) {
|
||||
const uint8_t level = gray[i] >> 6; // 0=black .. 3=white
|
||||
self->rowBuf[i >> 2] |= level << ((3 - (i & 3)) * 2);
|
||||
}
|
||||
if (self->file.write(self->rowBuf, rowBytes) != rowBytes) self->failed = true;
|
||||
return !self->failed;
|
||||
}
|
||||
};
|
||||
|
||||
bool ensureImageCached(BookPaginator& paginator, const std::string& cacheDir, const PageImage& img, const char* path) {
|
||||
if (Storage.exists(path)) return true;
|
||||
(void)cacheDir;
|
||||
|
||||
auto scratchBuf = makeUniqueNoThrow<uint8_t[]>(kImageScratchSize);
|
||||
if (!scratchBuf) {
|
||||
LOG_ERR("FIBIMG", "OOM: image decode scratch (%u B)", static_cast<unsigned>(kImageScratchSize));
|
||||
return false;
|
||||
}
|
||||
freeink::book::Arena scratch(scratchBuf.get(), kImageScratchSize);
|
||||
|
||||
char tmpPath[192];
|
||||
snprintf(tmpPath, sizeof(tmpPath), "%s.tmp", path);
|
||||
G2Writer writer;
|
||||
writer.width = img.width;
|
||||
if (!Storage.openFileForWrite("FIBIMG", tmpPath, writer.file)) return false;
|
||||
const uint8_t header[4] = {static_cast<uint8_t>(img.width & 0xFF), static_cast<uint8_t>(img.width >> 8),
|
||||
static_cast<uint8_t>(img.height & 0xFF), static_cast<uint8_t>(img.height >> 8)};
|
||||
writer.failed = writer.file.write(header, sizeof(header)) != sizeof(header);
|
||||
|
||||
const freeink::book::BookStatus st = freeink::book::ImageRenderer::render(
|
||||
*paginator.bookSource(), paginator.book().zip(), img, scratch, &G2Writer::onRow, &writer);
|
||||
writer.file.close(); // close before remove/rename below
|
||||
if (st != freeink::book::BookStatus::Ok || writer.failed) {
|
||||
LOG_ERR("FIBIMG", "Image decode failed (%d): %s", static_cast<int>(st), img.href);
|
||||
Storage.remove(tmpPath);
|
||||
return false;
|
||||
}
|
||||
Storage.remove(path); // may not exist
|
||||
if (!Storage.rename(tmpPath, path)) {
|
||||
Storage.remove(tmpPath);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void drawImageFromCache(const GfxRenderer& renderer, const char* path, const PageImage& img) {
|
||||
HalFile f;
|
||||
if (!Storage.openFileForRead("FIBIMG", path, f)) return;
|
||||
uint8_t header[4];
|
||||
if (f.read(header, 4) != 4) return;
|
||||
const uint16_t w = header[0] | (header[1] << 8);
|
||||
const uint16_t h = header[2] | (header[3] << 8);
|
||||
if (w != img.width || h != img.height || w == 0) return;
|
||||
|
||||
const GfxRenderer::RenderMode mode = renderer.getRenderMode();
|
||||
uint8_t rowBuf[512];
|
||||
const uint16_t rowBytes = (w + 3) / 4;
|
||||
if (rowBytes > sizeof(rowBuf)) return;
|
||||
|
||||
for (uint16_t y = 0; y < h; ++y) {
|
||||
if (f.read(rowBuf, rowBytes) != rowBytes) return;
|
||||
const int screenY = img.y + y;
|
||||
for (uint16_t x = 0; x < w; ++x) {
|
||||
const uint8_t level = (rowBuf[x >> 2] >> ((3 - (x & 3)) * 2)) & 0x3; // 0=black..3=white
|
||||
const int screenX = img.x + x;
|
||||
if (mode == GfxRenderer::BW) {
|
||||
// Solid black/white plus Bayer dithering for the two gray levels, so
|
||||
// panels without a grayscale pass still show shading.
|
||||
const bool black = level == 0 || (level < 3 && (level * 5) <= kBayer4[y & 3][x & 3]);
|
||||
if (black) renderer.drawPixel(screenX, screenY, true);
|
||||
} else if (mode == GfxRenderer::GRAYSCALE_MSB) {
|
||||
// Same plane convention as 2-bit glyphs: mark grays with state=false.
|
||||
if (level == 1 || level == 2) renderer.drawPixel(screenX, screenY, false);
|
||||
} else if (mode == GfxRenderer::GRAYSCALE_LSB) {
|
||||
if (level == 1) renderer.drawPixel(screenX, screenY, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace FreeInkPageRenderer {
|
||||
|
||||
void drawPage(GfxRenderer& renderer, BookPaginator& paginator, const Page& page, const std::string& cacheDir) {
|
||||
char textBuf[512];
|
||||
|
||||
for (uint16_t r = 0; r < page.runCount; ++r) {
|
||||
const PageTextRun& run = page.runs[r];
|
||||
const uint16_t len = run.len < sizeof(textBuf) - 1 ? run.len : sizeof(textBuf) - 1;
|
||||
memcpy(textBuf, run.text, len);
|
||||
textBuf[len] = '\0';
|
||||
|
||||
const int fontId = paginator.fontIdForRunSize(run.sizePx);
|
||||
const EpdFontFamily::Style style = styleFor(run.styleFlags);
|
||||
// drawText's y is the line top; runs carry the baseline. NONE: the engine
|
||||
// already produced visual order — never reorder here.
|
||||
const int top = run.baselineY - renderer.getFontAscenderSize(fontId);
|
||||
renderer.drawText(fontId, run.x, top, textBuf, true, style, BidiUtils::BidiBaseDir::NONE);
|
||||
|
||||
if (run.styleFlags & freeink::book::StyleUnderline) {
|
||||
const int width = renderer.getTextWidth(fontId, textBuf, style, BidiUtils::BidiBaseDir::NONE);
|
||||
renderer.drawLine(run.x, run.baselineY + 2, run.x + width - 1, run.baselineY + 2, true);
|
||||
}
|
||||
}
|
||||
|
||||
auto* fcm = renderer.getFontCacheManager();
|
||||
const bool scanning = fcm != nullptr && fcm->isScanning();
|
||||
if (scanning || SETTINGS.imageRendering == CrossPointSettings::IMAGES_SUPPRESS) return;
|
||||
if (SETTINGS.imageRendering == CrossPointSettings::IMAGES_PLACEHOLDER) {
|
||||
if (renderer.getRenderMode() == GfxRenderer::BW) {
|
||||
for (uint16_t m = 0; m < page.imageCount; ++m) {
|
||||
const PageImage& img = page.images[m];
|
||||
renderer.drawRect(img.x, img.y, img.width, img.height, true);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (uint16_t m = 0; m < page.imageCount; ++m) {
|
||||
const PageImage& img = page.images[m];
|
||||
char path[192];
|
||||
imageCachePath(cacheDir, img, path, sizeof(path));
|
||||
if (ensureImageCached(paginator, cacheDir, img, path)) {
|
||||
drawImageFromCache(renderer, path, img);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool imageBoundingBox(const Page& page, int16_t* x, int16_t* y, int16_t* w, int16_t* h) {
|
||||
if (page.imageCount == 0) return false;
|
||||
int16_t minX = INT16_MAX, minY = INT16_MAX, maxX = INT16_MIN, maxY = INT16_MIN;
|
||||
for (uint16_t m = 0; m < page.imageCount; ++m) {
|
||||
const PageImage& img = page.images[m];
|
||||
minX = std::min(minX, img.x);
|
||||
minY = std::min(minY, img.y);
|
||||
maxX = std::max<int16_t>(maxX, img.x + img.width);
|
||||
maxY = std::max<int16_t>(maxY, img.y + img.height);
|
||||
}
|
||||
*x = minX;
|
||||
*y = minY;
|
||||
*w = maxX - minX;
|
||||
*h = maxY - minY;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<FootnoteEntry> collectFootnotes(const Page& page) {
|
||||
std::vector<FootnoteEntry> notes;
|
||||
notes.reserve(page.linkCount);
|
||||
for (uint16_t l = 0; l < page.linkCount; ++l) {
|
||||
const freeink::book::PageLink& link = page.links[l];
|
||||
FootnoteEntry entry;
|
||||
if (link.fragment != nullptr && link.fragment[0] != '\0') {
|
||||
snprintf(entry.href, sizeof(entry.href), "%s#%s", link.target != nullptr ? link.target : "", link.fragment);
|
||||
} else if (link.target != nullptr && link.target[0] != '\0') {
|
||||
snprintf(entry.href, sizeof(entry.href), "%s", link.target);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
snprintf(entry.number, sizeof(entry.number), "%u", static_cast<unsigned>(notes.size() + 1));
|
||||
notes.push_back(entry);
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
std::string pageText(const Page& page) {
|
||||
std::string text;
|
||||
size_t total = 0;
|
||||
for (uint16_t r = 0; r < page.runCount; ++r) total += page.runs[r].len + 1;
|
||||
text.reserve(total);
|
||||
for (uint16_t r = 0; r < page.runCount; ++r) {
|
||||
if (r > 0) text += ' '; // runs carry no separators (justified gaps are positional)
|
||||
text.append(page.runs[r].text, page.runs[r].len);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
} // namespace FreeInkPageRenderer
|
||||
@@ -0,0 +1,45 @@
|
||||
#pragma once
|
||||
|
||||
// Draws FreeInkBook page records through GfxRenderer — the migration's
|
||||
// renderer contract: each run's UTF-8 text at (x, baselineY) with the font
|
||||
// for (sizePx, styleFlags) using that font's own advances/kerning, a line
|
||||
// under StyleUnderline runs, image rects, and link collection for the
|
||||
// footnote UI. Text is never reordered, shaped, or spaced here: runs arrive
|
||||
// in visual order with justification baked into their x positions
|
||||
// (drawText is called with BidiBaseDir::NONE).
|
||||
//
|
||||
// Images decode once per (href, placement) into a 2-bit cache file beside
|
||||
// the page caches, then every render pass — BW and both grayscale planes,
|
||||
// including per-band strip re-renders — streams that file instead of
|
||||
// re-decoding the PNG/JPEG.
|
||||
|
||||
#include <Epub/FootnoteEntry.h>
|
||||
#include <layout/ChapterLayout.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
class GfxRenderer;
|
||||
class BookPaginator;
|
||||
|
||||
namespace FreeInkPageRenderer {
|
||||
|
||||
// Draws the page's text runs and (unless SETTINGS disables images or the
|
||||
// renderer is in a font-cache scan pass) its images, honoring the renderer's
|
||||
// current render mode (BW / GRAYSCALE_LSB / GRAYSCALE_MSB).
|
||||
void drawPage(GfxRenderer& renderer, BookPaginator& paginator, const freeink::book::Page& page,
|
||||
const std::string& cacheDir);
|
||||
|
||||
// True when the page places at least one image.
|
||||
inline bool hasImages(const freeink::book::Page& page) { return page.imageCount > 0; }
|
||||
|
||||
// Union of all image rects (for the blank-then-refresh e-ink technique).
|
||||
bool imageBoundingBox(const freeink::book::Page& page, int16_t* x, int16_t* y, int16_t* w, int16_t* h);
|
||||
|
||||
// The page's tappable links as footnote entries (href = target#fragment).
|
||||
std::vector<FootnoteEntry> collectFootnotes(const freeink::book::Page& page);
|
||||
|
||||
// Concatenated run text (QR display, bookmark summaries).
|
||||
std::string pageText(const freeink::book::Page& page);
|
||||
|
||||
} // namespace FreeInkPageRenderer
|
||||
@@ -70,7 +70,17 @@ void KOReaderSyncActivity::saveProgressAndReturn(int spineIndex, int page) {
|
||||
// epub is guaranteed non-null here: ensureEpubLoaded() was called in performSync() before
|
||||
// SHOWING_RESULT state is entered, and this method is only called from that state.
|
||||
assert(epub);
|
||||
if (!EpubReaderUtils::saveProgress(*epub, spineIndex, page, 0)) {
|
||||
// The reader restores positions by chapter character offset; the remote
|
||||
// position arrives as an estimated (page, totalPages), so persist it as a
|
||||
// chapter fraction that resolves against totalChars() once the chapter's
|
||||
// page cache opens.
|
||||
const int totalPages = remotePosition.totalPages;
|
||||
const uint32_t fractionQ16 =
|
||||
(totalPages > 0 && page > 0 && page <= totalPages)
|
||||
? (static_cast<uint32_t>(page) << 16) / static_cast<uint32_t>(totalPages)
|
||||
: 0;
|
||||
if (!EpubReaderUtils::saveProgress(epub->getCachePath(), static_cast<uint16_t>(spineIndex),
|
||||
EpubReaderUtils::kNoCharStart, fractionQ16)) {
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state = SYNC_FAILED;
|
||||
|
||||
@@ -6,10 +6,8 @@
|
||||
#include <Memory.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "Epub.h"
|
||||
#include "EpubReaderActivity.h"
|
||||
#include "SdCardFontSystem.h"
|
||||
#include "Txt.h"
|
||||
#include "TxtReaderActivity.h"
|
||||
#include "Xtc.h"
|
||||
#include "XtcReaderActivity.h"
|
||||
@@ -26,31 +24,6 @@ bool ReaderActivity::isTxtFile(const std::string& path) {
|
||||
|
||||
bool ReaderActivity::isBmpFile(const std::string& path) { return FsHelpers::hasBmpExtension(path); }
|
||||
|
||||
std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
|
||||
if (!Storage.exists(path.c_str())) {
|
||||
LOG_ERR("READER", "File does not exist: %s", path.c_str());
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
auto epub = makeUniqueNoThrow<Epub>(path, "/.crosspoint");
|
||||
if (!epub) {
|
||||
LOG_ERR("READER", "Failed to allocate EPUB object");
|
||||
return nullptr;
|
||||
}
|
||||
// First open: building the spine/TOC index (book.bin) takes a couple of seconds. Show the
|
||||
// indexing popup so it isn't a silent wait on the home screen. The cachePath/hash is known at
|
||||
// construction, so this check is valid before load(); a cached open loads in a blink -> no popup.
|
||||
if (!Storage.exists((epub->getCachePath() + "/book.bin").c_str())) {
|
||||
GUI.drawPopup(renderer, tr(STR_INDEXING));
|
||||
}
|
||||
if (epub->load(true, SETTINGS.embeddedStyle == 0)) {
|
||||
return epub;
|
||||
}
|
||||
|
||||
LOG_ERR("READER", "Failed to load epub");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::unique_ptr<Xtc> ReaderActivity::loadXtc(const std::string& path) {
|
||||
if (!Storage.exists(path.c_str())) {
|
||||
LOG_ERR("READER", "File does not exist: %s", path.c_str());
|
||||
@@ -70,35 +43,20 @@ std::unique_ptr<Xtc> ReaderActivity::loadXtc(const std::string& path) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::unique_ptr<Txt> ReaderActivity::loadTxt(const std::string& path) {
|
||||
if (!Storage.exists(path.c_str())) {
|
||||
LOG_ERR("READER", "File does not exist: %s", path.c_str());
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
auto txt = makeUniqueNoThrow<Txt>(path, "/.crosspoint");
|
||||
if (!txt) {
|
||||
LOG_ERR("READER", "Failed to allocate TXT object");
|
||||
return nullptr;
|
||||
}
|
||||
if (txt->load()) {
|
||||
return txt;
|
||||
}
|
||||
|
||||
LOG_ERR("READER", "Failed to load TXT");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void ReaderActivity::goToLibrary(const std::string& fromBookPath) {
|
||||
// If coming from a book, start in that book's folder; otherwise start from root
|
||||
auto initialPath = fromBookPath.empty() ? "/" : FsHelpers::extractFolderPath(fromBookPath);
|
||||
activityManager.goToFileBrowser(std::move(initialPath));
|
||||
}
|
||||
|
||||
void ReaderActivity::onGoToEpubReader(std::unique_ptr<Epub> epub) {
|
||||
const auto epubPath = epub->getPath();
|
||||
currentBookPath = epubPath;
|
||||
activityManager.replaceActivity(std::make_unique<EpubReaderActivity>(renderer, mappedInput, std::move(epub)));
|
||||
void ReaderActivity::onGoToEpubReader(const std::string& path) {
|
||||
if (!Storage.exists(path.c_str())) {
|
||||
LOG_ERR("READER", "File does not exist: %s", path.c_str());
|
||||
onGoBack();
|
||||
return;
|
||||
}
|
||||
currentBookPath = path;
|
||||
activityManager.replaceActivity(std::make_unique<EpubReaderActivity>(renderer, mappedInput, path));
|
||||
}
|
||||
|
||||
void ReaderActivity::onGoToBmpViewer(const std::string& path) {
|
||||
@@ -111,10 +69,14 @@ void ReaderActivity::onGoToXtcReader(std::unique_ptr<Xtc> xtc) {
|
||||
activityManager.replaceActivity(std::make_unique<XtcReaderActivity>(renderer, mappedInput, std::move(xtc)));
|
||||
}
|
||||
|
||||
void ReaderActivity::onGoToTxtReader(std::unique_ptr<Txt> txt) {
|
||||
const auto txtPath = txt->getPath();
|
||||
currentBookPath = txtPath;
|
||||
activityManager.replaceActivity(std::make_unique<TxtReaderActivity>(renderer, mappedInput, std::move(txt)));
|
||||
void ReaderActivity::onGoToTxtReader(const std::string& path) {
|
||||
if (!Storage.exists(path.c_str())) {
|
||||
LOG_ERR("READER", "File does not exist: %s", path.c_str());
|
||||
onGoBack();
|
||||
return;
|
||||
}
|
||||
currentBookPath = path;
|
||||
activityManager.replaceActivity(std::make_unique<TxtReaderActivity>(renderer, mappedInput, path));
|
||||
}
|
||||
|
||||
void ReaderActivity::onEnter() {
|
||||
@@ -138,19 +100,9 @@ void ReaderActivity::onEnter() {
|
||||
}
|
||||
onGoToXtcReader(std::move(xtc));
|
||||
} else if (isTxtFile(initialBookPath)) {
|
||||
auto txt = loadTxt(initialBookPath);
|
||||
if (!txt) {
|
||||
onGoBack();
|
||||
return;
|
||||
}
|
||||
onGoToTxtReader(std::move(txt));
|
||||
onGoToTxtReader(initialBookPath);
|
||||
} else {
|
||||
auto epub = loadEpub(initialBookPath);
|
||||
if (!epub) {
|
||||
onGoBack();
|
||||
return;
|
||||
}
|
||||
onGoToEpubReader(std::move(epub));
|
||||
onGoToEpubReader(initialBookPath);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,25 +4,20 @@
|
||||
#include "activities/Activity.h"
|
||||
#include "activities/home/FileBrowserActivity.h"
|
||||
|
||||
class Epub;
|
||||
class Xtc;
|
||||
class Txt;
|
||||
|
||||
class ReaderActivity final : public Activity {
|
||||
std::string initialBookPath;
|
||||
std::string currentBookPath; // Track current book path for navigation
|
||||
// Non-static (unlike the other loaders): draws the first-open indexing popup, which needs the renderer.
|
||||
std::unique_ptr<Epub> loadEpub(const std::string& path);
|
||||
static std::unique_ptr<Xtc> loadXtc(const std::string& path);
|
||||
static std::unique_ptr<Txt> loadTxt(const std::string& path);
|
||||
static bool isXtcFile(const std::string& path);
|
||||
static bool isTxtFile(const std::string& path);
|
||||
static bool isBmpFile(const std::string& path);
|
||||
|
||||
void goToLibrary(const std::string& fromBookPath = "");
|
||||
void onGoToEpubReader(std::unique_ptr<Epub> epub);
|
||||
void onGoToEpubReader(const std::string& path);
|
||||
void onGoToXtcReader(std::unique_ptr<Xtc> xtc);
|
||||
void onGoToTxtReader(std::unique_ptr<Txt> txt);
|
||||
void onGoToTxtReader(const std::string& path);
|
||||
void onGoToBmpViewer(const std::string& path);
|
||||
|
||||
void onGoBack();
|
||||
|
||||
@@ -1,68 +1,75 @@
|
||||
#include "TxtReaderActivity.h"
|
||||
|
||||
#include <BidiUtils.h>
|
||||
#include <FontCacheManager.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
#include <Serialization.h>
|
||||
#include <Utf8.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "EpubReaderUtils.h"
|
||||
#include "FreeInkPageRenderer.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "ProgressFile.h"
|
||||
#include "ReaderUtils.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
constexpr size_t CHUNK_SIZE = 8 * 1024; // 8KB chunk for reading
|
||||
// Cache file magic and version
|
||||
constexpr uint32_t CACHE_MAGIC = 0x54585449; // "TXTI"
|
||||
constexpr uint8_t CACHE_VERSION = 3; // Increment when cache format changes
|
||||
} // namespace
|
||||
|
||||
void TxtReaderActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
if (!txt) {
|
||||
ReaderUtils::applyOrientation(renderer, SETTINGS.orientation);
|
||||
|
||||
// Same per-file cache dir convention the legacy Txt reader used.
|
||||
cacheDir_ = "/.crosspoint/txt_" + std::to_string(std::hash<std::string>{}(path_));
|
||||
Storage.ensureDirectoryExists("/.crosspoint");
|
||||
|
||||
if (!paginator.open(path_, cacheDir_, renderer, /*forcePlainText=*/true)) {
|
||||
LOG_ERR("TRS", "Failed to open text file: %s", path_.c_str());
|
||||
activityManager.goToFullScreenMessage(tr(STR_PAGE_LOAD_ERROR), EpdFontFamily::BOLD);
|
||||
return;
|
||||
}
|
||||
|
||||
ReaderUtils::applyOrientation(renderer, SETTINGS.orientation);
|
||||
const auto progress = EpubReaderUtils::loadProgress(cacheDir_);
|
||||
// Only v2 progress applies: the legacy txt format stored a raw page number
|
||||
// for a line-wrap pagination that no longer exists (reads back as a bogus
|
||||
// spine index — a plain text file has exactly one chapter).
|
||||
if (progress.valid && progress.spineIndex == 0 && progress.charStart != EpubReaderUtils::kNoCharStart) {
|
||||
pendingCharStart = progress.charStart;
|
||||
}
|
||||
|
||||
txt->setupCacheDir();
|
||||
|
||||
// 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.openEpubPath = path_;
|
||||
APP_STATE.saveToFile();
|
||||
RECENT_BOOKS.addBook(filePath, fileName, "", "");
|
||||
RECENT_BOOKS.addBook(path_, title(), "", "");
|
||||
|
||||
// Trigger first update
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void TxtReaderActivity::onExit() {
|
||||
Activity::onExit();
|
||||
|
||||
// Reset orientation back to portrait for the rest of the UI
|
||||
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
|
||||
|
||||
pageOffsets.clear();
|
||||
currentPageLines.clear();
|
||||
APP_STATE.readerActivityLoadCount = 0;
|
||||
APP_STATE.saveToFile();
|
||||
txt.reset();
|
||||
paginator.close();
|
||||
}
|
||||
|
||||
std::string TxtReaderActivity::title() const {
|
||||
const size_t slash = path_.rfind('/');
|
||||
return path_.substr(slash == std::string::npos ? 0 : slash + 1);
|
||||
}
|
||||
|
||||
void TxtReaderActivity::loop() {
|
||||
if (!paginator.isOpen()) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
// Long press BACK (1s+) goes to file selection
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) {
|
||||
activityManager.goToFileBrowser(txt ? txt->getPath() : "");
|
||||
activityManager.goToFileBrowser(path_);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,7 +89,7 @@ void TxtReaderActivity::loop() {
|
||||
currentPage--;
|
||||
requestUpdate();
|
||||
} else if (nextTriggered) {
|
||||
if (currentPage < totalPages - 1) {
|
||||
if (paginator.chapterReady() && currentPage + 1 < paginator.pageCount()) {
|
||||
currentPage++;
|
||||
requestUpdate();
|
||||
} else {
|
||||
@@ -91,496 +98,122 @@ void TxtReaderActivity::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
void TxtReaderActivity::initializeReader() {
|
||||
if (initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store current settings for cache validation
|
||||
cachedFontId = SETTINGS.getReaderFontId();
|
||||
cachedScreenMargin = SETTINGS.screenMargin;
|
||||
cachedParagraphAlignment = SETTINGS.paragraphAlignment;
|
||||
|
||||
// Calculate viewport dimensions
|
||||
renderer.getOrientedViewableTRBL(&cachedOrientedMarginTop, &cachedOrientedMarginRight, &cachedOrientedMarginBottom,
|
||||
&cachedOrientedMarginLeft);
|
||||
cachedOrientedMarginTop += cachedScreenMargin;
|
||||
cachedOrientedMarginLeft += cachedScreenMargin;
|
||||
cachedOrientedMarginRight += cachedScreenMargin;
|
||||
cachedOrientedMarginBottom +=
|
||||
std::max(cachedScreenMargin, static_cast<uint8_t>(UITheme::getInstance().getStatusBarHeight()));
|
||||
|
||||
viewportWidth = renderer.getScreenWidth() - cachedOrientedMarginLeft - cachedOrientedMarginRight;
|
||||
const int viewportHeight = renderer.getScreenHeight() - cachedOrientedMarginTop - cachedOrientedMarginBottom;
|
||||
const int lineHeight = renderer.getLineHeight(cachedFontId);
|
||||
|
||||
linesPerPage = viewportHeight / lineHeight;
|
||||
if (linesPerPage < 1) linesPerPage = 1;
|
||||
|
||||
LOG_DBG("TRS", "Viewport: %dx%d, lines per page: %d", viewportWidth, viewportHeight, linesPerPage);
|
||||
|
||||
// Try to load cached page index first
|
||||
if (!loadPageIndexCache()) {
|
||||
// Cache not found, build page index
|
||||
buildPageIndex();
|
||||
// Save to cache for next time
|
||||
savePageIndexCache();
|
||||
}
|
||||
|
||||
// Load saved progress
|
||||
loadProgress();
|
||||
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
void TxtReaderActivity::buildPageIndex() {
|
||||
pageOffsets.clear();
|
||||
pageOffsets.push_back(0); // First page starts at offset 0
|
||||
|
||||
size_t offset = 0;
|
||||
const size_t fileSize = txt->getFileSize();
|
||||
|
||||
LOG_DBG("TRS", "Building page index for %zu bytes...", fileSize);
|
||||
|
||||
GUI.drawPopup(renderer, tr(STR_INDEXING));
|
||||
|
||||
while (offset < fileSize) {
|
||||
std::vector<std::string> tempLines;
|
||||
size_t nextOffset = offset;
|
||||
|
||||
if (!loadPageAtOffset(offset, tempLines, nextOffset)) {
|
||||
break;
|
||||
bool TxtReaderActivity::ensureChapterAndPosition() {
|
||||
const uint32_t gen = paginator.generation();
|
||||
if (!chapterOpen || openGeneration != gen) {
|
||||
// Settings/orientation change: reanchor on the page being shown.
|
||||
if (chapterOpen && !pendingCharStart.has_value()) {
|
||||
pendingCharStart = lastCharStart;
|
||||
}
|
||||
chapterOpen = false;
|
||||
buildPopupShown = false;
|
||||
|
||||
if (nextOffset <= offset) {
|
||||
// No progress made, avoid infinite loop
|
||||
break;
|
||||
}
|
||||
|
||||
offset = nextOffset;
|
||||
if (offset < fileSize) {
|
||||
pageOffsets.push_back(offset);
|
||||
}
|
||||
|
||||
// Yield to other tasks periodically
|
||||
if (pageOffsets.size() % 20 == 0) {
|
||||
vTaskDelay(1);
|
||||
}
|
||||
}
|
||||
|
||||
totalPages = pageOffsets.size();
|
||||
LOG_DBG("TRS", "Built page index: %d pages", totalPages);
|
||||
}
|
||||
|
||||
bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector<std::string>& outLines, size_t& nextOffset) {
|
||||
outLines.clear();
|
||||
const size_t fileSize = txt->getFileSize();
|
||||
|
||||
if (offset >= fileSize) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read a chunk from file
|
||||
size_t chunkSize = std::min(CHUNK_SIZE, fileSize - offset);
|
||||
auto* buffer = static_cast<uint8_t*>(malloc(chunkSize + 1));
|
||||
if (!buffer) {
|
||||
LOG_ERR("TRS", "Failed to allocate %zu bytes", chunkSize);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!txt->readContent(buffer, offset, chunkSize)) {
|
||||
free(buffer);
|
||||
return false;
|
||||
}
|
||||
buffer[chunkSize] = '\0';
|
||||
|
||||
// Prime the SD card font's advance table with this chunk's codepoints.
|
||||
// Without this, every getTextAdvanceX() call in the wrap loop below triggers
|
||||
// on-demand glyph loads through the 8-slot overflow ring buffer, which
|
||||
// thrashes for any text with more than 8 unique chars (i.e. all English),
|
||||
// floods the heap with short-lived bitmap allocations, and eventually
|
||||
// corrupts FreeRTOS state. The advance table persists across calls per
|
||||
// font, so the cost amortizes to ~ASCII-size after the first chunk.
|
||||
if (renderer.isSdCardFont(cachedFontId)) {
|
||||
renderer.ensureSdCardFontReady(cachedFontId, reinterpret_cast<const char*>(buffer), /*styleMask=*/0x01);
|
||||
}
|
||||
|
||||
// Parse lines from buffer
|
||||
size_t pos = 0;
|
||||
|
||||
while (pos < chunkSize && static_cast<int>(outLines.size()) < linesPerPage) {
|
||||
// Find end of line
|
||||
size_t lineEnd = pos;
|
||||
while (lineEnd < chunkSize && buffer[lineEnd] != '\n') {
|
||||
lineEnd++;
|
||||
}
|
||||
|
||||
// Check if we have a complete line
|
||||
bool lineComplete = (lineEnd < chunkSize) || (offset + lineEnd >= fileSize);
|
||||
|
||||
if (!lineComplete && static_cast<int>(outLines.size()) > 0) {
|
||||
// Incomplete line and we already have some lines, stop here
|
||||
break;
|
||||
}
|
||||
|
||||
// Calculate the actual length of line content in the buffer (excluding newline)
|
||||
size_t lineContentLen = lineEnd - pos;
|
||||
|
||||
// Check for carriage return
|
||||
bool hasCR = (lineContentLen > 0 && buffer[pos + lineContentLen - 1] == '\r');
|
||||
size_t displayLen = hasCR ? lineContentLen - 1 : lineContentLen;
|
||||
|
||||
// Extract line content for display (without CR/LF)
|
||||
std::string line(reinterpret_cast<char*>(buffer + pos), displayLen);
|
||||
|
||||
// Track position within this source line (in bytes from pos)
|
||||
size_t lineBytePos = 0;
|
||||
|
||||
// Emit at least one visual line for each source line (including blank lines),
|
||||
// then continue with wrapping when needed.
|
||||
do {
|
||||
if (line.empty()) {
|
||||
outLines.emplace_back();
|
||||
break;
|
||||
BookPaginator::BuildProgress progressCb;
|
||||
progressCb.ctx = this;
|
||||
progressCb.fn = [](void* ctx, uint32_t) {
|
||||
auto* self = static_cast<TxtReaderActivity*>(ctx);
|
||||
if (!self->buildPopupShown) {
|
||||
GUI.drawPopup(self->renderer, tr(STR_INDEXING));
|
||||
self->pagesUntilFullRefresh = 1;
|
||||
self->buildPopupShown = true;
|
||||
}
|
||||
};
|
||||
|
||||
int lineWidth = renderer.getTextAdvanceX(cachedFontId, line.c_str(), EpdFontFamily::REGULAR);
|
||||
|
||||
if (lineWidth <= viewportWidth) {
|
||||
outLines.push_back(line);
|
||||
lineBytePos = displayLen; // Consumed entire display content
|
||||
line.clear();
|
||||
break;
|
||||
}
|
||||
|
||||
// Find break point
|
||||
size_t breakPos = line.length();
|
||||
while (breakPos > 0 && renderer.getTextAdvanceX(cachedFontId, line.substr(0, breakPos).c_str(),
|
||||
EpdFontFamily::REGULAR) > viewportWidth) {
|
||||
// Try to break at space
|
||||
size_t spacePos = line.rfind(' ', breakPos - 1);
|
||||
if (spacePos != std::string::npos && spacePos > 0) {
|
||||
breakPos = spacePos;
|
||||
} else {
|
||||
// Break at character boundary for UTF-8
|
||||
breakPos--;
|
||||
// Make sure we don't break in the middle of a UTF-8 sequence
|
||||
while (breakPos > 0 && (line[breakPos] & 0xC0) == 0x80) {
|
||||
breakPos--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (breakPos == 0) {
|
||||
breakPos = 1;
|
||||
}
|
||||
|
||||
outLines.push_back(line.substr(0, breakPos));
|
||||
|
||||
// Skip space at break point
|
||||
size_t skipChars = breakPos;
|
||||
if (breakPos < line.length() && line[breakPos] == ' ') {
|
||||
skipChars++;
|
||||
}
|
||||
lineBytePos += skipChars;
|
||||
line = line.substr(skipChars);
|
||||
} while (!line.empty() && static_cast<int>(outLines.size()) < linesPerPage);
|
||||
|
||||
// Determine how much of the source buffer we consumed
|
||||
if (line.empty()) {
|
||||
// Fully consumed this source line, move past the newline
|
||||
pos = lineEnd + 1;
|
||||
} else {
|
||||
// Partially consumed - page is full mid-line
|
||||
// Move pos to where we stopped in the line (NOT past the line)
|
||||
pos = pos + lineBytePos;
|
||||
break;
|
||||
const auto status = paginator.ensureChapter(0, progressCb);
|
||||
if (status != freeink::book::BookStatus::Ok) {
|
||||
LOG_ERR("TRS", "Pagination failed: %d", static_cast<int>(status));
|
||||
return false;
|
||||
}
|
||||
chapterOpen = true;
|
||||
openGeneration = gen;
|
||||
}
|
||||
|
||||
// Ensure we make progress even if calculations go wrong
|
||||
if (pos == 0 && !outLines.empty()) {
|
||||
// Fallback: at minimum, consume something to avoid infinite loop
|
||||
pos = 1;
|
||||
if (pendingCharStart.has_value()) {
|
||||
currentPage = paginator.pageForChar(*pendingCharStart);
|
||||
pendingCharStart.reset();
|
||||
}
|
||||
|
||||
nextOffset = offset + pos;
|
||||
|
||||
// Make sure we don't go past the file
|
||||
if (nextOffset > fileSize) {
|
||||
nextOffset = fileSize;
|
||||
if (paginator.pageCount() > 0 && currentPage >= paginator.pageCount()) {
|
||||
currentPage = paginator.pageCount() - 1;
|
||||
}
|
||||
|
||||
free(buffer);
|
||||
|
||||
return !outLines.empty();
|
||||
return true;
|
||||
}
|
||||
|
||||
void TxtReaderActivity::render(RenderLock&&) {
|
||||
if (!txt) {
|
||||
if (!paginator.isOpen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize reader if not done
|
||||
if (!initialized) {
|
||||
initializeReader();
|
||||
}
|
||||
int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft;
|
||||
renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom,
|
||||
&orientedMarginLeft);
|
||||
orientedMarginTop += SETTINGS.screenMargin;
|
||||
orientedMarginLeft += SETTINGS.screenMargin;
|
||||
orientedMarginRight += SETTINGS.screenMargin;
|
||||
orientedMarginBottom +=
|
||||
std::max(SETTINGS.screenMargin, static_cast<uint8_t>(UITheme::getInstance().getStatusBarHeight()));
|
||||
|
||||
if (pageOffsets.empty()) {
|
||||
paginator.configureLayout(static_cast<int16_t>(renderer.getScreenWidth()),
|
||||
static_cast<int16_t>(renderer.getScreenHeight()), static_cast<int16_t>(orientedMarginLeft),
|
||||
static_cast<int16_t>(orientedMarginRight), static_cast<int16_t>(orientedMarginTop),
|
||||
static_cast<int16_t>(orientedMarginBottom));
|
||||
|
||||
if (!ensureChapterAndPosition() || paginator.pageCount() == 0) {
|
||||
renderer.clearScreen();
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_EMPTY_FILE), true, EpdFontFamily::BOLD);
|
||||
renderer.displayBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
// Bounds check
|
||||
if (currentPage < 0) currentPage = 0;
|
||||
if (currentPage >= totalPages) currentPage = totalPages - 1;
|
||||
|
||||
// Load current page content
|
||||
size_t offset = pageOffsets[currentPage];
|
||||
size_t nextOffset;
|
||||
currentPageLines.clear();
|
||||
loadPageAtOffset(offset, currentPageLines, nextOffset);
|
||||
freeink::book::Page page{};
|
||||
if (paginator.readPage(currentPage, &page) != freeink::book::BookStatus::Ok) {
|
||||
LOG_ERR("TRS", "Failed to read page %u - clearing cache", currentPage);
|
||||
chapterOpen = false;
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
lastCharStart = page.charStart;
|
||||
|
||||
renderer.clearScreen();
|
||||
renderPage();
|
||||
renderPage(page);
|
||||
|
||||
// Save progress
|
||||
saveProgress();
|
||||
EpubReaderUtils::saveProgress(cacheDir_, 0, lastCharStart);
|
||||
}
|
||||
|
||||
void TxtReaderActivity::renderPage() {
|
||||
const int lineHeight = renderer.getLineHeight(cachedFontId);
|
||||
const int contentWidth = viewportWidth;
|
||||
|
||||
// Render text lines with alignment
|
||||
auto renderLines = [&]() {
|
||||
int y = cachedOrientedMarginTop;
|
||||
for (const auto& line : currentPageLines) {
|
||||
if (!line.empty()) {
|
||||
int x = cachedOrientedMarginLeft;
|
||||
const bool lineIsRtl = BidiUtils::startsWithRtl(line.c_str(), BidiUtils::RTL_PARAGRAPH_PROBE_DEPTH);
|
||||
uint8_t effectiveAlignment = cachedParagraphAlignment;
|
||||
if (lineIsRtl && (effectiveAlignment == CrossPointSettings::LEFT_ALIGN ||
|
||||
effectiveAlignment == CrossPointSettings::JUSTIFIED)) {
|
||||
effectiveAlignment = CrossPointSettings::RIGHT_ALIGN;
|
||||
}
|
||||
const int textWidth = renderer.getTextAdvanceX(cachedFontId, line.c_str(), EpdFontFamily::REGULAR);
|
||||
|
||||
// Apply text alignment
|
||||
switch (effectiveAlignment) {
|
||||
case CrossPointSettings::LEFT_ALIGN:
|
||||
default:
|
||||
// x already set to left margin
|
||||
break;
|
||||
case CrossPointSettings::CENTER_ALIGN: {
|
||||
x = cachedOrientedMarginLeft + (contentWidth - textWidth) / 2;
|
||||
break;
|
||||
}
|
||||
case CrossPointSettings::RIGHT_ALIGN: {
|
||||
x = cachedOrientedMarginLeft + contentWidth - textWidth;
|
||||
break;
|
||||
}
|
||||
case CrossPointSettings::JUSTIFIED:
|
||||
// For plain text, justified is treated as left-aligned
|
||||
// (true justification would require word spacing adjustments)
|
||||
break;
|
||||
}
|
||||
|
||||
renderer.drawText(cachedFontId, x, y, line.c_str());
|
||||
}
|
||||
y += lineHeight;
|
||||
}
|
||||
};
|
||||
|
||||
void TxtReaderActivity::renderPage(const freeink::book::Page& page) {
|
||||
// Font prewarm: scan pass accumulates text, then prewarm, then real render
|
||||
auto* fcm = renderer.getFontCacheManager();
|
||||
auto scope = fcm->createPrewarmScope();
|
||||
renderLines(); // scan pass — text accumulated, no drawing
|
||||
FreeInkPageRenderer::drawPage(renderer, paginator, page, cacheDir_); // scan pass
|
||||
scope.endScanAndPrewarm();
|
||||
|
||||
// BW rendering
|
||||
renderLines();
|
||||
FreeInkPageRenderer::drawPage(renderer, paginator, page, cacheDir_);
|
||||
renderStatusBar();
|
||||
|
||||
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);
|
||||
|
||||
if (SETTINGS.textAntiAliasing) {
|
||||
ReaderUtils::renderAntiAliased(renderer, [&renderLines]() { renderLines(); });
|
||||
ReaderUtils::renderAntiAliased(
|
||||
renderer, [this, &page]() { FreeInkPageRenderer::drawPage(renderer, paginator, page, cacheDir_); });
|
||||
}
|
||||
// scope destructor clears font cache via FontCacheManager
|
||||
}
|
||||
|
||||
void TxtReaderActivity::renderStatusBar() const {
|
||||
const float progress = totalPages > 0 ? (currentPage + 1) * 100.0f / totalPages : 0;
|
||||
std::string title;
|
||||
const uint32_t pageCount = paginator.pageCount();
|
||||
const float progress = pageCount > 0 ? (currentPage + 1) * 100.0f / pageCount : 0;
|
||||
std::string barTitle;
|
||||
if (SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE) {
|
||||
title = txt->getTitle();
|
||||
barTitle = title();
|
||||
}
|
||||
GUI.drawStatusBar(renderer, progress, currentPage + 1, totalPages, title);
|
||||
}
|
||||
|
||||
void TxtReaderActivity::saveProgress() const {
|
||||
uint8_t data[4];
|
||||
data[0] = currentPage & 0xFF;
|
||||
data[1] = (currentPage >> 8) & 0xFF;
|
||||
data[2] = 0;
|
||||
data[3] = 0;
|
||||
if (!ProgressFile::writeAtomic(txt->getCachePath(), data, sizeof(data))) {
|
||||
LOG_ERR("TRS", "Failed to save progress: page %d", currentPage);
|
||||
}
|
||||
}
|
||||
|
||||
void TxtReaderActivity::loadProgress() {
|
||||
HalFile f;
|
||||
if (Storage.openFileForRead("TRS", txt->getCachePath() + "/progress.bin", f)) {
|
||||
uint8_t data[4];
|
||||
if (f.read(data, 4) == 4) {
|
||||
currentPage = data[0] + (data[1] << 8);
|
||||
if (currentPage >= totalPages) {
|
||||
currentPage = totalPages - 1;
|
||||
}
|
||||
if (currentPage < 0) {
|
||||
currentPage = 0;
|
||||
}
|
||||
LOG_DBG("TRS", "Loaded progress: page %d/%d", currentPage, totalPages);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool TxtReaderActivity::loadPageIndexCache() {
|
||||
// Cache file format (using serialization module):
|
||||
// - uint32_t: magic "TXTI"
|
||||
// - uint8_t: cache version
|
||||
// - uint32_t: file size (to validate cache)
|
||||
// - int32_t: viewport width
|
||||
// - int32_t: lines per page
|
||||
// - int32_t: font ID (to invalidate cache on font change)
|
||||
// - int32_t: screen margin (to invalidate cache on margin change)
|
||||
// - uint8_t: paragraph alignment (to invalidate cache on alignment change)
|
||||
// - uint32_t: total pages count
|
||||
// - N * uint32_t: page offsets
|
||||
|
||||
std::string cachePath = txt->getCachePath() + "/index.bin";
|
||||
HalFile f;
|
||||
if (!Storage.openFileForRead("TRS", cachePath, f)) {
|
||||
LOG_DBG("TRS", "No page index cache found");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read and validate header using serialization module
|
||||
uint32_t magic;
|
||||
serialization::readPod(f, magic);
|
||||
if (magic != CACHE_MAGIC) {
|
||||
LOG_DBG("TRS", "Cache magic mismatch, rebuilding");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t version;
|
||||
serialization::readPod(f, version);
|
||||
if (version != CACHE_VERSION) {
|
||||
LOG_DBG("TRS", "Cache version mismatch (%d != %d), rebuilding", version, CACHE_VERSION);
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t fileSize;
|
||||
serialization::readPod(f, fileSize);
|
||||
if (fileSize != txt->getFileSize()) {
|
||||
LOG_DBG("TRS", "Cache file size mismatch, rebuilding");
|
||||
return false;
|
||||
}
|
||||
|
||||
int32_t cachedWidth;
|
||||
serialization::readPod(f, cachedWidth);
|
||||
if (cachedWidth != viewportWidth) {
|
||||
LOG_DBG("TRS", "Cache viewport width mismatch, rebuilding");
|
||||
return false;
|
||||
}
|
||||
|
||||
int32_t cachedLines;
|
||||
serialization::readPod(f, cachedLines);
|
||||
if (cachedLines != linesPerPage) {
|
||||
LOG_DBG("TRS", "Cache lines per page mismatch, rebuilding");
|
||||
return false;
|
||||
}
|
||||
|
||||
int32_t fontId;
|
||||
serialization::readPod(f, fontId);
|
||||
if (fontId != cachedFontId) {
|
||||
LOG_DBG("TRS", "Cache font ID mismatch (%d != %d), rebuilding", fontId, cachedFontId);
|
||||
return false;
|
||||
}
|
||||
|
||||
int32_t margin;
|
||||
serialization::readPod(f, margin);
|
||||
if (margin != cachedScreenMargin) {
|
||||
LOG_DBG("TRS", "Cache screen margin mismatch, rebuilding");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t alignment;
|
||||
serialization::readPod(f, alignment);
|
||||
if (alignment != cachedParagraphAlignment) {
|
||||
LOG_DBG("TRS", "Cache paragraph alignment mismatch, rebuilding");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t numPages;
|
||||
serialization::readPod(f, numPages);
|
||||
|
||||
// Read page offsets
|
||||
pageOffsets.clear();
|
||||
pageOffsets.reserve(numPages);
|
||||
|
||||
for (uint32_t i = 0; i < numPages; i++) {
|
||||
uint32_t offset;
|
||||
serialization::readPod(f, offset);
|
||||
pageOffsets.push_back(offset);
|
||||
}
|
||||
|
||||
totalPages = pageOffsets.size();
|
||||
LOG_DBG("TRS", "Loaded page index cache: %d pages", totalPages);
|
||||
return true;
|
||||
}
|
||||
|
||||
void TxtReaderActivity::savePageIndexCache() const {
|
||||
std::string cachePath = txt->getCachePath() + "/index.bin";
|
||||
HalFile f;
|
||||
if (!Storage.openFileForWrite("TRS", cachePath, f)) {
|
||||
LOG_ERR("TRS", "Failed to save page index cache");
|
||||
return;
|
||||
}
|
||||
|
||||
// Write header using serialization module
|
||||
serialization::writePod(f, CACHE_MAGIC);
|
||||
serialization::writePod(f, CACHE_VERSION);
|
||||
serialization::writePod(f, static_cast<uint32_t>(txt->getFileSize()));
|
||||
serialization::writePod(f, static_cast<int32_t>(viewportWidth));
|
||||
serialization::writePod(f, static_cast<int32_t>(linesPerPage));
|
||||
serialization::writePod(f, static_cast<int32_t>(cachedFontId));
|
||||
serialization::writePod(f, static_cast<int32_t>(cachedScreenMargin));
|
||||
serialization::writePod(f, cachedParagraphAlignment);
|
||||
serialization::writePod(f, static_cast<uint32_t>(pageOffsets.size()));
|
||||
|
||||
// Write page offsets
|
||||
for (size_t offset : pageOffsets) {
|
||||
serialization::writePod(f, static_cast<uint32_t>(offset));
|
||||
}
|
||||
|
||||
LOG_DBG("TRS", "Saved page index cache: %d pages", totalPages);
|
||||
GUI.drawStatusBar(renderer, progress, static_cast<int>(currentPage) + 1, static_cast<int>(pageCount), barTitle);
|
||||
}
|
||||
|
||||
ScreenshotInfo TxtReaderActivity::getScreenshotInfo() const {
|
||||
ScreenshotInfo info;
|
||||
info.readerType = ScreenshotInfo::ReaderType::Txt;
|
||||
if (txt) {
|
||||
const std::string t = txt->getTitle();
|
||||
snprintf(info.title, sizeof(info.title), "%s", t.c_str());
|
||||
}
|
||||
info.currentPage = currentPage + 1;
|
||||
info.totalPages = totalPages;
|
||||
info.progressPercent = totalPages > 0 ? static_cast<int>((currentPage + 1) * 100.0f / totalPages + 0.5f) : 0;
|
||||
if (info.progressPercent > 100) info.progressPercent = 100;
|
||||
snprintf(info.title, sizeof(info.title), "%s", title().c_str());
|
||||
info.currentPage = static_cast<int>(currentPage) + 1;
|
||||
info.totalPages = static_cast<int>(paginator.pageCount());
|
||||
info.progressPercent =
|
||||
info.totalPages > 0 ? std::min(100, static_cast<int>(info.currentPage * 100.0f / info.totalPages + 0.5f)) : 0;
|
||||
return info;
|
||||
}
|
||||
|
||||
@@ -1,49 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
#include <Txt.h>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "BookPaginator.h"
|
||||
#include "activities/Activity.h"
|
||||
|
||||
// Plain-text (.txt / .md) reading UI over the FreeInkBook engine: the file is
|
||||
// one chapter driven through ChapterLayout::layoutPlainText, so justification,
|
||||
// hyphenation, page caching, and character-offset progress all work exactly
|
||||
// as they do for EPUBs.
|
||||
class TxtReaderActivity final : public Activity {
|
||||
std::unique_ptr<Txt> txt;
|
||||
std::string path_;
|
||||
std::string cacheDir_;
|
||||
BookPaginator paginator;
|
||||
|
||||
int currentPage = 0;
|
||||
int totalPages = 1;
|
||||
uint32_t currentPage = 0;
|
||||
uint32_t lastCharStart = 0;
|
||||
uint32_t openGeneration = 0;
|
||||
bool chapterOpen = false;
|
||||
bool buildPopupShown = false;
|
||||
std::optional<uint32_t> pendingCharStart;
|
||||
int pagesUntilFullRefresh = 0;
|
||||
|
||||
// Streaming text reader - stores file offsets for each page
|
||||
std::vector<size_t> pageOffsets; // File offset for start of each page
|
||||
std::vector<std::string> currentPageLines;
|
||||
int linesPerPage = 0;
|
||||
int viewportWidth = 0;
|
||||
bool initialized = false;
|
||||
|
||||
// Cached settings for cache validation (different fonts/margins require re-indexing)
|
||||
int cachedFontId = 0;
|
||||
uint8_t cachedScreenMargin = 0;
|
||||
uint8_t cachedParagraphAlignment = CrossPointSettings::LEFT_ALIGN;
|
||||
int cachedOrientedMarginTop = 0;
|
||||
int cachedOrientedMarginRight = 0;
|
||||
int cachedOrientedMarginBottom = 0;
|
||||
int cachedOrientedMarginLeft = 0;
|
||||
|
||||
void renderPage();
|
||||
bool ensureChapterAndPosition();
|
||||
void renderPage(const freeink::book::Page& page);
|
||||
void renderStatusBar() const;
|
||||
|
||||
void initializeReader();
|
||||
bool loadPageAtOffset(size_t offset, std::vector<std::string>& outLines, size_t& nextOffset);
|
||||
void buildPageIndex();
|
||||
bool loadPageIndexCache();
|
||||
void savePageIndexCache() const;
|
||||
void saveProgress() const;
|
||||
void loadProgress();
|
||||
std::string title() const;
|
||||
|
||||
public:
|
||||
explicit TxtReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Txt> txt)
|
||||
: Activity("TxtReader", renderer, mappedInput), txt(std::move(txt)) {}
|
||||
explicit TxtReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string path)
|
||||
: Activity("TxtReader", renderer, mappedInput), path_(std::move(path)) {}
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
|
||||
Reference in New Issue
Block a user