Add FreeInkBook EPUB engine migration guide
Document migration plan from CrossPoint's lib/Epub to FreeInkBook, a memory-efficient clean-room EPUB engine. Covers advantages (O(paragraph+page) memory via SAX parsing, arena allocation, host-testable, full UAX#14 typography, runtime TTF/OTF support), 9-step migration strategy, ESP32-C3 constraints, and integration approach preserving existing UI layer while replacing parsing/layout stack.
This commit is contained in:
@@ -351,10 +351,12 @@ int CrossPointSettings::getRefreshFrequency() const {
|
||||
}
|
||||
}
|
||||
|
||||
int CrossPointSettings::getReaderFontId() const {
|
||||
int CrossPointSettings::getReaderFontId() const { return getReaderFontId(fontSize); }
|
||||
|
||||
int CrossPointSettings::getReaderFontId(const uint8_t sizeIndex) const {
|
||||
// Check SD card font first
|
||||
if (sdFontFamilyName[0] != '\0' && sdFontIdResolver) {
|
||||
int id = sdFontIdResolver(sdFontResolverCtx, sdFontFamilyName, fontSize);
|
||||
int id = sdFontIdResolver(sdFontResolverCtx, sdFontFamilyName, sizeIndex);
|
||||
if (id != 0) return id;
|
||||
// Fall through to built-in if SD font not found
|
||||
}
|
||||
@@ -362,7 +364,7 @@ int CrossPointSettings::getReaderFontId() const {
|
||||
switch (fontFamily) {
|
||||
case NOTOSERIF:
|
||||
default:
|
||||
switch (fontSize) {
|
||||
switch (sizeIndex) {
|
||||
case SMALL:
|
||||
return NOTOSERIF_12_FONT_ID;
|
||||
case MEDIUM:
|
||||
@@ -374,7 +376,7 @@ int CrossPointSettings::getReaderFontId() const {
|
||||
return NOTOSERIF_18_FONT_ID;
|
||||
}
|
||||
case NOTOSANS:
|
||||
switch (fontSize) {
|
||||
switch (sizeIndex) {
|
||||
case SMALL:
|
||||
return NOTOSANS_12_FONT_ID;
|
||||
case MEDIUM:
|
||||
|
||||
@@ -295,6 +295,10 @@ class CrossPointSettings {
|
||||
return (shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP) ? 10 : 400;
|
||||
}
|
||||
int getReaderFontId() const;
|
||||
// Same resolution (SD font first, then built-in family) for an explicit
|
||||
// size rung — the FreeInkBook ladder needs all four sizes of the active
|
||||
// family, not just the one the fontSize setting selects.
|
||||
int getReaderFontId(uint8_t sizeIndex) const;
|
||||
|
||||
// If count_only is true, returns the number of settings items that would be written.
|
||||
uint8_t writeSettings(HalFile& file, bool count_only = false) const;
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
#include "BookPaginator.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <Memory.h>
|
||||
#include <text/hyph_en_us.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
|
||||
using freeink::book::Arena;
|
||||
using freeink::book::BookStatus;
|
||||
using freeink::book::ChapterLayout;
|
||||
using freeink::book::CssStylesheetBuilder;
|
||||
using freeink::book::ManifestItem;
|
||||
using freeink::book::PageCacheWriter;
|
||||
using freeink::book::TextAlign;
|
||||
using freeink::book::ZipCatalog;
|
||||
|
||||
namespace {
|
||||
|
||||
// The .cpfont ladder: FONT_SIZE setting index -> pixel size. Must stay in
|
||||
// step with the NOTOSERIF_12..18 / NOTOSANS_12..18 font registrations.
|
||||
constexpr uint16_t kLadderPx[CrossPointSettings::FONT_SIZE_COUNT] = {12, 14, 16, 18};
|
||||
|
||||
uint32_t hashMix(uint32_t hash, uint32_t value) {
|
||||
hash ^= value;
|
||||
return hash * 16777619u;
|
||||
}
|
||||
|
||||
// Forwards pages to the cache writer while surfacing build progress to the
|
||||
// UI (indexing popup) every 16 pages.
|
||||
class ProgressSink : public freeink::book::PageSink {
|
||||
public:
|
||||
ProgressSink(PageCacheWriter& writer, const BookPaginator::BuildProgress& progress)
|
||||
: writer_(writer), progress_(progress) {}
|
||||
void onAnchor(const uint32_t idHash, const uint32_t charStart) override { writer_.onAnchor(idHash, charStart); }
|
||||
bool onPage(const freeink::book::Page& page) override {
|
||||
const bool ok = writer_.onPage(page);
|
||||
if (ok && progress_.fn != nullptr && (writer_.pageCount() & 15u) == 0) {
|
||||
progress_.fn(progress_.ctx, writer_.pageCount());
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
private:
|
||||
PageCacheWriter& writer_;
|
||||
BookPaginator::BuildProgress progress_;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
bool BookPaginator::open(const std::string& path, const std::string& cacheDir, GfxRenderer& renderer) {
|
||||
close();
|
||||
|
||||
bookBuf_ = makeUniqueNoThrow<uint8_t[]>(kBookArenaSize);
|
||||
indexBuf_ = makeUniqueNoThrow<uint8_t[]>(kIndexArenaSize);
|
||||
pageBuf_ = makeUniqueNoThrow<uint8_t[]>(kPageArenaSize);
|
||||
sheetBuf_ = makeUniqueNoThrow<uint8_t[]>(kSheetArenaSize);
|
||||
if (!bookBuf_ || !indexBuf_ || !pageBuf_ || !sheetBuf_) {
|
||||
LOG_ERR("FIB", "OOM: paginator arenas (%u B)",
|
||||
static_cast<unsigned>(kBookArenaSize + kIndexArenaSize + kPageArenaSize + kSheetArenaSize));
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
bookArena_.init(bookBuf_.get(), kBookArenaSize);
|
||||
indexArena_.init(indexBuf_.get(), kIndexArenaSize);
|
||||
pageArena_.init(pageBuf_.get(), kPageArenaSize);
|
||||
sheetArena_.init(sheetBuf_.get(), kSheetArenaSize);
|
||||
|
||||
if (!source_.open(path.c_str())) {
|
||||
LOG_ERR("FIB", "Cannot open book file: %s", path.c_str());
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
|
||||
const size_t len = path.size();
|
||||
isTxt_ = len > 4 && strcasecmp(path.c_str() + len - 4, ".txt") == 0;
|
||||
|
||||
if (!isTxt_) {
|
||||
// Container open + book stylesheet need parse scratch; both are
|
||||
// once-per-open, so the big build buffer is borrowed transiently.
|
||||
auto scratchBuf = makeUniqueNoThrow<uint8_t[]>(kBuildScratchSize);
|
||||
if (!scratchBuf) {
|
||||
LOG_ERR("FIB", "OOM: open scratch (%u B)", static_cast<unsigned>(kBuildScratchSize));
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
Arena scratch(scratchBuf.get(), kBuildScratchSize);
|
||||
|
||||
const BookStatus st = book_.open(source_, bookArena_, scratch);
|
||||
if (st != BookStatus::Ok) {
|
||||
LOG_ERR("FIB", "Book open failed: %d (%s)", static_cast<int>(st), path.c_str());
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
|
||||
CssStylesheetBuilder builder;
|
||||
if (builder.begin(sheetArena_)) {
|
||||
for (size_t m = 0; m < book_.manifestCount(); ++m) {
|
||||
const ManifestItem* item = book_.manifestItem(m);
|
||||
if (item == nullptr || item->mediaType == nullptr || strcmp(item->mediaType, "text/css") != 0) continue;
|
||||
if (const freeink::book::ZipEntry* e = book_.zip().find(item->href)) {
|
||||
builder.addSheet(source_, *e, scratch);
|
||||
}
|
||||
}
|
||||
sheet_ = builder.finish();
|
||||
if (builder.skippedSheets() > 0) {
|
||||
LOG_INF("FIB", "%u stylesheet(s) skipped (size cap)", builder.skippedSheets());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cache_.setDir(cacheDir.c_str());
|
||||
|
||||
if (!buildFontChain(renderer)) {
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
loadHyphenator();
|
||||
|
||||
open_ = true;
|
||||
LOG_DBG("FIB", "Book open: %u spine items, book arena %u/%u B", static_cast<unsigned>(spineCount()),
|
||||
static_cast<unsigned>(bookArena_.used()), static_cast<unsigned>(kBookArenaSize));
|
||||
return true;
|
||||
}
|
||||
|
||||
void BookPaginator::close() {
|
||||
source_.close();
|
||||
reader_ = freeink::book::PageCacheReader();
|
||||
book_ = freeink::book::Book();
|
||||
sheet_ = freeink::book::CssStylesheet{};
|
||||
chain_ = freeink::book::FontChain();
|
||||
hyphBlob_.reset();
|
||||
bookBuf_.reset();
|
||||
indexBuf_.reset();
|
||||
pageBuf_.reset();
|
||||
sheetBuf_.reset();
|
||||
ladderCount_ = 0;
|
||||
curSpine_ = kNoSpine;
|
||||
open_ = false;
|
||||
isTxt_ = false;
|
||||
}
|
||||
|
||||
const char* BookPaginator::language() const {
|
||||
if (!isTxt_ && book_.metadata().language != nullptr && book_.metadata().language[0] != '\0') {
|
||||
return book_.metadata().language;
|
||||
}
|
||||
return "en";
|
||||
}
|
||||
|
||||
bool BookPaginator::buildFontChain(GfxRenderer& renderer) {
|
||||
static constexpr freeink::book::StyleFlags kChainFlags[4] = {
|
||||
freeink::book::StyleNone,
|
||||
freeink::book::StyleBold,
|
||||
freeink::book::StyleItalic,
|
||||
static_cast<freeink::book::StyleFlags>(freeink::book::StyleBold | freeink::book::StyleItalic),
|
||||
};
|
||||
|
||||
ladderCount_ = 0;
|
||||
const auto& fontMap = renderer.getFontMap();
|
||||
for (uint8_t s = 0; s < CrossPointSettings::FONT_SIZE_COUNT; ++s) {
|
||||
const int fontId = SETTINGS.getReaderFontId(s);
|
||||
const auto it = fontMap.find(fontId);
|
||||
if (it == fontMap.end()) {
|
||||
LOG_ERR("FIB", "Reader font id %d (size %u px) not registered", fontId, kLadderPx[s]);
|
||||
continue;
|
||||
}
|
||||
for (auto& adapter : adapters_) {
|
||||
adapter.addSize(kLadderPx[s], &it->second);
|
||||
}
|
||||
ladderFontIds_[ladderCount_] = fontId;
|
||||
ladderSizes_[ladderCount_] = kLadderPx[s];
|
||||
++ladderCount_;
|
||||
}
|
||||
if (ladderCount_ == 0) {
|
||||
LOG_ERR("FIB", "No reader fonts available");
|
||||
return false;
|
||||
}
|
||||
for (uint8_t i = 0; i < 4; ++i) {
|
||||
chain_.add(&adapters_[i], kChainFlags[i]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void BookPaginator::loadHyphenator() {
|
||||
if (!SETTINGS.hyphenationEnabled) return;
|
||||
|
||||
// Prefer language-specific patterns from the SD card (compiled with the
|
||||
// SDK's tools/hyphc.py); English ships embedded in flash. A missing blob
|
||||
// degrades to the embedded en-US patterns, which simply never match
|
||||
// non-Latin words — text still lays out, just unhyphenated.
|
||||
const char* lang = language();
|
||||
char two[3] = {0, 0, 0};
|
||||
two[0] = lang[0];
|
||||
two[1] = lang[1] != '\0' ? lang[1] : '\0';
|
||||
if (strncasecmp(two, "en", 2) != 0) {
|
||||
char blobPath[64];
|
||||
snprintf(blobPath, sizeof(blobPath), "/hyphenation/hyph-%c%c.fibh", two[0], two[1]);
|
||||
HalFile f;
|
||||
if (Storage.openFileForRead("FIB", blobPath, f)) {
|
||||
const size_t size = f.fileSize();
|
||||
hyphBlob_ = makeUniqueNoThrow<uint8_t[]>(size);
|
||||
if (hyphBlob_ && f.read(hyphBlob_.get(), size) == static_cast<int>(size) &&
|
||||
hyphenator_.init(hyphBlob_.get(), static_cast<uint32_t>(size))) {
|
||||
LOG_INF("FIB", "Hyphenation patterns: %s (%u B)", blobPath, static_cast<unsigned>(size));
|
||||
return;
|
||||
}
|
||||
hyphBlob_.reset();
|
||||
LOG_ERR("FIB", "Failed to load %s; falling back to embedded en-US", blobPath);
|
||||
}
|
||||
}
|
||||
hyphenator_.init(freeink::book::k_hyph_en_us, sizeof(freeink::book::k_hyph_en_us));
|
||||
}
|
||||
|
||||
void BookPaginator::configureLayout(const int16_t pageWidth, const int16_t pageHeight, const int16_t marginLeft,
|
||||
const int16_t marginRight, const int16_t marginTop, const int16_t marginBottom) {
|
||||
params_ = freeink::book::LayoutParams{};
|
||||
params_.pageWidth = pageWidth;
|
||||
params_.pageHeight = pageHeight;
|
||||
params_.marginLeft = marginLeft;
|
||||
params_.marginRight = marginRight;
|
||||
params_.marginTop = marginTop;
|
||||
params_.marginBottom = marginBottom;
|
||||
|
||||
const uint8_t sizeIndex =
|
||||
SETTINGS.fontSize < CrossPointSettings::FONT_SIZE_COUNT ? SETTINGS.fontSize : CrossPointSettings::MEDIUM;
|
||||
params_.baseSizePx = kLadderPx[sizeIndex];
|
||||
params_.font = &chain_;
|
||||
params_.stylesheet = (!isTxt_ && sheet_.ruleCount > 0) ? &sheet_ : nullptr;
|
||||
params_.language = language();
|
||||
|
||||
params_.lineSpacingPct = static_cast<uint16_t>(SETTINGS.getReaderLineCompression() * 100.0f + 0.5f);
|
||||
params_.paragraphSpacingPct = SETTINGS.extraParagraphSpacing ? 150 : 100;
|
||||
params_.embeddedStyles = SETTINGS.embeddedStyle != 0;
|
||||
params_.focusReading = SETTINGS.focusReadingEnabled != 0;
|
||||
params_.hyphenator = (SETTINGS.hyphenationEnabled && hyphenator_.ready()) ? &hyphenator_ : nullptr;
|
||||
|
||||
switch (SETTINGS.paragraphAlignment) {
|
||||
case CrossPointSettings::LEFT_ALIGN:
|
||||
params_.defaultAlign = TextAlign::Left;
|
||||
break;
|
||||
case CrossPointSettings::CENTER_ALIGN:
|
||||
params_.defaultAlign = TextAlign::Center;
|
||||
break;
|
||||
case CrossPointSettings::RIGHT_ALIGN:
|
||||
params_.defaultAlign = TextAlign::Right;
|
||||
break;
|
||||
case CrossPointSettings::BOOK_STYLE:
|
||||
// Publisher's choice: left when the book CSS is silent.
|
||||
params_.defaultAlign = TextAlign::Left;
|
||||
break;
|
||||
case CrossPointSettings::JUSTIFIED:
|
||||
default:
|
||||
params_.defaultAlign = TextAlign::Justify;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
uint32_t BookPaginator::fontFingerprint() const {
|
||||
uint32_t hash = 2166136261u;
|
||||
for (uint8_t i = 0; i < ladderCount_; ++i) {
|
||||
hash = hashMix(hash, static_cast<uint32_t>(ladderFontIds_[i]));
|
||||
hash = hashMix(hash, ladderSizes_[i]);
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
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();
|
||||
if (!freeink::book::pageCacheName(spineIndex, gen, cacheName_, sizeof(cacheName_))) {
|
||||
return BookStatus::Unsupported;
|
||||
}
|
||||
|
||||
indexArena_.reset();
|
||||
curSpine_ = kNoSpine;
|
||||
BookStatus st = reader_.open(cache_, cacheName_, gen, indexArena_);
|
||||
if (st == BookStatus::Ok) {
|
||||
curSpine_ = spineIndex;
|
||||
return st;
|
||||
}
|
||||
|
||||
const ManifestItem* item = isTxt_ ? nullptr : book_.spineItem(spineIndex);
|
||||
const freeink::book::ZipEntry* entry = (!isTxt_ && item != nullptr) ? book_.zip().find(item->href) : nullptr;
|
||||
if (!isTxt_ && entry == nullptr) return BookStatus::NotFound;
|
||||
if (isTxt_ && spineIndex != 0) return BookStatus::NotFound;
|
||||
|
||||
// The pagination working set (layout buffers + one inflate stream + the
|
||||
// writer's page index) lives only for this call.
|
||||
auto scratchBuf = makeUniqueNoThrow<uint8_t[]>(kBuildScratchSize);
|
||||
if (!scratchBuf) {
|
||||
LOG_ERR("FIB", "OOM: build scratch (%u B, free heap %u)", static_cast<unsigned>(kBuildScratchSize),
|
||||
static_cast<unsigned>(ESP.getFreeHeap()));
|
||||
return BookStatus::OutOfMemory;
|
||||
}
|
||||
Arena scratch(scratchBuf.get(), kBuildScratchSize);
|
||||
|
||||
const uint32_t t0 = millis();
|
||||
PageCacheWriter writer;
|
||||
if (!writer.begin(cache_, cacheName_, gen, scratch)) {
|
||||
return BookStatus::IoError;
|
||||
}
|
||||
|
||||
ProgressSink sink(writer, progress);
|
||||
uint32_t totalChars = 0;
|
||||
st = isTxt_ ? ChapterLayout::layoutPlainText(source_, params_, scratch, sink, nullptr, &totalChars)
|
||||
: ChapterLayout::layout(source_, book_.zip(), *entry, item->href, params_, scratch, sink, nullptr,
|
||||
&totalChars);
|
||||
writer.setTotalChars(totalChars);
|
||||
if (st == BookStatus::Ok && !writer.finish()) st = BookStatus::IoError;
|
||||
if (st != BookStatus::Ok) {
|
||||
LOG_ERR("FIB", "Chapter %u layout failed: %d (scratch high water %u B)", spineIndex, static_cast<int>(st),
|
||||
static_cast<unsigned>(scratch.highWater()));
|
||||
return st;
|
||||
}
|
||||
LOG_INF("FIB", "Chapter %u paginated: %u pages in %ums (scratch high water %u B)", spineIndex, writer.pageCount(),
|
||||
static_cast<unsigned>(millis() - t0), static_cast<unsigned>(scratch.highWater()));
|
||||
|
||||
indexArena_.reset();
|
||||
st = reader_.open(cache_, cacheName_, gen, indexArena_);
|
||||
if (st == BookStatus::Ok) curSpine_ = spineIndex;
|
||||
return st;
|
||||
}
|
||||
|
||||
bool BookPaginator::charForAnchor(const char* fragment, uint32_t* charOut) const {
|
||||
if (fragment == nullptr || fragment[0] == '\0') return false;
|
||||
return reader_.charForAnchor(ZipCatalog::hashPath(fragment), charOut);
|
||||
}
|
||||
|
||||
freeink::book::BookStatus BookPaginator::readPage(const uint32_t pageIndex, freeink::book::Page* out) {
|
||||
pageArena_.reset();
|
||||
return reader_.readPage(pageIndex, pageArena_, out);
|
||||
}
|
||||
|
||||
int BookPaginator::spineIndexForHref(const char* href) const {
|
||||
if (href == nullptr || href[0] == '\0' || isTxt_) return -1;
|
||||
for (size_t s = 0; s < book_.spineCount(); ++s) {
|
||||
const ManifestItem* item = book_.spineItem(s);
|
||||
if (item != nullptr && strcmp(item->href, href) == 0) return static_cast<int>(s);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
int BookPaginator::fontIdForRunSize(const uint16_t sizePx) const {
|
||||
const uint16_t q = adapters_[0].quantize(sizePx);
|
||||
for (uint8_t i = 0; i < ladderCount_; ++i) {
|
||||
if (ladderSizes_[i] == q) return ladderFontIds_[i];
|
||||
}
|
||||
return ladderCount_ > 0 ? ladderFontIds_[0] : 0;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
#pragma once
|
||||
|
||||
// BookPaginator — one open book's FreeInkBook session: container, fonts,
|
||||
// layout parameters, and the per-chapter page cache. This is the engine-side
|
||||
// half of the reader; EpubReaderActivity keeps the UI half (input, menus,
|
||||
// status bar, refresh cadence) and talks to the engine only through this.
|
||||
//
|
||||
// Memory model (ESP32-C3, no PSRAM — all buffers heap-allocated once in
|
||||
// open() and freed in close()):
|
||||
// book arena 64 KB ZIP catalog + metadata + spine + TOC (corpus
|
||||
// high-water 5-40 KB; omnibus containers that exceed
|
||||
// it fail with a clean OutOfMemory)
|
||||
// index arena 16 KB current chapter's page index + anchor table
|
||||
// page arena 16 KB decoded runs of the page being rendered
|
||||
// build scratch (transient, ~120 KB) allocated only while a chapter
|
||||
// (re)paginates — the layout engine's whole working set
|
||||
// Steady-state page turns touch only the page arena (~2 KB used).
|
||||
|
||||
#include <FreeInkBook.h>
|
||||
#include <cache/PageCache.h>
|
||||
#include <css/Css.h>
|
||||
#include <layout/ChapterLayout.h>
|
||||
#include <render/TtfFont.h>
|
||||
#include <text/Hyphenator.h>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "CpFontAdapter.h"
|
||||
#include "FreeInkBookStorage.h"
|
||||
|
||||
class GfxRenderer;
|
||||
|
||||
class BookPaginator {
|
||||
public:
|
||||
// Called periodically during a chapter (re)pagination so the UI can show
|
||||
// an indexing popup / keep the watchdog fed.
|
||||
struct BuildProgress {
|
||||
void* ctx;
|
||||
void (*fn)(void* ctx, uint32_t pagesDone);
|
||||
}; // value-initialized ({nullptr, nullptr}) by the default argument
|
||||
|
||||
BookPaginator() = default;
|
||||
~BookPaginator() { close(); }
|
||||
BookPaginator(const BookPaginator&) = delete;
|
||||
BookPaginator& operator=(const BookPaginator&) = delete;
|
||||
|
||||
// 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);
|
||||
void close();
|
||||
bool isOpen() const { return open_; }
|
||||
bool isTxt() const { return isTxt_; }
|
||||
|
||||
freeink::book::Book& book() { return book_; }
|
||||
size_t spineCount() const { return isTxt_ ? 1 : book_.spineCount(); }
|
||||
const char* language() const;
|
||||
|
||||
// Refreshes LayoutParams from SETTINGS and the given content box. Must be
|
||||
// called before ensureChapter() and after any settings/orientation change;
|
||||
// a changed generation makes the next ensureChapter() re-paginate.
|
||||
void configureLayout(int16_t pageWidth, int16_t pageHeight, int16_t marginLeft, int16_t marginRight,
|
||||
int16_t marginTop, int16_t marginBottom);
|
||||
|
||||
// Everything layout-relevant, hashed — the cache key.
|
||||
uint32_t generation() const;
|
||||
|
||||
// Opens the chapter's page cache for the current generation, laying the
|
||||
// chapter out first when missing or stale. Heavy only on that miss.
|
||||
freeink::book::BookStatus ensureChapter(uint16_t spineIndex, const BuildProgress& progress = BuildProgress());
|
||||
bool chapterReady() const { return curSpine_ != kNoSpine; }
|
||||
uint16_t currentSpine() const { return curSpine_; }
|
||||
|
||||
uint32_t pageCount() const { return reader_.pageCount(); }
|
||||
uint32_t totalChars() const { return reader_.totalChars(); }
|
||||
uint32_t charStartOfPage(uint32_t pageIndex) const { return reader_.charStart(pageIndex); }
|
||||
uint32_t pageForChar(uint32_t charOffset) const { return reader_.pageForChar(charOffset); }
|
||||
// Resolve an id="" fragment in the CURRENT chapter to a char offset.
|
||||
bool charForAnchor(const char* fragment, uint32_t* charOut) const;
|
||||
|
||||
// Decodes one page into the page arena; the returned Page's records stay
|
||||
// valid until the next readPage() call.
|
||||
freeink::book::BookStatus readPage(uint32_t pageIndex, freeink::book::Page* out);
|
||||
|
||||
// Spine index for a container href (link/TOC targets), -1 when absent.
|
||||
int spineIndexForHref(const char* href) const;
|
||||
|
||||
// Render-path lockstep: the renderer font id for a run's sizePx, quantized
|
||||
// exactly as CpFontAdapter quantized during layout.
|
||||
int fontIdForRunSize(uint16_t sizePx) const;
|
||||
// The ladder rung (px) a run size resolves to — for underline metrics etc.
|
||||
uint16_t quantizeRunSize(uint16_t sizePx) const { return adapters_[0].quantize(sizePx); }
|
||||
|
||||
const freeink::book::LayoutParams& layoutParams() const { return params_; }
|
||||
|
||||
private:
|
||||
static constexpr uint16_t kNoSpine = 0xFFFF;
|
||||
static constexpr size_t kBookArenaSize = 48 * 1024;
|
||||
static constexpr size_t kIndexArenaSize = 12 * 1024;
|
||||
static constexpr size_t kPageArenaSize = 12 * 1024;
|
||||
static constexpr size_t kSheetArenaSize = 12 * 1024;
|
||||
static constexpr size_t kBuildScratchSize = 120 * 1024;
|
||||
|
||||
bool buildFontChain(GfxRenderer& renderer);
|
||||
void loadHyphenator();
|
||||
uint32_t fontFingerprint() const;
|
||||
|
||||
SdBookSource source_;
|
||||
SdCacheStorage cache_;
|
||||
freeink::book::Book book_;
|
||||
freeink::book::LayoutParams params_;
|
||||
freeink::book::PageCacheReader reader_;
|
||||
freeink::book::FontChain chain_;
|
||||
CpFontAdapter adapters_[4] = {
|
||||
CpFontAdapter(EpdFontFamily::REGULAR),
|
||||
CpFontAdapter(EpdFontFamily::BOLD),
|
||||
CpFontAdapter(EpdFontFamily::ITALIC),
|
||||
CpFontAdapter(EpdFontFamily::BOLD_ITALIC),
|
||||
};
|
||||
freeink::book::Hyphenator hyphenator_;
|
||||
std::unique_ptr<uint8_t[]> hyphBlob_; // SD-loaded patterns (non-English books)
|
||||
|
||||
std::unique_ptr<uint8_t[]> bookBuf_;
|
||||
std::unique_ptr<uint8_t[]> indexBuf_;
|
||||
std::unique_ptr<uint8_t[]> pageBuf_;
|
||||
std::unique_ptr<uint8_t[]> sheetBuf_;
|
||||
freeink::book::Arena bookArena_;
|
||||
freeink::book::Arena indexArena_;
|
||||
freeink::book::Arena pageArena_;
|
||||
freeink::book::Arena sheetArena_;
|
||||
freeink::book::CssStylesheet sheet_{};
|
||||
|
||||
// PageCacheReader borrows this for readPage() — must outlive the reader.
|
||||
char cacheName_[64] = "";
|
||||
int ladderFontIds_[CpFontAdapter::kMaxLadder] = {};
|
||||
uint16_t ladderSizes_[CpFontAdapter::kMaxLadder] = {};
|
||||
uint8_t ladderCount_ = 0;
|
||||
uint16_t curSpine_ = kNoSpine;
|
||||
bool open_ = false;
|
||||
bool isTxt_ = false;
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
#include "CpFontAdapter.h"
|
||||
|
||||
#include <Utf8.h>
|
||||
|
||||
bool CpFontAdapter::addSize(const uint16_t sizePx, const EpdFontFamily* family) {
|
||||
if (family == nullptr || count_ >= kMaxLadder) return false;
|
||||
if (count_ > 0 && sizePx <= ladder_[count_ - 1].sizePx) return false; // ascending only
|
||||
ladder_[count_++] = {sizePx, family};
|
||||
return true;
|
||||
}
|
||||
|
||||
uint16_t CpFontAdapter::quantize(const uint16_t sizePx) const {
|
||||
if (count_ == 0) return sizePx;
|
||||
const Rung* best = &ladder_[0];
|
||||
for (uint8_t i = 1; i < count_; ++i) {
|
||||
const int cur = ladder_[i].sizePx > sizePx ? ladder_[i].sizePx - sizePx : sizePx - ladder_[i].sizePx;
|
||||
const int prev = best->sizePx > sizePx ? best->sizePx - sizePx : sizePx - best->sizePx;
|
||||
if (cur < prev) best = &ladder_[i]; // ties keep the smaller rung
|
||||
}
|
||||
return best->sizePx;
|
||||
}
|
||||
|
||||
const EpdFontFamily* CpFontAdapter::familyFor(const uint16_t sizePx) const {
|
||||
if (count_ == 0) return nullptr;
|
||||
const uint16_t q = quantize(sizePx);
|
||||
for (uint8_t i = 0; i < count_; ++i) {
|
||||
if (ladder_[i].sizePx == q) return ladder_[i].family;
|
||||
}
|
||||
return ladder_[0].family;
|
||||
}
|
||||
|
||||
int16_t CpFontAdapter::advance(const uint32_t codepoint, const uint16_t sizePx, uint8_t) {
|
||||
// GfxRenderer does not advance the cursor for combining marks (they center
|
||||
// over the previous base glyph); report the same.
|
||||
if (utf8IsCombiningMark(codepoint)) return 0;
|
||||
const EpdFontFamily* family = familyFor(sizePx);
|
||||
if (family == nullptr) return 0;
|
||||
const EpdGlyph* glyph = family->getGlyph(codepoint, style_);
|
||||
return glyph != nullptr ? static_cast<int16_t>(fp4::toPixel(glyph->advanceX)) : 0;
|
||||
}
|
||||
|
||||
int16_t CpFontAdapter::lineHeight(const uint16_t sizePx) {
|
||||
// advanceY is the newline distance GfxRenderer::getLineHeight() reports —
|
||||
// the same figure the legacy reader spaced lines with (CrossPoint parity).
|
||||
const EpdFontFamily* family = familyFor(sizePx);
|
||||
return family != nullptr ? static_cast<int16_t>(family->getData(style_)->advanceY) : 0;
|
||||
}
|
||||
|
||||
int16_t CpFontAdapter::ascent(const uint16_t sizePx) {
|
||||
const EpdFontFamily* family = familyFor(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) {
|
||||
if (utf8IsCombiningMark(left) || utf8IsCombiningMark(right)) return 0;
|
||||
const EpdFontFamily* family = familyFor(sizePx);
|
||||
if (family == nullptr) return 0;
|
||||
const EpdGlyph* leftGlyph = family->getGlyph(left, style_);
|
||||
if (leftGlyph == nullptr) return 0;
|
||||
const int32_t advFP = leftGlyph->advanceX; // 12.4 fixed-point
|
||||
const int32_t kernFP = family->getKerning(left, right, style_); // 4.4 fixed-point
|
||||
// Differential-rounding parity: layout adds advance(left) + kerning(left,
|
||||
// right); returning the delta between the renderer's fused snap and the
|
||||
// advance's own snap makes the sum equal the renderer's cursor step.
|
||||
return static_cast<int16_t>(fp4::toPixel(advFP + kernFP) - fp4::toPixel(advFP));
|
||||
}
|
||||
|
||||
uint32_t CpFontAdapter::ligature(const uint32_t left, const uint32_t right, uint8_t) {
|
||||
// Size-independent in cpfonts; use the largest rung's table.
|
||||
if (count_ == 0) return 0;
|
||||
return ladder_[count_ - 1].family->getLigature(left, right, style_);
|
||||
}
|
||||
|
||||
bool CpFontAdapter::hasGlyph(const uint32_t codepoint) const {
|
||||
if (count_ == 0) return false;
|
||||
return ladder_[count_ - 1].family->hasGlyph(codepoint, style_);
|
||||
}
|
||||
|
||||
const freeink::book::GlyphBitmap* CpFontAdapter::rasterize(uint32_t, uint16_t) {
|
||||
// CrossPoint draws page records with GfxRenderer (glyph groups, SD overflow
|
||||
// fetch, 2-bit AA all stay in the renderer); the engine's PageRenderer is
|
||||
// not used, so nothing ever rasterizes through this adapter.
|
||||
return nullptr;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
#pragma once
|
||||
|
||||
// CpFontAdapter — one .cpfont style (regular/bold/italic/bold-italic) exposed
|
||||
// through FreeInkBook's RenderFont interface, so the engine lays out with the
|
||||
// exact metrics GfxRenderer will draw with.
|
||||
//
|
||||
// .cpfonts exist on a fixed size ladder (12/14/16/18 px). The engine requests
|
||||
// arbitrary sizePx (headings scale the base size); every request is quantized
|
||||
// to the nearest ladder entry identically in ALL metrics, and the render path
|
||||
// quantizes the same way when picking a font id, so measurement and drawing
|
||||
// can never disagree about which font a run uses.
|
||||
//
|
||||
// Width parity is exact by construction: GfxRenderer advances the cursor with
|
||||
// differential fixed-point rounding — each step is toPixel(advFP(prev) +
|
||||
// kernFP(prev, cur)). This adapter reports advance(cp) = toPixel(advFP(cp))
|
||||
// and kerning(l, r) = toPixel(advFP(l) + kernFP(l, r)) - toPixel(advFP(l));
|
||||
// layout's per-glyph sum then telescopes to precisely the renderer's total.
|
||||
// (One knowingly accepted divergence: the renderer kerns across combining
|
||||
// marks against the base letter; layout sees the mark as `prev` and skips
|
||||
// that kern — sub-pixel, and only on mark-bearing text.)
|
||||
//
|
||||
// Metrics-only and host-compilable (EpdFont + Utf8 + FreeInkBook headers).
|
||||
// rasterize() intentionally returns nullptr: CrossPoint renders page records
|
||||
// through GfxRenderer's own glyph pipeline (see the migration renderer
|
||||
// contract), never through the engine's PageRenderer.
|
||||
|
||||
#include <BookFont.h>
|
||||
#include <EpdFontFamily.h>
|
||||
|
||||
class CpFontAdapter : public freeink::book::RenderFont {
|
||||
public:
|
||||
static constexpr uint8_t kMaxLadder = 6;
|
||||
|
||||
explicit CpFontAdapter(EpdFontFamily::Style style = EpdFontFamily::REGULAR) : style_(style) {}
|
||||
|
||||
// Register one ladder rung. Call in ascending sizePx order.
|
||||
bool addSize(uint16_t sizePx, const EpdFontFamily* family);
|
||||
|
||||
// The ladder size a request resolves to (also used by the render path to
|
||||
// pick the matching font id — keep the two in lockstep).
|
||||
uint16_t quantize(uint16_t sizePx) const;
|
||||
|
||||
// freeink::book::RenderFont
|
||||
int16_t advance(uint32_t codepoint, uint16_t sizePx, uint8_t styleFlags) override;
|
||||
int16_t lineHeight(uint16_t sizePx) override;
|
||||
int16_t ascent(uint16_t sizePx) override;
|
||||
int16_t kerning(uint32_t left, uint32_t right, uint16_t sizePx, uint8_t styleFlags) override;
|
||||
uint32_t ligature(uint32_t left, uint32_t right, uint8_t styleFlags) override;
|
||||
bool hasGlyph(uint32_t codepoint) const override;
|
||||
const freeink::book::GlyphBitmap* rasterize(uint32_t codepoint, uint16_t sizePx) override;
|
||||
|
||||
private:
|
||||
const EpdFontFamily* familyFor(uint16_t sizePx) const;
|
||||
|
||||
struct Rung {
|
||||
uint16_t sizePx;
|
||||
const EpdFontFamily* family;
|
||||
};
|
||||
Rung ladder_[kMaxLadder] = {};
|
||||
uint8_t count_ = 0;
|
||||
EpdFontFamily::Style style_;
|
||||
};
|
||||
@@ -25,6 +25,7 @@
|
||||
#include "EpubReaderFootnotesActivity.h"
|
||||
#include "EpubReaderPercentSelectionActivity.h"
|
||||
#include "EpubReaderUtils.h"
|
||||
#include "FreeInkBookStorage.h"
|
||||
#include "KOReaderCredentialStore.h"
|
||||
#include "KOReaderSyncActivity.h"
|
||||
#include "MappedInputManager.h"
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
#pragma once
|
||||
|
||||
// FreeInkBook storage adapters — bind the engine's BookSource/CacheStorage
|
||||
// interfaces to HalStorage (all SD access must go through the HAL mutex).
|
||||
// Pattern follows freeink-books' BookStorageAdapters.h with CrossPoint's
|
||||
// torn-write-safe temp+rename commit.
|
||||
|
||||
#include <BookStorage.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
// Random access over one book file on the SD card. The file stays open for
|
||||
// the book's lifetime; every readAt takes the storage mutex via HalFile.
|
||||
class SdBookSource : public freeink::book::BookSource {
|
||||
public:
|
||||
bool open(const char* path) {
|
||||
if (!Storage.openFileForRead("FIBSRC", path, file_)) return false;
|
||||
size_ = file_.fileSize64();
|
||||
return size_ > 0;
|
||||
}
|
||||
void close() { file_.close(); }
|
||||
int32_t readAt(uint64_t offset, void* dst, uint32_t len) override {
|
||||
if (!file_.isOpen() || !file_.seek64(offset)) return -1;
|
||||
return file_.read(dst, len);
|
||||
}
|
||||
uint64_t size() const override { return size_; }
|
||||
|
||||
private:
|
||||
HalFile file_;
|
||||
uint64_t size_ = 0;
|
||||
};
|
||||
|
||||
// Layout-cache files inside one book's cache directory. Writes stream to a
|
||||
// temp name and commit via rename, so an interrupted write leaves the old
|
||||
// file or none — never a torn one (the engine additionally verifies a footer
|
||||
// magic). Reads keep the last-touched file open: page turns are then one
|
||||
// seek+read instead of a directory walk per call.
|
||||
class SdCacheStorage : public freeink::book::CacheStorage {
|
||||
public:
|
||||
// `dir` is the per-book cache directory (e.g. ".crosspoint/epub_<hash>").
|
||||
void setDir(const char* dir) {
|
||||
snprintf(dir_, sizeof(dir_), "%s", dir);
|
||||
Storage.ensureDirectoryExists(dir_);
|
||||
closeRead();
|
||||
}
|
||||
|
||||
bool exists(const char* name) override { return Storage.exists(path(name)); }
|
||||
|
||||
bool remove(const char* name) override {
|
||||
invalidateRead(name);
|
||||
return Storage.remove(path(name));
|
||||
}
|
||||
|
||||
int64_t fileSize(const char* name) override {
|
||||
if (!ensureReadOpen(name)) return -1;
|
||||
return static_cast<int64_t>(readFile_.fileSize64());
|
||||
}
|
||||
|
||||
int32_t readAt(const char* name, uint32_t offset, void* dst, uint32_t len) override {
|
||||
if (!ensureReadOpen(name) || !readFile_.seekSet(offset)) return -1;
|
||||
return readFile_.read(dst, len);
|
||||
}
|
||||
|
||||
bool beginWrite(const char* name) override {
|
||||
snprintf(commitPath_, sizeof(commitPath_), "%s/%s", dir_, name);
|
||||
invalidateRead(name);
|
||||
if (!Storage.openFileForWrite("FIBCACHE", path(kTempName), write_)) {
|
||||
LOG_ERR("FIBCACHE", "beginWrite failed: %s", commitPath_);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
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)
|
||||
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_);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
static constexpr const char* kTempName = "_tmp.fibp";
|
||||
|
||||
const char* path(const char* name) {
|
||||
snprintf(pathBuf_, sizeof(pathBuf_), "%s/%s", dir_, name);
|
||||
return pathBuf_;
|
||||
}
|
||||
|
||||
bool ensureReadOpen(const char* name) {
|
||||
if (readFile_.isOpen() && strncmp(readName_, name, sizeof(readName_)) == 0) return true;
|
||||
closeRead();
|
||||
if (!Storage.openFileForRead("FIBCACHE", path(name), readFile_)) return false;
|
||||
snprintf(readName_, sizeof(readName_), "%s", name);
|
||||
return true;
|
||||
}
|
||||
|
||||
void invalidateRead(const char* name) {
|
||||
if (readFile_.isOpen() && strncmp(readName_, name, sizeof(readName_)) == 0) closeRead();
|
||||
}
|
||||
|
||||
void closeRead() {
|
||||
if (readFile_.isOpen()) readFile_.close();
|
||||
readName_[0] = '\0';
|
||||
}
|
||||
|
||||
char dir_[96] = "";
|
||||
char pathBuf_[192];
|
||||
char commitPath_[192];
|
||||
char readName_[80] = "";
|
||||
HalFile readFile_;
|
||||
HalFile write_;
|
||||
};
|
||||
Reference in New Issue
Block a user