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:
Justin Mitchell
2026-07-08 00:22:21 -04:00
parent 5776911af0
commit 0848649ce2
49 changed files with 1065 additions and 15633 deletions
+6 -4
View File
@@ -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:
+4
View File
@@ -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;
+357
View File
@@ -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;
}
+142
View File
@@ -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;
};
+85
View File
@@ -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;
}
+62
View File
@@ -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"
+122
View File
@@ -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_;
};