Fixes and new apps (#30)
- M5 Unit Modules library + M5 Unit Test app - Minor fixes for TodoList, TwoEleven, Snake, Brainfuck and Breakout - Fixed SerialConsole to use the uart controller as it was broken in one of the many updates - Fixed keyboard input in TwoEleven, Breakout, Magic8Ball and Snake - Bluetooth Media Keys app, supports pressing physical keys to trigger the corresponding buttonmatrix button - Epub Reader app
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
#include "EpubReader.h"
|
||||
#include "HtmlStrip.h" // stripHtmlToText
|
||||
#include <tt_bundle.h>
|
||||
#include <tt_lvgl_toolbar.h>
|
||||
#include <tt_app_alertdialog.h>
|
||||
#include <tt_app_selectiondialog.h>
|
||||
#include <tactility/log.h>
|
||||
#include <esp_heap_caps.h>
|
||||
#include <sys/stat.h>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
static const char* TAG = "EpubReader";
|
||||
static const char* EPUB_FILE_ARGUMENT = "file";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
std::string EpubReader::getAppDataRoot(AppHandle app) {
|
||||
char path[128] = {0};
|
||||
size_t sz = sizeof(path);
|
||||
tt_app_get_user_data_path(app, path, &sz);
|
||||
// path = /sdcard/user/app/one.tactility.epubreader → extract "/sdcard"
|
||||
// path = /data/user/app/one.tactility.epubreader → extract "/data"
|
||||
std::string s(path);
|
||||
size_t pos = s.find('/', 1);
|
||||
return (pos != std::string::npos) ? s.substr(0, pos) : "/sdcard";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Books folder persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void EpubReader::loadBooksPath() {
|
||||
if (!appHandle_) return;
|
||||
char path[128]; size_t sz = sizeof(path);
|
||||
tt_app_get_user_data_child_path(appHandle_, "books_folder.txt", path, &sz);
|
||||
FILE* f = fopen(path, "r");
|
||||
if (!f) return;
|
||||
char buf[256] = {};
|
||||
if (fgets(buf, sizeof(buf), f)) {
|
||||
size_t len = strlen(buf);
|
||||
if (len > 0 && buf[len - 1] == '\n') buf[len - 1] = '\0';
|
||||
if (buf[0] != '\0') booksPath_ = buf;
|
||||
}
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
void EpubReader::saveBooksPath() {
|
||||
if (!appHandle_) return;
|
||||
char path[128]; size_t sz = sizeof(path);
|
||||
tt_app_get_user_data_child_path(appHandle_, "books_folder.txt", path, &sz);
|
||||
FILE* f = fopen(path, "w");
|
||||
if (!f) return;
|
||||
fprintf(f, "%s\n", booksPath_.c_str());
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File type helpers (static members - used by UI and Async TUs via the class)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bool EpubReader::isSupportedFile(const std::string& name) {
|
||||
auto pos = name.rfind('.');
|
||||
if (pos == std::string::npos) return false;
|
||||
std::string ext = name.substr(pos);
|
||||
std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower);
|
||||
return ext == ".epub" || ext == ".txt";
|
||||
}
|
||||
|
||||
bool EpubReader::isTextFile(const std::string& path) {
|
||||
auto pos = path.rfind('.');
|
||||
if (pos == std::string::npos) return false;
|
||||
std::string ext = path.substr(pos);
|
||||
std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower);
|
||||
return ext == ".txt";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persistence - plain text file in the app user-data directory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void EpubReader::saveProgress() {
|
||||
if (currentFilePath_.empty() || !appHandle_) return;
|
||||
char path[128]; size_t sz = sizeof(path);
|
||||
tt_app_get_user_data_child_path(appHandle_, "progress.txt", path, &sz);
|
||||
FILE* f = fopen(path, "w");
|
||||
if (!f) return;
|
||||
// Text mode: pageOffset_ is a scroll-Y pixel position; epub: spine chapter index.
|
||||
// The mode tag makes the file self-describing so the value's semantics are unambiguous.
|
||||
int savedIndex = textMode_ ? (int)pageOffset_ : currentSpineIndex_;
|
||||
fprintf(f, "%s\n%d\n%s\n", currentFilePath_.c_str(), savedIndex,
|
||||
textMode_ ? "text" : "epub");
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
bool EpubReader::loadProgress(std::string& outPath, int& outChapter, bool& outIsText) {
|
||||
if (!appHandle_) return false;
|
||||
char path[128]; size_t sz = sizeof(path);
|
||||
tt_app_get_user_data_child_path(appHandle_, "progress.txt", path, &sz);
|
||||
FILE* f = fopen(path, "r");
|
||||
if (!f) return false;
|
||||
char filePath[256] = {};
|
||||
bool ok = (fgets(filePath, sizeof(filePath), f) != nullptr);
|
||||
int chapter = 0;
|
||||
char modeStr[8] = "epub"; // default for backward-compat with old progress files
|
||||
if (ok) {
|
||||
size_t len = strlen(filePath);
|
||||
if (len > 0 && filePath[len - 1] == '\n') filePath[len - 1] = '\0';
|
||||
if (fscanf(f, "%d", &chapter) != 1) chapter = 0;
|
||||
fscanf(f, " %7s", modeStr); // optional - old files won't have it
|
||||
}
|
||||
fclose(f);
|
||||
if (!ok || filePath[0] == '\0') return false;
|
||||
outPath = filePath;
|
||||
outChapter = chapter;
|
||||
outIsText = (strcmp(modeStr, "text") == 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapter loading and pagination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Render all of pageContent_ as per-paragraph labels, then restore saved scroll position.
|
||||
// Prev/Next navigate by scrolling one viewport height within the chapter; at chapter
|
||||
// boundaries they load the adjacent chapter (like text mode, but across chapters).
|
||||
void EpubReader::renderPage() {
|
||||
if (!contentWidget_) return;
|
||||
lv_obj_clean(contentWidget_);
|
||||
if (pageContent_.empty()) {
|
||||
lv_obj_t* lbl = lv_label_create(contentWidget_);
|
||||
lv_label_set_text(lbl, "(Empty chapter)");
|
||||
lv_obj_scroll_to_y(lv_obj_get_parent(contentWidget_), 0, LV_ANIM_OFF);
|
||||
return;
|
||||
}
|
||||
renderSlice(pageContent_);
|
||||
lv_coord_t scrollY = (pageOffset_ > (size_t)LV_COORD_MAX) ? LV_COORD_MAX : (lv_coord_t)pageOffset_;
|
||||
lv_obj_scroll_to_y(lv_obj_get_parent(contentWidget_), scrollY, LV_ANIM_OFF);
|
||||
}
|
||||
|
||||
void EpubReader::loadChapter(int index, int direction) {
|
||||
if (!epub_ || !contentWidget_) return;
|
||||
|
||||
const auto& spine = epub_->getSpine();
|
||||
|
||||
// Auto-skip chapters whose HTML strips to nothing (image-only, boilerplate, etc.)
|
||||
// currentSpineIndex_ is NOT updated until we confirm the chapter has content -
|
||||
// if we exhaust the spine before finding any, we return with it unchanged so
|
||||
// subsequent navigation calls aren't confused by a corrupted index.
|
||||
while (true) {
|
||||
if (index < 0 || index >= (int)spine.size()) return;
|
||||
|
||||
std::string html = epub_->readFile(spine[index].href, MAX_CHAPTER_HTML);
|
||||
stripHtmlToText(html, pageContent_);
|
||||
html = {}; // release raw HTML before rendering
|
||||
|
||||
if (!pageContent_.empty()) break;
|
||||
|
||||
// Chapter stripped to nothing - advance in the navigation direction
|
||||
if (direction == 0) {
|
||||
currentSpineIndex_ = index;
|
||||
pageOffset_ = 0;
|
||||
LOG_W(TAG, "Chapter %d stripped to nothing and no direction to skip - blank chapter", index);
|
||||
lv_obj_clean(contentWidget_);
|
||||
lv_obj_t* lbl = lv_label_create(contentWidget_);
|
||||
lv_label_set_text(lbl, "(Chapter content unavailable)");
|
||||
return;
|
||||
}
|
||||
index += direction;
|
||||
}
|
||||
|
||||
currentSpineIndex_ = index;
|
||||
pageOffset_ = 0;
|
||||
renderPage();
|
||||
// When arriving from a later chapter (going backward), jump to the end of this chapter
|
||||
// after LVGL has laid out the labels (deferred so content height is known).
|
||||
if (direction < 0) lv_async_call(asyncScrollToEnd, this);
|
||||
saveProgress();
|
||||
}
|
||||
|
||||
void EpubReader::openTocDialog() {
|
||||
if (!epub_) return;
|
||||
const auto& toc = epub_->getToc();
|
||||
if (toc.empty()) return;
|
||||
|
||||
std::vector<const char*> titles;
|
||||
titles.reserve(toc.size());
|
||||
for (const auto& item : toc) {
|
||||
titles.push_back(item.title.c_str());
|
||||
}
|
||||
tocDialogId_ = tt_app_selectiondialog_start(
|
||||
"Table of Contents", (int)titles.size(), titles.data()
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void EpubReader::onShow(AppHandle app, lv_obj_t* parent) {
|
||||
appHandle_ = app;
|
||||
|
||||
// Hard requirement: without PSRAM this app cannot parse EPUBs or hold fonts.
|
||||
// Show a one-shot alert then stop; psramAlertId_ prevents re-launching the dialog
|
||||
// if onShow is called again before tt_app_stop() takes effect.
|
||||
if (heap_caps_get_total_size(MALLOC_CAP_SPIRAM) == 0) {
|
||||
if (psramAlertId_ == 0) {
|
||||
static const char* kButtons[] = { "OK" };
|
||||
psramAlertId_ = tt_app_alertdialog_start(
|
||||
"PSRAM Required",
|
||||
"Epub Reader requires a device with PSRAM and cannot run on this hardware.",
|
||||
kButtons, 1
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
loadFonts();
|
||||
|
||||
if (dataRoot_.empty()) {
|
||||
dataRoot_ = getAppDataRoot(app);
|
||||
// Ensure the app data directory exists (needed for progress.txt and books_folder.txt)
|
||||
char dir[128]; size_t sz = sizeof(dir);
|
||||
tt_app_get_user_data_path(app, dir, &sz);
|
||||
for (char* p = dir + 1; *p; ++p) {
|
||||
if (*p == '/') { *p = '\0'; mkdir(dir, 0755); *p = '/'; }
|
||||
}
|
||||
mkdir(dir, 0755);
|
||||
// Load saved books folder; start browser there if set, otherwise dataRoot_
|
||||
loadBooksPath();
|
||||
browsePath_ = booksPath_.empty() ? dataRoot_ : booksPath_;
|
||||
}
|
||||
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
toolbar_ = tt_lvgl_toolbar_create_for_app(parent, app);
|
||||
|
||||
wrapperWidget_ = lv_obj_create(parent);
|
||||
lv_obj_set_width(wrapperWidget_, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(wrapperWidget_, 1);
|
||||
lv_obj_set_flex_flow(wrapperWidget_, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_all(wrapperWidget_, 0, 0);
|
||||
lv_obj_set_style_border_width(wrapperWidget_, 0, 0);
|
||||
lv_obj_set_style_bg_opa(wrapperWidget_, LV_OPA_TRANSP, 0);
|
||||
lv_obj_remove_flag(wrapperWidget_, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
// Apply chapter jump from TOC dialog (stored in onResult)
|
||||
if (pendingChapterIndex_ >= 0) {
|
||||
currentSpineIndex_ = pendingChapterIndex_;
|
||||
pendingChapterIndex_ = -1;
|
||||
}
|
||||
|
||||
// Attempt to open a book: launch parameter → saved progress (first show only).
|
||||
// Both paths use lv_async_call so EpubService::open runs at fresh stack
|
||||
// depth (not nested inside the deep GuiService→onShow call chain).
|
||||
bool asyncOpen = false;
|
||||
if (!epub_) {
|
||||
BundleHandle bundle = tt_app_get_parameters(app);
|
||||
if (bundle) {
|
||||
char filepath[256] = {0};
|
||||
if (tt_bundle_opt_string(bundle, EPUB_FILE_ARGUMENT, filepath, (uint32_t)sizeof(filepath))) {
|
||||
LOG_I(TAG, "Opening from parameter: %s", filepath);
|
||||
pendingFilePath_ = filepath;
|
||||
lv_async_call(asyncOpenEpub, this);
|
||||
asyncOpen = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
bool asyncRestore = false;
|
||||
if (!epub_ && !asyncOpen) {
|
||||
std::string savedPath;
|
||||
int savedChapter = 0;
|
||||
bool savedIsText = false;
|
||||
if (loadProgress(savedPath, savedChapter, savedIsText)) {
|
||||
// Schedule open via lv_async_call so it runs at the same call-stack
|
||||
// depth as asyncOpenEpub - calling EpubService::open directly here
|
||||
// (from GuiService→onShow) adds extra frames that overflow the task stack.
|
||||
pendingFilePath_ = savedPath;
|
||||
currentSpineIndex_ = savedChapter;
|
||||
lv_async_call(asyncRestoreEpub, this);
|
||||
asyncRestore = true;
|
||||
LOG_I(TAG, "Scheduling restore: %s ch%d", savedPath.c_str(), savedChapter);
|
||||
}
|
||||
}
|
||||
|
||||
if (epub_ && epub_->isValid()) {
|
||||
setReaderToolbarButtons();
|
||||
buildReaderUI(wrapperWidget_);
|
||||
} else if (!asyncRestore && !asyncOpen) {
|
||||
epub_ = nullptr;
|
||||
setBrowserToolbarButtons();
|
||||
buildBrowserUI(wrapperWidget_);
|
||||
} else {
|
||||
// asyncOpenEpub / asyncRestoreEpub will replace the wrapper contents when
|
||||
// they fire; show an empty placeholder for now (no browser flash on ePaper)
|
||||
}
|
||||
}
|
||||
|
||||
void EpubReader::onHide(AppHandle /*app*/) {
|
||||
++openToken_; // invalidate any in-flight background open task
|
||||
// Skip font unload during a dialog roundtrip (tocDialogId_ is non-zero while
|
||||
// the TOC selection dialog is open). loadFonts() guards against double-loading,
|
||||
// so fonts will be reused as-is when onShow fires after the dialog closes.
|
||||
// When truly exiting, no dialog is open and fonts are freed as normal.
|
||||
if (tocDialogId_ == 0 && psramAlertId_ == 0) {
|
||||
unloadFonts();
|
||||
}
|
||||
contentWidget_ = nullptr;
|
||||
wrapperWidget_ = nullptr;
|
||||
toolbar_ = nullptr;
|
||||
appHandle_ = nullptr;
|
||||
pageContent_ = {}; // release chapter/text memory
|
||||
pageOffset_ = 0;
|
||||
textMode_ = false;
|
||||
}
|
||||
|
||||
void EpubReader::onResult(AppHandle /*app*/, void* /*data*/, AppLaunchId launchId,
|
||||
AppResult /*result*/, BundleHandle resultData) {
|
||||
// Never touch LVGL objects here - store state for onShow
|
||||
if (launchId == psramAlertId_ && psramAlertId_ != 0) {
|
||||
tt_app_stop();
|
||||
return;
|
||||
}
|
||||
if (launchId == tocDialogId_ && tocDialogId_ != 0) {
|
||||
tocDialogId_ = 0;
|
||||
if (resultData) {
|
||||
int32_t selection = tt_app_selectiondialog_get_result_index(resultData);
|
||||
if (selection >= 0 && epub_) {
|
||||
const auto& toc = epub_->getToc();
|
||||
const auto& spine = epub_->getSpine();
|
||||
if (selection < (int32_t)toc.size()) {
|
||||
pendingChapterIndex_ = -1; // Reset before searching
|
||||
for (size_t i = 0; i < spine.size(); ++i) {
|
||||
if (spine[i].href == toc[selection].href) {
|
||||
pendingChapterIndex_ = (int)i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
#pragma once
|
||||
|
||||
#include <TactilityCpp/App.h>
|
||||
#include <tt_app.h>
|
||||
#include <lvgl.h>
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "EpubService.h"
|
||||
|
||||
// Icon fonts (compiled in; small and device-independent)
|
||||
LV_FONT_DECLARE(material_symbols_shared_20)
|
||||
LV_FONT_DECLARE(material_symbols_shared_32)
|
||||
|
||||
// Font selection utility (defined in EpubReaderUI.cpp, shared across translation units).
|
||||
const lv_font_t* selectContentFont(bool italic = false, bool bold = false);
|
||||
|
||||
class EpubReader final : public App {
|
||||
|
||||
// App handle (stored for use in async rebuilds where AppHandle isn't available)
|
||||
AppHandle appHandle_ = nullptr;
|
||||
|
||||
static constexpr size_t MAX_CHAPTER_HTML = 131072; // max HTML bytes read per chapter (128 KB)
|
||||
|
||||
// EPUB / text reader state
|
||||
std::shared_ptr<EpubService> epub_;
|
||||
bool textMode_ = false; // true when showing a plain .txt file (no epub_)
|
||||
std::string currentFilePath_;
|
||||
int currentSpineIndex_ = 0;
|
||||
AppLaunchId tocDialogId_ = 0;
|
||||
AppLaunchId psramAlertId_ = 0; // Non-zero once the no-PSRAM alert has been launched
|
||||
int pendingChapterIndex_ = -1; // Set by onResult, consumed in onShow
|
||||
|
||||
// Per-chapter stripped plain text + current display offset
|
||||
std::string pageContent_;
|
||||
size_t pageOffset_ = 0;
|
||||
|
||||
// File browser state
|
||||
std::string dataRoot_;
|
||||
std::string browsePath_;
|
||||
std::string pendingFilePath_; // Set before async open
|
||||
std::vector<std::pair<std::string, bool>> browserEntries_; // {name, isDir}
|
||||
|
||||
// Books folder
|
||||
std::string booksPath_; // saved books folder path (empty = not set)
|
||||
int shelfPage_ = 0; // current page in shelf view (persists across open/close)
|
||||
|
||||
// Background open token - incremented on each new open + in onHide to invalidate
|
||||
// any in-flight background task so its result is discarded if it arrives late.
|
||||
std::atomic<uint32_t> openToken_ = 0;
|
||||
|
||||
// UI pointers (nulled in onHide)
|
||||
lv_obj_t* toolbar_ = nullptr;
|
||||
lv_obj_t* wrapperWidget_ = nullptr;
|
||||
// In text mode: an lv_label.
|
||||
// In EPUB mode: a transparent flex-column container holding per-paragraph labels.
|
||||
lv_obj_t* contentWidget_ = nullptr;
|
||||
|
||||
// Font lifecycle - loads the 4 Noto Serif variants for the active display size tier
|
||||
// from binary assets via lv_binfont_create; unloaded in onHide (skipped during dialog roundtrips).
|
||||
void loadFonts();
|
||||
void unloadFonts();
|
||||
|
||||
// UI builders
|
||||
void buildReaderUI(lv_obj_t* parent);
|
||||
// Parse an ESC-encoded page slice and populate contentWidget_ with labels.
|
||||
void renderSlice(const std::string& slice);
|
||||
void buildBrowserUI(lv_obj_t* parent);
|
||||
void buildShelfUI(lv_obj_t* parent);
|
||||
void setReaderToolbarButtons();
|
||||
void setBrowserToolbarButtons();
|
||||
|
||||
// Chapter / text logic
|
||||
void loadChapter(int index, int direction = 1);
|
||||
void renderPage();
|
||||
void saveProgress();
|
||||
bool loadProgress(std::string& outPath, int& outChapter, bool& outIsText);
|
||||
void openTocDialog();
|
||||
|
||||
// Helpers
|
||||
static std::string getAppDataRoot(AppHandle app);
|
||||
void loadBooksPath();
|
||||
void saveBooksPath();
|
||||
static bool isSupportedFile(const std::string& name);
|
||||
static bool isTextFile(const std::string& path);
|
||||
void scanBooksDir(const std::string& path, const std::string& prefix); // shelf flat-scan helper
|
||||
|
||||
// Async rebuilds - safe to call from within LVGL event callbacks
|
||||
static void asyncOpenEpub(void* data);
|
||||
static void asyncRestoreEpub(void* data); // like asyncOpenEpub but keeps currentSpineIndex_
|
||||
static void asyncNavigateBrowser(void* data);
|
||||
static void asyncSwitchToBrowser(void* data);
|
||||
|
||||
// Background open - runs EpubService::open off the LVGL task to avoid stack overflow.
|
||||
// asyncOpenComplete is posted via lv_async_call when done.
|
||||
static void spawnOpenTask(EpubReader* self, bool restore); // shared setup for both open paths
|
||||
static void backgroundOpenTask(void* data); // xTaskCreate target
|
||||
static void asyncOpenComplete(void* data); // lv_async_call target, back on LVGL task
|
||||
static void asyncScrollToEnd(void* data); // lv_async_call: scroll to bottom after layout
|
||||
|
||||
// Navigation - shared by toolbar callbacks and tap zones
|
||||
void doPrev();
|
||||
void doNext();
|
||||
|
||||
// LVGL event callbacks
|
||||
static void onPrevPressed(lv_event_t* e);
|
||||
static void onNextPressed(lv_event_t* e);
|
||||
static void onReaderTap(lv_event_t* e);
|
||||
static void onTocPressed(lv_event_t* e);
|
||||
static void onBrowsePressed(lv_event_t* e);
|
||||
static void onBrowserBack(lv_event_t* e);
|
||||
static void onBrowserItem(lv_event_t* e);
|
||||
static void onSetBooksFolder(lv_event_t* e); // "Folder" toolbar button
|
||||
static void onShelfFirst(lv_event_t* e);
|
||||
static void onShelfPrev(lv_event_t* e);
|
||||
static void onShelfNext(lv_event_t* e);
|
||||
static void onShelfLast(lv_event_t* e);
|
||||
|
||||
public:
|
||||
void onShow(AppHandle context, lv_obj_t* parent) override;
|
||||
void onHide(AppHandle context) override;
|
||||
void onResult(AppHandle app, void* data, AppLaunchId launchId, AppResult result, BundleHandle resultData) override;
|
||||
};
|
||||
@@ -0,0 +1,370 @@
|
||||
#include "EpubReader.h"
|
||||
#include <tt_lvgl_toolbar.h>
|
||||
#include <tt_lock.h>
|
||||
#include <tactility/log.h>
|
||||
#include <Tactility/kernel/Kernel.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
#include <freertos/idf_additions.h>
|
||||
#include <climits>
|
||||
#include <cstdio>
|
||||
#include <esp_heap_caps.h>
|
||||
|
||||
static const char* TAG = "EpubReader";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Background open arguments - allocated on heap, owned by the background task,
|
||||
// freed in asyncOpenComplete (or in spawnOpenTask on task-create failure).
|
||||
// ---------------------------------------------------------------------------
|
||||
struct OpenArgs {
|
||||
EpubReader* self;
|
||||
std::string filePath; // path to .epub or .txt
|
||||
bool restore; // true = keep currentSpineIndex_ (restore mode)
|
||||
int spineIndex; // savedChapter / savedOffset for restore
|
||||
uint32_t token; // matches self->openToken_ at dispatch time
|
||||
// Results filled by backgroundOpenTask:
|
||||
std::shared_ptr<EpubService> epub; // null on failure
|
||||
std::string textContent; // pre-read content for .txt files
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Async rebuilds (deferred - safe to trigger from within LVGL callbacks)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Helper: allocate an OpenArgs, clean the wrapper, show a placeholder, and spawn
|
||||
// the background task. Used by both asyncOpenEpub and asyncRestoreEpub.
|
||||
void EpubReader::spawnOpenTask(EpubReader* self, bool restore) {
|
||||
auto* args = new OpenArgs{};
|
||||
args->self = self;
|
||||
args->filePath = self->pendingFilePath_;
|
||||
args->restore = restore;
|
||||
args->spineIndex = self->currentSpineIndex_;
|
||||
args->token = self->openToken_;
|
||||
|
||||
// Show a brief placeholder so old content doesn't linger during the open
|
||||
lv_obj_clean(self->wrapperWidget_);
|
||||
tt_lvgl_toolbar_clear_actions(self->toolbar_);
|
||||
lv_obj_t* lbl = lv_label_create(self->wrapperWidget_);
|
||||
lv_obj_set_style_pad_all(lbl, 8, 0);
|
||||
lv_label_set_text(lbl, restore ? "Loading..." : "Opening...");
|
||||
|
||||
if (xTaskCreateWithCaps(EpubReader::backgroundOpenTask, "epubOpen", 32768 /* 32 KB */, args, 3, nullptr, MALLOC_CAP_SPIRAM)
|
||||
!= pdPASS) {
|
||||
LOG_E(TAG, "Failed to create open task - out of memory");
|
||||
delete args;
|
||||
self->epub_ = nullptr;
|
||||
self->setBrowserToolbarButtons();
|
||||
lv_obj_clean(self->wrapperWidget_);
|
||||
self->buildBrowserUI(self->wrapperWidget_);
|
||||
}
|
||||
}
|
||||
|
||||
// Like asyncOpenEpub but keeps currentSpineIndex_ - used when restoring a saved session.
|
||||
void EpubReader::asyncRestoreEpub(void* data) {
|
||||
auto* self = static_cast<EpubReader*>(data);
|
||||
if (!self->wrapperWidget_ || !self->toolbar_) return;
|
||||
self->textMode_ = false;
|
||||
++self->openToken_;
|
||||
spawnOpenTask(self, /*restore=*/true);
|
||||
}
|
||||
|
||||
void EpubReader::asyncNavigateBrowser(void* data) {
|
||||
auto* self = static_cast<EpubReader*>(data);
|
||||
if (!self->wrapperWidget_ || !self->toolbar_) return;
|
||||
self->setBrowserToolbarButtons();
|
||||
lv_obj_clean(self->wrapperWidget_);
|
||||
self->buildBrowserUI(self->wrapperWidget_);
|
||||
}
|
||||
|
||||
void EpubReader::asyncOpenEpub(void* data) {
|
||||
auto* self = static_cast<EpubReader*>(data);
|
||||
if (!self->wrapperWidget_ || !self->toolbar_) return;
|
||||
|
||||
self->currentSpineIndex_ = 0;
|
||||
self->textMode_ = false;
|
||||
++self->openToken_;
|
||||
|
||||
spawnOpenTask(self, /*restore=*/false);
|
||||
}
|
||||
|
||||
void EpubReader::asyncSwitchToBrowser(void* data) {
|
||||
auto* self = static_cast<EpubReader*>(data);
|
||||
if (!self->wrapperWidget_ || !self->toolbar_) return;
|
||||
|
||||
self->epub_ = nullptr;
|
||||
self->textMode_ = false;
|
||||
self->currentSpineIndex_ = 0;
|
||||
self->contentWidget_ = nullptr;
|
||||
|
||||
// Return to books folder (if set) so the user lands on their library
|
||||
if (!self->booksPath_.empty()) self->browsePath_ = self->booksPath_;
|
||||
self->setBrowserToolbarButtons();
|
||||
lv_obj_clean(self->wrapperWidget_);
|
||||
self->buildBrowserUI(self->wrapperWidget_);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Background open task + completion callback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Runs on a FreeRTOS task with its own 32 KB stack.
|
||||
// Does all SD card I/O (epub parse or text file read) completely off the LVGL
|
||||
// task to prevent stack overflow and serialise SDMMC access.
|
||||
void EpubReader::backgroundOpenTask(void* data) {
|
||||
auto* a = static_cast<OpenArgs*>(data);
|
||||
|
||||
// Acquire the filesystem lock before any SD card I/O - prevents concurrent
|
||||
// SDMMC access from the background and LVGL tasks (bus errors 0x107/0x108).
|
||||
auto lock = tt_lock_alloc_for_path(a->filePath.c_str());
|
||||
if (!tt_lock_acquire(lock, tt::kernel::MAX_TICKS)) {
|
||||
LOG_E(TAG, "FS lock timed out, skipping open: %s", a->filePath.c_str());
|
||||
tt_lock_free(lock);
|
||||
lv_async_call(asyncOpenComplete, a);
|
||||
vTaskDelete(nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTextFile(a->filePath)) {
|
||||
// Read the entire text file here (under the lock) so asyncOpenComplete
|
||||
// only needs to update UI state - no SD I/O on the LVGL task.
|
||||
FILE* f = fopen(a->filePath.c_str(), "r");
|
||||
if (f) {
|
||||
char buf[512];
|
||||
while (a->textContent.size() < MAX_CHAPTER_HTML) {
|
||||
size_t remaining = MAX_CHAPTER_HTML - a->textContent.size();
|
||||
size_t toRead = remaining < sizeof(buf) ? remaining : sizeof(buf);
|
||||
size_t n = fread(buf, 1, toRead, f);
|
||||
if (n == 0) break;
|
||||
a->textContent.append(buf, n);
|
||||
}
|
||||
fclose(f);
|
||||
} else {
|
||||
LOG_E(TAG, "Cannot open text file: %s", a->filePath.c_str());
|
||||
}
|
||||
} else {
|
||||
// Open the epub (ZIP directory scan + OPF/NCX XML parse)
|
||||
a->epub = EpubService::open(a->filePath);
|
||||
}
|
||||
|
||||
tt_lock_release(lock);
|
||||
tt_lock_free(lock);
|
||||
|
||||
// Signal the LVGL task that the work is done
|
||||
lv_async_call(asyncOpenComplete, a);
|
||||
vTaskDelete(nullptr);
|
||||
}
|
||||
|
||||
// Called back on the LVGL task (via lv_async_call from backgroundOpenTask).
|
||||
// Checks the open token, then either builds the reader UI or falls back to browser.
|
||||
void EpubReader::asyncOpenComplete(void* data) {
|
||||
auto* a = static_cast<OpenArgs*>(data);
|
||||
auto* self = a->self;
|
||||
|
||||
// Discard stale results if the app was hidden or a newer open was started
|
||||
if (!self->wrapperWidget_ || !self->toolbar_ || a->token != self->openToken_) {
|
||||
delete a;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTextFile(a->filePath)) {
|
||||
self->epub_ = nullptr;
|
||||
self->textMode_ = false;
|
||||
self->currentSpineIndex_ = a->restore ? a->spineIndex : 0;
|
||||
if (!a->textContent.empty()) {
|
||||
// Content was pre-read in backgroundOpenTask (under the FS lock) -
|
||||
// no SD I/O needed here on the LVGL task.
|
||||
self->pageContent_ = std::move(a->textContent);
|
||||
self->textMode_ = true;
|
||||
self->currentFilePath_ = a->filePath;
|
||||
self->pageOffset_ = (self->currentSpineIndex_ > 0)
|
||||
? (size_t)self->currentSpineIndex_ : 0u;
|
||||
self->currentSpineIndex_ = 0;
|
||||
LOG_I(TAG, "Text file loaded: %zu bytes", self->pageContent_.size());
|
||||
} else {
|
||||
// Text content empty (lock timeout or read error) - show error in browser
|
||||
LOG_E(TAG, "Text content empty; cannot display: %s", a->filePath.c_str());
|
||||
lv_obj_clean(self->wrapperWidget_);
|
||||
lv_obj_t* errLbl = lv_label_create(self->wrapperWidget_);
|
||||
lv_obj_set_style_pad_all(errLbl, 8, 0);
|
||||
lv_label_set_text(errLbl, "Failed to open file.\nPlease try again.");
|
||||
self->setBrowserToolbarButtons();
|
||||
delete a;
|
||||
return;
|
||||
}
|
||||
self->setReaderToolbarButtons();
|
||||
lv_obj_clean(self->wrapperWidget_);
|
||||
self->buildReaderUI(self->wrapperWidget_);
|
||||
delete a;
|
||||
return;
|
||||
}
|
||||
|
||||
if (a->epub && a->epub->isValid()) {
|
||||
self->epub_ = a->epub;
|
||||
self->currentFilePath_ = a->filePath;
|
||||
self->setReaderToolbarButtons();
|
||||
lv_obj_clean(self->wrapperWidget_);
|
||||
self->buildReaderUI(self->wrapperWidget_);
|
||||
} else {
|
||||
LOG_E(TAG, "Failed to open: %s", a->filePath.c_str());
|
||||
self->epub_ = nullptr;
|
||||
self->currentSpineIndex_ = 0;
|
||||
self->setBrowserToolbarButtons();
|
||||
lv_obj_clean(self->wrapperWidget_);
|
||||
self->buildBrowserUI(self->wrapperWidget_);
|
||||
}
|
||||
delete a;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LVGL event callbacks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Fired via lv_async_call after renderPage() when loading a chapter backward (direction < 0).
|
||||
// By the time this runs LVGL has completed layout, so content height is known and we can
|
||||
// scroll to the very end - placing the user at the bottom of the chapter they backed into.
|
||||
void EpubReader::asyncScrollToEnd(void* data) {
|
||||
auto* self = static_cast<EpubReader*>(data);
|
||||
if (!self->contentWidget_ || !self->wrapperWidget_) return;
|
||||
lv_obj_t* scroll = lv_obj_get_parent(self->contentWidget_);
|
||||
if (!scroll) return;
|
||||
lv_obj_scroll_to_y(scroll, LV_COORD_MAX, LV_ANIM_OFF);
|
||||
lv_coord_t sy = lv_obj_get_scroll_y(scroll);
|
||||
self->pageOffset_ = (sy > 0) ? (size_t)sy : 0;
|
||||
self->saveProgress();
|
||||
}
|
||||
|
||||
// Snap a scroll step down to the nearest whole-line multiple so page turns
|
||||
// always land on a clean line boundary (no partial lines at top or bottom).
|
||||
// Uses the actual LVGL font line_height rather than hardcoded estimates.
|
||||
static lv_coord_t snapStep(lv_coord_t viewH) {
|
||||
lv_coord_t lineH = (lv_coord_t)lv_font_get_line_height(selectContentFont());
|
||||
lv_coord_t step = (lineH > 0) ? (viewH / lineH) * lineH : viewH;
|
||||
return (step > 0) ? step : viewH; // fallback: scroll full height if tiny display
|
||||
}
|
||||
|
||||
// Both text and EPUB modes use the same scroll-by-viewport-height approach.
|
||||
// snapStep ensures each page turn is a whole-line multiple, so - provided all
|
||||
// paragraph label Y positions are also multiples of lineH (zero label padding +
|
||||
// pad_row=lineH on contentWidget_) - pages always start on a clean line boundary.
|
||||
// At chapter boundaries (EPUB only) the adjacent chapter is loaded.
|
||||
void EpubReader::doPrev() {
|
||||
if (!contentWidget_) return;
|
||||
lv_obj_t* scroll = lv_obj_get_parent(contentWidget_);
|
||||
if (!scroll) return;
|
||||
lv_coord_t curY = lv_obj_get_scroll_y(scroll);
|
||||
lv_coord_t step = snapStep(lv_obj_get_height(scroll));
|
||||
lv_obj_scroll_to_y(scroll, curY > step ? curY - step : 0, LV_ANIM_OFF);
|
||||
lv_coord_t newY = lv_obj_get_scroll_y(scroll);
|
||||
if (newY == curY && !textMode_) {
|
||||
// Scroll didn't move - already at the top; cross into the previous chapter.
|
||||
if (currentSpineIndex_ > 0) loadChapter(currentSpineIndex_ - 1, -1);
|
||||
} else {
|
||||
pageOffset_ = (newY > 0) ? (size_t)newY : 0;
|
||||
saveProgress();
|
||||
}
|
||||
}
|
||||
|
||||
void EpubReader::doNext() {
|
||||
if (!contentWidget_) return;
|
||||
lv_obj_t* scroll = lv_obj_get_parent(contentWidget_);
|
||||
if (!scroll) return;
|
||||
lv_coord_t curY = lv_obj_get_scroll_y(scroll);
|
||||
lv_coord_t step = snapStep(lv_obj_get_height(scroll));
|
||||
lv_obj_scroll_to_y(scroll, curY + step, LV_ANIM_OFF);
|
||||
lv_coord_t newY = lv_obj_get_scroll_y(scroll);
|
||||
if (newY == curY && !textMode_) {
|
||||
// Scroll position didn't move - content fits in the viewport or we've reached
|
||||
// the end. lv_obj_get_scroll_bottom() returns negative for short chapters so
|
||||
// checking == 0 is unreliable; this approach works for all chapter lengths.
|
||||
loadChapter(currentSpineIndex_ + 1, +1);
|
||||
} else {
|
||||
pageOffset_ = (newY > 0) ? (size_t)newY : 0;
|
||||
saveProgress();
|
||||
}
|
||||
}
|
||||
|
||||
void EpubReader::onPrevPressed(lv_event_t* e) {
|
||||
static_cast<EpubReader*>(lv_event_get_user_data(e))->doPrev();
|
||||
}
|
||||
|
||||
void EpubReader::onNextPressed(lv_event_t* e) {
|
||||
static_cast<EpubReader*>(lv_event_get_user_data(e))->doNext();
|
||||
}
|
||||
|
||||
void EpubReader::onReaderTap(lv_event_t* e) {
|
||||
auto* self = static_cast<EpubReader*>(lv_event_get_user_data(e));
|
||||
lv_indev_t* indev = lv_indev_active();
|
||||
if (!indev) return;
|
||||
lv_point_t pt;
|
||||
lv_indev_get_point(indev, &pt);
|
||||
lv_coord_t w = lv_display_get_horizontal_resolution(nullptr);
|
||||
if (pt.x < w / 2) self->doPrev();
|
||||
else self->doNext();
|
||||
}
|
||||
|
||||
void EpubReader::onTocPressed(lv_event_t* e) {
|
||||
auto* self = static_cast<EpubReader*>(lv_event_get_user_data(e));
|
||||
self->openTocDialog();
|
||||
}
|
||||
|
||||
void EpubReader::onBrowsePressed(lv_event_t* e) {
|
||||
auto* self = static_cast<EpubReader*>(lv_event_get_user_data(e));
|
||||
lv_async_call(asyncSwitchToBrowser, self);
|
||||
}
|
||||
|
||||
void EpubReader::onBrowserBack(lv_event_t* e) {
|
||||
auto* self = static_cast<EpubReader*>(lv_event_get_user_data(e));
|
||||
size_t pos = self->browsePath_.rfind('/');
|
||||
if (pos != std::string::npos && self->browsePath_ != self->dataRoot_) {
|
||||
self->browsePath_ = self->browsePath_.substr(0, pos);
|
||||
}
|
||||
lv_async_call(asyncNavigateBrowser, self);
|
||||
}
|
||||
|
||||
void EpubReader::onBrowserItem(lv_event_t* e) {
|
||||
auto* self = static_cast<EpubReader*>(lv_event_get_user_data(e));
|
||||
uintptr_t idx = (uintptr_t)lv_obj_get_user_data(lv_event_get_target_obj(e));
|
||||
if (idx >= self->browserEntries_.size()) return;
|
||||
|
||||
const auto& [name, isDir] = self->browserEntries_[idx];
|
||||
if (isDir) {
|
||||
self->browsePath_ += "/" + name;
|
||||
lv_async_call(asyncNavigateBrowser, self);
|
||||
} else {
|
||||
self->pendingFilePath_ = self->browsePath_ + "/" + name;
|
||||
lv_async_call(asyncOpenEpub, self);
|
||||
}
|
||||
}
|
||||
|
||||
// "Use Folder" toolbar button - save the current browsePath_ as the books folder.
|
||||
void EpubReader::onSetBooksFolder(lv_event_t* e) {
|
||||
auto* self = static_cast<EpubReader*>(lv_event_get_user_data(e));
|
||||
self->booksPath_ = self->browsePath_;
|
||||
self->saveBooksPath();
|
||||
lv_async_call(asyncNavigateBrowser, self);
|
||||
}
|
||||
|
||||
// Shelf page navigation callbacks
|
||||
void EpubReader::onShelfFirst(lv_event_t* e) {
|
||||
auto* self = static_cast<EpubReader*>(lv_event_get_user_data(e));
|
||||
self->shelfPage_ = 0;
|
||||
lv_async_call(asyncNavigateBrowser, self);
|
||||
}
|
||||
|
||||
void EpubReader::onShelfPrev(lv_event_t* e) {
|
||||
auto* self = static_cast<EpubReader*>(lv_event_get_user_data(e));
|
||||
if (self->shelfPage_ > 0) --self->shelfPage_;
|
||||
lv_async_call(asyncNavigateBrowser, self);
|
||||
}
|
||||
|
||||
void EpubReader::onShelfNext(lv_event_t* e) {
|
||||
auto* self = static_cast<EpubReader*>(lv_event_get_user_data(e));
|
||||
++self->shelfPage_; // clamped in buildShelfUI
|
||||
lv_async_call(asyncNavigateBrowser, self);
|
||||
}
|
||||
|
||||
void EpubReader::onShelfLast(lv_event_t* e) {
|
||||
auto* self = static_cast<EpubReader*>(lv_event_get_user_data(e));
|
||||
self->shelfPage_ = INT_MAX; // clamped to totalPages-1 in buildShelfUI
|
||||
lv_async_call(asyncNavigateBrowser, self);
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
#include "EpubReader.h"
|
||||
#include <tt_lvgl_toolbar.h>
|
||||
#include <tactility/log.h>
|
||||
#include <esp_heap_caps.h>
|
||||
#include <dirent.h>
|
||||
#include <sys/stat.h>
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
static const char* TAG = "EpubReader";
|
||||
|
||||
// Runtime-loaded Noto Serif fonts - 4 variants for the active display size tier.
|
||||
// Loaded by EpubReader::loadFonts() on onShow, freed by unloadFonts() on onHide
|
||||
// (skipped when onHide fires for a dialog roundtrip - tocDialogId_ will be non-zero).
|
||||
// lv_binfont_create/destroy are exported by the firmware's lvgl-module symbols.
|
||||
// NOTE: These are file-scope statics intentionally. Tactility runs one instance of
|
||||
// each app at a time, so there is no multi-instance aliasing or use-after-free risk.
|
||||
static lv_font_t* s_fontRegular = nullptr;
|
||||
static lv_font_t* s_fontItalic = nullptr;
|
||||
static lv_font_t* s_fontBold = nullptr;
|
||||
static lv_font_t* s_fontBoldItalic = nullptr;
|
||||
|
||||
#define LVGL_SYMBOL_BOOK "\xEF\x94\xBE" // U+F53E (MaterialSymbols: book_2)
|
||||
#define LVGL_SYMBOL_TEXT "\xEF\x87\x86" // U+F1C6 (MaterialSymbols: text_snippet)
|
||||
|
||||
// lv_list_add_btn places the icon image at child 0 and the text label at child 1.
|
||||
// Centralise that assumption here so there's one place to update if LVGL changes.
|
||||
static void setListBtnLongMode(lv_obj_t* btn, lv_label_long_mode_t mode) {
|
||||
lv_obj_t* lbl = lv_obj_get_child(btn, 1);
|
||||
if (lbl) lv_label_set_long_mode(lbl, mode);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Toolbar helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void EpubReader::setReaderToolbarButtons() {
|
||||
tt_lvgl_toolbar_clear_actions(toolbar_);
|
||||
tt_lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_PREV, onPrevPressed, this);
|
||||
if (!textMode_) {
|
||||
tt_lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_LIST, onTocPressed, this);
|
||||
}
|
||||
tt_lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_NEXT, onNextPressed, this);
|
||||
tt_lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_DIRECTORY, onBrowsePressed, this);
|
||||
}
|
||||
|
||||
void EpubReader::setBrowserToolbarButtons() {
|
||||
tt_lvgl_toolbar_clear_actions(toolbar_);
|
||||
// Show "Use Folder" button when the current browse path isn't already the saved books folder
|
||||
if (browsePath_ != booksPath_) {
|
||||
tt_lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_DIRECTORY, onSetBooksFolder, this);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static const lv_font_t* selectIconFont() {
|
||||
lv_coord_t w = lv_display_get_horizontal_resolution(nullptr);
|
||||
lv_coord_t h = lv_display_get_vertical_resolution(nullptr);
|
||||
bool isLarge = w >= 480 || h >= 320;
|
||||
return isLarge ? &material_symbols_shared_32 : &material_symbols_shared_20;
|
||||
}
|
||||
|
||||
const lv_font_t* selectContentFont(bool italic, bool bold) {
|
||||
const lv_font_t* f = bold ? (italic ? s_fontBoldItalic : s_fontBold)
|
||||
: (italic ? s_fontItalic : s_fontRegular);
|
||||
if (f) return f;
|
||||
// Variant not loaded (e.g. no-PSRAM device only loads regular) - fall back gracefully.
|
||||
return s_fontRegular ? s_fontRegular : lv_font_get_default();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Font lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void EpubReader::loadFonts() {
|
||||
if (s_fontRegular) return; // already loaded
|
||||
|
||||
// Without PSRAM the heap is too constrained to hold even a single binary font
|
||||
// alongside the epub parser and LVGL allocations - skip loading entirely.
|
||||
// onShow() will have already shown an alert dialog for this case.
|
||||
if (heap_caps_get_total_size(MALLOC_CAP_SPIRAM) == 0) return;
|
||||
|
||||
// Verify the assets directory exists before attempting individual file loads.
|
||||
// This produces one clear diagnostic instead of one error per font file.
|
||||
char assetsDir[256]; size_t dirSz = sizeof(assetsDir);
|
||||
tt_app_get_assets_path(appHandle_, assetsDir, &dirSz);
|
||||
if (dirSz == 0) {
|
||||
LOG_E(TAG, "loadFonts: could not resolve assets path");
|
||||
return;
|
||||
}
|
||||
struct stat st;
|
||||
if (stat(assetsDir, &st) != 0 || !S_ISDIR(st.st_mode)) {
|
||||
LOG_W(TAG, "loadFonts: assets directory not found: %s", assetsDir);
|
||||
return;
|
||||
}
|
||||
|
||||
lv_coord_t w = lv_display_get_horizontal_resolution(nullptr);
|
||||
lv_coord_t h = lv_display_get_vertical_resolution(nullptr);
|
||||
const char* sz;
|
||||
if (w >= 1000 || h >= 1000) sz = "36";
|
||||
else if (w >= 600 || h >= 480 ) sz = "28";
|
||||
else if (w >= 400 || h >= 300 ) sz = "20";
|
||||
else sz = "16";
|
||||
|
||||
struct Variant { const char* suffix; lv_font_t** ptr; };
|
||||
Variant variants[] = {
|
||||
{ "regular", &s_fontRegular },
|
||||
{ "italic", &s_fontItalic },
|
||||
{ "bold", &s_fontBold },
|
||||
{ "bold_italic", &s_fontBoldItalic },
|
||||
};
|
||||
for (auto& v : variants) {
|
||||
char filename[64];
|
||||
snprintf(filename, sizeof(filename), "font_%spt_%s.bin", sz, v.suffix);
|
||||
char assetPath[256]; size_t pathSz = sizeof(assetPath);
|
||||
tt_app_get_assets_child_path(appHandle_, filename, assetPath, &pathSz);
|
||||
if (pathSz == 0) {
|
||||
LOG_E(TAG, "loadFonts: no asset path for %s", filename);
|
||||
continue;
|
||||
}
|
||||
// LVGL STDIO driver letter 'A' with empty path prefix: "A:/foo" → fopen("/foo")
|
||||
std::string lvglPath = std::string("A:") + assetPath;
|
||||
*v.ptr = lv_binfont_create(lvglPath.c_str());
|
||||
if (!*v.ptr) LOG_E(TAG, "lv_binfont_create failed: %s", assetPath);
|
||||
else LOG_I(TAG, "Font loaded: %s (%dpt)", v.suffix, atoi(sz));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void EpubReader::unloadFonts() {
|
||||
lv_font_t** ptrs[] = { &s_fontRegular, &s_fontItalic, &s_fontBold, &s_fontBoldItalic };
|
||||
for (auto* p : ptrs) {
|
||||
if (*p) { lv_binfont_destroy(*p); *p = nullptr; }
|
||||
}
|
||||
}
|
||||
|
||||
void EpubReader::buildReaderUI(lv_obj_t* parent) {
|
||||
lv_obj_t* scroll = lv_obj_create(parent);
|
||||
lv_obj_set_width(scroll, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(scroll, 1);
|
||||
lv_obj_set_style_pad_all(scroll, 6, 0);
|
||||
lv_obj_set_style_border_width(scroll, 0, 0);
|
||||
|
||||
// Tap left half = prev, right half = next.
|
||||
// LV_EVENT_CLICKED only fires on press+release with minimal movement,
|
||||
// so it won't conflict with vertical scrolling in text mode.
|
||||
lv_obj_add_event_cb(scroll, onReaderTap, LV_EVENT_CLICKED, this);
|
||||
|
||||
if (textMode_) {
|
||||
// Plain text file: single label, Prev/Next scroll by screen height.
|
||||
contentWidget_ = lv_label_create(scroll);
|
||||
lv_obj_set_width(contentWidget_, LV_PCT(100));
|
||||
lv_label_set_long_mode(contentWidget_, LV_LABEL_LONG_MODE_WRAP);
|
||||
lv_obj_set_style_text_font(contentWidget_, selectContentFont(false, false), 0);
|
||||
lv_label_set_text(contentWidget_, pageContent_.c_str());
|
||||
lv_obj_scroll_to_y(scroll, (lv_coord_t)pageOffset_, LV_ANIM_OFF);
|
||||
saveProgress();
|
||||
} else if (epub_) {
|
||||
// EPUB: transparent flex-column container; renderPage() fills it with
|
||||
// per-paragraph labels (one per paragraph, with individual alignment).
|
||||
contentWidget_ = lv_obj_create(scroll);
|
||||
lv_obj_set_width(contentWidget_, LV_PCT(100));
|
||||
lv_obj_set_height(contentWidget_, LV_SIZE_CONTENT);
|
||||
lv_obj_set_flex_flow(contentWidget_, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_all(contentWidget_, 0, 0);
|
||||
// One line-height of gap between paragraphs keeps all paragraph tops on
|
||||
// exact lineH-multiple boundaries - required for snapStep to work cleanly.
|
||||
lv_obj_set_style_pad_row(contentWidget_,
|
||||
(lv_coord_t)lv_font_get_line_height(selectContentFont(false, false)), 0);
|
||||
lv_obj_set_style_border_width(contentWidget_, 0, 0);
|
||||
lv_obj_set_style_bg_opa(contentWidget_, LV_OPA_TRANSP, 0);
|
||||
lv_obj_remove_flag(contentWidget_, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_remove_flag(contentWidget_, LV_OBJ_FLAG_CLICKABLE);
|
||||
loadChapter(currentSpineIndex_, +1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// renderSlice helpers - inline bold/italic via lv_spangroup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Returns true if the text segment contains any inline bold/italic ESC tokens.
|
||||
static bool hasInlineTokens(const char* s, size_t len) {
|
||||
for (size_t i = 0; i + 1 < len; ++i) {
|
||||
if ((unsigned char)s[i] == 0x1B) {
|
||||
char t = s[i + 1];
|
||||
if (t == 'B' || t == 'b' || t == 'I' || t == 'i') return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Builds an lv_spangroup for a paragraph containing inline bold/italic ESC tokens.
|
||||
// Each ESC+B/b/I/i boundary becomes a new span with the appropriate font.
|
||||
static void renderSpanParagraph(lv_obj_t* parent, const char* seg, size_t len,
|
||||
lv_text_align_t align) {
|
||||
lv_obj_t* sg = lv_spangroup_create(parent);
|
||||
lv_obj_set_width(sg, LV_PCT(100));
|
||||
lv_obj_set_height(sg, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(sg, 0, 0); // keep heights = N*lineH (same rule as lv_label)
|
||||
lv_spangroup_set_align(sg, align);
|
||||
lv_spangroup_set_mode(sg, LV_SPAN_MODE_BREAK); // wrap to content height
|
||||
lv_obj_remove_flag(sg, LV_OBJ_FLAG_CLICKABLE);
|
||||
|
||||
bool bold = false, italic = false;
|
||||
std::string run;
|
||||
run.reserve(len);
|
||||
|
||||
auto flushRun = [&]() {
|
||||
if (run.empty()) return;
|
||||
lv_span_t* span = lv_spangroup_add_span(sg);
|
||||
lv_span_set_text(span, run.c_str());
|
||||
lv_style_set_text_font(lv_span_get_style(span), selectContentFont(italic, bold));
|
||||
run.clear();
|
||||
};
|
||||
|
||||
for (size_t i = 0; i < len; ++i) {
|
||||
unsigned char c = (unsigned char)seg[i];
|
||||
if (c == 0x1B && i + 1 < len) {
|
||||
char tok = seg[i + 1];
|
||||
if (tok == 'B') { flushRun(); bold = true; ++i; continue; }
|
||||
if (tok == 'b') { flushRun(); bold = false; ++i; continue; }
|
||||
if (tok == 'I') { flushRun(); italic = true; ++i; continue; }
|
||||
if (tok == 'i') { flushRun(); italic = false; ++i; continue; }
|
||||
}
|
||||
run += (char)c;
|
||||
}
|
||||
flushRun();
|
||||
lv_spangroup_refresh(sg);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// renderSlice - parse ESC-encoded text into per-paragraph LVGL widgets
|
||||
// ---------------------------------------------------------------------------
|
||||
// ESC token protocol (see HtmlStrip.h):
|
||||
// Every paragraph starts with ESC + 'L'|'C'|'R' (alignment).
|
||||
// Paragraphs are separated by '\n\n'.
|
||||
// Single '\n' = line break within a paragraph.
|
||||
// Inline ESC+'B'/'b'/'I'/'i' tokens delimit bold/italic runs.
|
||||
//
|
||||
// Paragraphs without inline tokens → lv_label (lighter, fewer allocations).
|
||||
// Paragraphs with inline tokens → lv_spangroup (per-span font selection).
|
||||
void EpubReader::renderSlice(const std::string& slice) {
|
||||
if (!contentWidget_) return;
|
||||
|
||||
const lv_font_t* font = selectContentFont(false, false);
|
||||
size_t pos = 0;
|
||||
const size_t len = slice.size();
|
||||
|
||||
while (pos < len) {
|
||||
// Find the next paragraph boundary (\n\n) or end of string
|
||||
size_t nlnl = slice.find("\n\n", pos);
|
||||
size_t paraEnd = (nlnl != std::string::npos) ? nlnl : len;
|
||||
|
||||
// Extract alignment from leading ESC token
|
||||
lv_text_align_t align = LV_TEXT_ALIGN_LEFT;
|
||||
size_t textStart = pos;
|
||||
if (paraEnd > pos + 1 && (unsigned char)slice[pos] == 0x1B) {
|
||||
char code = slice[pos + 1];
|
||||
if (code == 'C') align = LV_TEXT_ALIGN_CENTER;
|
||||
else if (code == 'R') align = LV_TEXT_ALIGN_RIGHT;
|
||||
textStart = pos + 2;
|
||||
}
|
||||
|
||||
const char* seg = slice.data() + textStart;
|
||||
size_t segLen = paraEnd - textStart;
|
||||
|
||||
// Skip blank paragraphs
|
||||
bool hasContent = false;
|
||||
for (size_t k = 0; k < segLen; ++k) {
|
||||
char ch = seg[k];
|
||||
if (ch != ' ' && ch != '\n') { hasContent = true; break; }
|
||||
}
|
||||
|
||||
if (hasContent) {
|
||||
if (hasInlineTokens(seg, segLen)) {
|
||||
// Mixed-style paragraph: spangroup renders inline bold/italic runs
|
||||
renderSpanParagraph(contentWidget_, seg, segLen, align);
|
||||
} else {
|
||||
// Plain paragraph: lv_label (simpler, lower memory overhead)
|
||||
lv_obj_t* lbl = lv_label_create(contentWidget_);
|
||||
lv_obj_set_width(lbl, LV_PCT(100));
|
||||
lv_obj_set_style_pad_all(lbl, 0, 0); // override theme padding; keeps heights = N*lineH
|
||||
lv_label_set_long_mode(lbl, LV_LABEL_LONG_MODE_WRAP);
|
||||
lv_obj_set_style_text_font(lbl, font, 0);
|
||||
lv_obj_set_style_text_align(lbl, align, 0);
|
||||
lv_label_set_text(lbl, std::string(seg, segLen).c_str());
|
||||
}
|
||||
}
|
||||
|
||||
pos = (nlnl != std::string::npos) ? nlnl + 2 : len;
|
||||
}
|
||||
}
|
||||
|
||||
void EpubReader::buildBrowserUI(lv_obj_t* parent) {
|
||||
// When at the configured books folder, show the shelf instead of the file list
|
||||
if (!booksPath_.empty() && browsePath_ == booksPath_) {
|
||||
buildShelfUI(parent);
|
||||
return;
|
||||
}
|
||||
|
||||
// Path bar
|
||||
lv_obj_t* pathBar = lv_obj_create(parent);
|
||||
lv_obj_set_size(pathBar, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_flex_flow(pathBar, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(pathBar, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_all(pathBar, 4, 0);
|
||||
lv_obj_set_style_pad_gap(pathBar, 4, 0);
|
||||
lv_obj_set_style_border_width(pathBar, 0, 0);
|
||||
lv_obj_set_style_bg_opa(pathBar, LV_OPA_TRANSP, 0);
|
||||
lv_obj_remove_flag(pathBar, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
if (browsePath_ != dataRoot_) {
|
||||
lv_obj_t* backBtn = lv_button_create(pathBar);
|
||||
lv_obj_set_size(backBtn, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(backBtn, 4, 0);
|
||||
lv_label_set_text(lv_label_create(backBtn), LV_SYMBOL_LEFT " Back");
|
||||
lv_obj_add_event_cb(backBtn, onBrowserBack, LV_EVENT_CLICKED, this);
|
||||
}
|
||||
|
||||
lv_obj_t* pathLabel = lv_label_create(pathBar);
|
||||
lv_obj_set_flex_grow(pathLabel, 1);
|
||||
lv_label_set_text(pathLabel, browsePath_.c_str());
|
||||
lv_label_set_long_mode(pathLabel, LV_LABEL_LONG_MODE_DOTS);//LV_LABEL_LONG_MODE_SCROLL_CIRCULAR
|
||||
|
||||
// File list
|
||||
lv_obj_t* list = lv_list_create(parent);
|
||||
lv_obj_set_width(list, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(list, 1);
|
||||
lv_obj_set_style_border_width(list, 0, 0);
|
||||
|
||||
// Scan directory
|
||||
browserEntries_.clear();
|
||||
DIR* dir = opendir(browsePath_.c_str());
|
||||
if (dir) {
|
||||
struct dirent* entry;
|
||||
while ((entry = readdir(dir)) != nullptr) {
|
||||
if (entry->d_name[0] == '.') continue;
|
||||
std::string name(entry->d_name);
|
||||
bool isDir = (entry->d_type == DT_DIR);
|
||||
if (entry->d_type == DT_UNKNOWN) {
|
||||
struct stat st;
|
||||
std::string fullPath = browsePath_ + "/" + name;
|
||||
if (stat(fullPath.c_str(), &st) == 0) {
|
||||
isDir = S_ISDIR(st.st_mode);
|
||||
}
|
||||
}
|
||||
if (isDir || isSupportedFile(name)) {
|
||||
browserEntries_.push_back({name, isDir});
|
||||
}
|
||||
}
|
||||
closedir(dir);
|
||||
} else {
|
||||
LOG_W(TAG, "Cannot open dir: %s", browsePath_.c_str());
|
||||
}
|
||||
|
||||
// Sort: directories first, then alphabetically within each group
|
||||
std::sort(browserEntries_.begin(), browserEntries_.end(),
|
||||
[](const auto& a, const auto& b) {
|
||||
if (a.second != b.second) return a.second > b.second;
|
||||
return a.first < b.first;
|
||||
});
|
||||
|
||||
if (browserEntries_.empty()) {
|
||||
lv_list_add_text(list, "No supported files found.");
|
||||
} else {
|
||||
for (size_t i = 0; i < browserEntries_.size(); ++i) {
|
||||
const auto& [name, isDir] = browserEntries_[i];
|
||||
const char* icon = isDir ? LV_SYMBOL_DIRECTORY : LV_SYMBOL_FILE;
|
||||
lv_obj_t* btn = lv_list_add_button(list, icon, name.c_str());
|
||||
lv_obj_set_user_data(btn, (void*)(uintptr_t)i);
|
||||
lv_obj_add_event_cb(btn, onBrowserItem, LV_EVENT_CLICKED, this);
|
||||
setListBtnLongMode(btn, LV_LABEL_LONG_MODE_DOTS);//LV_LABEL_LONG_MODE_SCROLL_CIRCULAR
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively scans path for epub/txt files, appending entries to browserEntries_.
|
||||
// Called with prefix="" for booksPath_; recurses one level into subdirectories.
|
||||
void EpubReader::scanBooksDir(const std::string& path, const std::string& prefix) {
|
||||
DIR* d = opendir(path.c_str());
|
||||
if (!d) return;
|
||||
struct dirent* ent;
|
||||
while ((ent = readdir(d)) != nullptr) {
|
||||
if (ent->d_name[0] == '.') continue;
|
||||
std::string name(ent->d_name);
|
||||
bool isDir = (ent->d_type == DT_DIR);
|
||||
if (ent->d_type == DT_UNKNOWN) {
|
||||
struct stat st;
|
||||
if (stat((path + "/" + name).c_str(), &st) == 0)
|
||||
isDir = S_ISDIR(st.st_mode);
|
||||
}
|
||||
if (isDir && prefix.empty()) {
|
||||
scanBooksDir(path + "/" + name, name + "/");
|
||||
} else if (!isDir) {
|
||||
auto p = name.rfind('.');
|
||||
if (p == std::string::npos) continue;
|
||||
std::string e = name.substr(p);
|
||||
std::transform(e.begin(), e.end(), e.begin(), ::tolower);
|
||||
if (e == ".epub" || e == ".txt")
|
||||
browserEntries_.push_back({prefix + name, false});
|
||||
}
|
||||
}
|
||||
closedir(d);
|
||||
}
|
||||
|
||||
// Books shelf view - shown when browsePath_ == booksPath_.
|
||||
// Scans booksPath_ and one level of subdirectories into a single flat sorted list.
|
||||
void EpubReader::buildShelfUI(lv_obj_t* parent) {
|
||||
lv_coord_t dispW = lv_display_get_horizontal_resolution(nullptr);
|
||||
lv_coord_t dispH = lv_display_get_vertical_resolution(nullptr);
|
||||
bool isLarge = (dispW >= 480 || dispH >= 480);
|
||||
bool isXLarge = (dispW >= 600 || dispH >= 600);
|
||||
|
||||
int pageSize = isXLarge ? 8 : (isLarge ? 6 : 4);
|
||||
int padBar = isLarge ? 4 : 2;
|
||||
int navPadHor = isLarge ? 12 : 6;
|
||||
int navPadVer = isLarge ? 6 : 3;
|
||||
|
||||
// Optional path bar with Back so the user can navigate away from the shelf
|
||||
if (booksPath_ != dataRoot_) {
|
||||
lv_obj_t* pathBar = lv_obj_create(parent);
|
||||
lv_obj_set_size(pathBar, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_flex_flow(pathBar, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(pathBar, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_all(pathBar, padBar, 0);
|
||||
lv_obj_set_style_pad_gap(pathBar, padBar, 0);
|
||||
lv_obj_set_style_border_width(pathBar, 0, 0);
|
||||
lv_obj_set_style_bg_opa(pathBar, LV_OPA_TRANSP, 0);
|
||||
lv_obj_remove_flag(pathBar, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
lv_obj_t* backBtn = lv_button_create(pathBar);
|
||||
lv_obj_set_size(backBtn, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(backBtn, padBar, 0);
|
||||
lv_label_set_text(lv_label_create(backBtn), LV_SYMBOL_LEFT " Browse");
|
||||
lv_obj_add_event_cb(backBtn, onBrowserBack, LV_EVENT_CLICKED, this);
|
||||
|
||||
lv_obj_t* pathLbl = lv_label_create(pathBar);
|
||||
lv_obj_set_flex_grow(pathLbl, 1);
|
||||
lv_label_set_long_mode(pathLbl, LV_LABEL_LONG_MODE_DOTS);//LV_LABEL_LONG_MODE_SCROLL_CIRCULAR
|
||||
lv_label_set_text(pathLbl, booksPath_.c_str());
|
||||
}
|
||||
|
||||
// Scan booksPath_ for books, and one level of subdirectories into a flat list.
|
||||
// Subfolder entries are stored as "subfolder/filename" so onBrowserItem builds
|
||||
// the correct full path via browsePath_ + "/" + name.
|
||||
browserEntries_.clear();
|
||||
scanBooksDir(booksPath_, "");
|
||||
|
||||
// Sort alphabetically by filename (ignoring subfolder prefix)
|
||||
std::sort(browserEntries_.begin(), browserEntries_.end(),
|
||||
[](const auto& a, const auto& b) {
|
||||
// rfind returns npos if no '/' found; npos+1 wraps to 0, yielding the full string
|
||||
auto nameA = a.first.substr(a.first.rfind('/') + 1);
|
||||
auto nameB = b.first.substr(b.first.rfind('/') + 1);
|
||||
return std::lexicographical_compare(
|
||||
nameA.begin(), nameA.end(),
|
||||
nameB.begin(), nameB.end(),
|
||||
[](char ca, char cb) { return std::tolower((unsigned char)ca) < std::tolower((unsigned char)cb); });
|
||||
});
|
||||
|
||||
// Clamp shelfPage_ to valid range
|
||||
int totalItems = (int)browserEntries_.size();
|
||||
int totalPages = std::max(1, (totalItems + pageSize - 1) / pageSize);
|
||||
shelfPage_ = std::max(0, std::min(shelfPage_, totalPages - 1));
|
||||
int startIdx = shelfPage_ * pageSize;
|
||||
int endIdx = std::min(startIdx + pageSize, totalItems);
|
||||
|
||||
// Non-scrollable list; items share height equally via flex_grow=1
|
||||
lv_obj_t* list = lv_list_create(parent);
|
||||
lv_obj_set_width(list, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(list, 1);
|
||||
lv_obj_set_style_border_width(list, 0, 0);
|
||||
lv_obj_remove_flag(list, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
if (browserEntries_.empty()) {
|
||||
lv_list_add_text(list, "No books found in books folder.");
|
||||
} else {
|
||||
for (int i = startIdx; i < endIdx; ++i) {
|
||||
const std::string& entry = browserEntries_[(size_t)i].first;
|
||||
// Display name: strip subfolder prefix and extension
|
||||
std::string displayName = entry.substr(entry.rfind('/') + 1);
|
||||
auto dot = displayName.rfind('.');
|
||||
if (dot != std::string::npos) displayName = displayName.substr(0, dot);
|
||||
|
||||
auto dotPos = entry.rfind('.');
|
||||
std::string ext = (dotPos != std::string::npos) ? entry.substr(dotPos) : "";
|
||||
std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower);
|
||||
const char* icon = (ext == ".txt") ? LVGL_SYMBOL_TEXT : LVGL_SYMBOL_BOOK;
|
||||
|
||||
lv_obj_t* btn = lv_list_add_button(list, icon, displayName.c_str());
|
||||
lv_obj_set_flex_grow(btn, 1); // equal height share across page items
|
||||
lv_obj_set_user_data(btn, (void*)(uintptr_t)i);
|
||||
lv_obj_add_event_cb(btn, onBrowserItem, LV_EVENT_CLICKED, this);
|
||||
setListBtnLongMode(btn, LV_LABEL_LONG_MODE_DOTS);//LV_LABEL_LONG_MODE_SCROLL_CIRCULAR
|
||||
lv_obj_t* iconLbl = lv_obj_get_child(btn, 0);
|
||||
if (iconLbl) lv_obj_set_style_text_font(iconLbl, selectIconFont(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bottom navigation bar ─────────────────────────────────────────────────
|
||||
lv_obj_t* navBar = lv_obj_create(parent);
|
||||
lv_obj_set_size(navBar, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_flex_flow(navBar, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(navBar, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_all(navBar, padBar, 0);
|
||||
lv_obj_set_style_pad_gap(navBar, padBar, 0);
|
||||
lv_obj_set_style_border_width(navBar, 0, 0);
|
||||
lv_obj_set_style_bg_opa(navBar, LV_OPA_TRANSP, 0);
|
||||
lv_obj_remove_flag(navBar, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
bool canPrev = (shelfPage_ > 0);
|
||||
bool canNext = (shelfPage_ < totalPages - 1);
|
||||
|
||||
auto makeNavBtn = [&](const char* label, lv_event_cb_t cb, bool enabled) {
|
||||
lv_obj_t* btn = lv_button_create(navBar);
|
||||
lv_obj_set_size(btn, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_hor(btn, navPadHor, 0);
|
||||
lv_obj_set_style_pad_ver(btn, navPadVer, 0);
|
||||
lv_label_set_text(lv_label_create(btn), label);
|
||||
if (enabled) lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, this);
|
||||
else lv_obj_add_state(btn, LV_STATE_DISABLED);
|
||||
};
|
||||
|
||||
makeNavBtn(LV_SYMBOL_PREV, onShelfFirst, canPrev);
|
||||
makeNavBtn(LV_SYMBOL_LEFT, onShelfPrev, canPrev);
|
||||
|
||||
char pageBuf[24];
|
||||
snprintf(pageBuf, sizeof(pageBuf), "%d / %d", shelfPage_ + 1, totalPages);
|
||||
lv_obj_t* pageLbl = lv_label_create(navBar);
|
||||
lv_obj_set_style_pad_hor(pageLbl, 10, 0);
|
||||
lv_label_set_text(pageLbl, pageBuf);
|
||||
|
||||
makeNavBtn(LV_SYMBOL_RIGHT, onShelfNext, canNext);
|
||||
makeNavBtn(LV_SYMBOL_NEXT, onShelfLast, canNext);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
#include "EpubService.h"
|
||||
#include "SimpleXmlParser.h"
|
||||
#include <tactility/log.h>
|
||||
#include <map>
|
||||
|
||||
extern "C" {
|
||||
#include "epub_parser.h"
|
||||
}
|
||||
|
||||
static const char* TAG = "EpubService";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
std::string EpubService::pathParent(const std::string& path) {
|
||||
size_t pos = path.rfind('/');
|
||||
return (pos == std::string::npos) ? "" : path.substr(0, pos);
|
||||
}
|
||||
|
||||
// Join basePath + href and resolve any ".." or "." segments so EPUBs that use
|
||||
// relative paths like "../../Text/chapter1.xhtml" resolve correctly.
|
||||
static std::string normalizePath(const std::string& basePath, const std::string& href) {
|
||||
if (!href.empty() && href[0] == '/') return href; // already absolute
|
||||
|
||||
std::string combined = basePath.empty() ? href : basePath + "/" + href;
|
||||
|
||||
// Walk each slash-delimited segment and resolve ".." / "."
|
||||
std::string result;
|
||||
result.reserve(combined.size());
|
||||
size_t i = 0;
|
||||
while (i <= combined.size()) {
|
||||
size_t slash = combined.find('/', i);
|
||||
bool atEnd = (slash == std::string::npos);
|
||||
std::string seg = atEnd ? combined.substr(i) : combined.substr(i, slash - i);
|
||||
|
||||
if (seg == "..") {
|
||||
size_t last = result.rfind('/');
|
||||
if (last != std::string::npos) result.erase(last);
|
||||
else result.clear();
|
||||
} else if (!seg.empty() && seg != ".") {
|
||||
if (!result.empty()) result += '/';
|
||||
result += seg;
|
||||
}
|
||||
|
||||
if (atEnd) break;
|
||||
i = slash + 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
std::shared_ptr<EpubService> EpubService::open(const std::string& path) {
|
||||
epub_reader_c* reader = nullptr;
|
||||
epub_parser_error err = epub_open_c(path.c_str(), &reader);
|
||||
if (err != EPUB_OK) {
|
||||
LOG_E(TAG, "Failed to open epub: %s (err=%d)", path.c_str(), (int)err);
|
||||
return nullptr;
|
||||
}
|
||||
auto service = std::shared_ptr<EpubService>(new EpubService());
|
||||
service->reader_ = reader;
|
||||
service->parseContainer();
|
||||
service->parseContentOpf();
|
||||
|
||||
// Return nullptr if essential parsing failed
|
||||
if (service->spine_.empty()) {
|
||||
LOG_E(TAG, "EPUB initialization failed: no spine items");
|
||||
return nullptr;
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
EpubService::~EpubService() {
|
||||
if (reader_) {
|
||||
epub_close_c(reader_);
|
||||
reader_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
std::string EpubService::readFile(const std::string& filename, size_t maxBytes) const {
|
||||
if (!reader_) return "";
|
||||
epub_stream_context_c* stream = epub_stream_open_c(reader_, filename.c_str());
|
||||
if (!stream) {
|
||||
LOG_W(TAG, "File not found in epub: %s", filename.c_str());
|
||||
return "";
|
||||
}
|
||||
std::string result;
|
||||
// Small stack buffer - readFile sits deep in the LVGL call chain and 4 KB
|
||||
// eaten by a local array is enough to cause a stack overflow on some targets.
|
||||
char buffer[512];
|
||||
size_t readBytes;
|
||||
while ((readBytes = epub_stream_read_c(stream, buffer, sizeof(buffer))) > 0) {
|
||||
if (maxBytes > 0 && result.size() + readBytes > maxBytes) {
|
||||
result.append(buffer, maxBytes - result.size());
|
||||
break;
|
||||
}
|
||||
result.append(buffer, readBytes);
|
||||
}
|
||||
epub_stream_close_c(stream);
|
||||
|
||||
// If truncated at maxBytes, ensure we didn't split a UTF-8 multi-byte sequence.
|
||||
if (maxBytes > 0 && result.size() >= maxBytes) {
|
||||
size_t len = result.size();
|
||||
while (len > 0 && ((unsigned char)result[len - 1] & 0xC0) == 0x80) --len;
|
||||
if (len > 0) {
|
||||
unsigned char lead = (unsigned char)result[len - 1];
|
||||
size_t seqLen = (lead < 0x80) ? 1 : (lead < 0xE0) ? 2 : (lead < 0xF0) ? 3 : 4;
|
||||
if (len - 1 + seqLen > result.size()) result.resize(len - 1);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string EpubService::getMetadata(const std::string& key) const {
|
||||
auto it = metadata_.find(key);
|
||||
return (it != metadata_.end()) ? it->second : "";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void EpubService::parseContainer() {
|
||||
std::string xml = readFile("META-INF/container.xml", 65536); // 64 KB cap
|
||||
if (xml.empty()) {
|
||||
LOG_E(TAG, "container.xml missing");
|
||||
return;
|
||||
}
|
||||
SimpleXmlParser parser;
|
||||
if (!parser.openFromMemory(xml.c_str(), xml.size())) return;
|
||||
|
||||
while (parser.read()) {
|
||||
if (parser.getNodeType() == SimpleXmlParser::NodeType::Element &&
|
||||
parser.getName() == "rootfile") {
|
||||
contentOpfPath_ = parser.getAttribute("full-path");
|
||||
LOG_I(TAG, "OPF path: %s", contentOpfPath_.c_str());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void EpubService::parseContentOpf() {
|
||||
if (contentOpfPath_.empty()) return;
|
||||
|
||||
std::string xml = readFile(contentOpfPath_, 1048576); // 1 MB cap
|
||||
if (xml.empty()) {
|
||||
LOG_E(TAG, "OPF file empty: %s", contentOpfPath_.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
SimpleXmlParser parser;
|
||||
if (!parser.openFromMemory(xml.c_str(), xml.size())) return;
|
||||
|
||||
std::map<std::string, std::string> manifest;
|
||||
bool inMetadata = false, inManifest = false, inSpine = false;
|
||||
std::string lastTag;
|
||||
std::string tocId;
|
||||
std::string coverMetaId; // id from <meta name="cover" content="ID"/>
|
||||
|
||||
while (parser.read()) {
|
||||
auto nodeType = parser.getNodeType();
|
||||
auto name = parser.getName();
|
||||
|
||||
if (nodeType == SimpleXmlParser::NodeType::Element) {
|
||||
lastTag = name;
|
||||
if (name == "metadata") {
|
||||
inMetadata = true;
|
||||
} else if (name == "manifest") {
|
||||
inManifest = true;
|
||||
} else if (name == "spine") {
|
||||
inSpine = true;
|
||||
tocId = parser.getAttribute("toc");
|
||||
} else if (inMetadata && name == "meta") {
|
||||
// EPUB2: <meta name="cover" content="cover-item-id"/>
|
||||
if (parser.getAttribute("name") == "cover") {
|
||||
coverMetaId = parser.getAttribute("content");
|
||||
}
|
||||
} else if (inManifest && name == "item") {
|
||||
std::string id = parser.getAttribute("id");
|
||||
std::string href = parser.getAttribute("href");
|
||||
if (!id.empty() && !href.empty()) {
|
||||
manifest[id] = href;
|
||||
// EPUB3: <item properties="cover-image" .../>
|
||||
if (coverPath_.empty()) {
|
||||
std::string props = parser.getAttribute("properties");
|
||||
if (props.find("cover-image") != std::string::npos) {
|
||||
coverPath_ = href; // resolved below after baseDir is known
|
||||
}
|
||||
}
|
||||
// Common fallback ids
|
||||
if (coverPath_.empty() && (id == "cover" || id == "cover-image" || id == "cover-img")) {
|
||||
std::string mt = parser.getAttribute("media-type");
|
||||
if (mt.find("image") != std::string::npos) {
|
||||
coverPath_ = href;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (inSpine && name == "itemref") {
|
||||
std::string idref = parser.getAttribute("idref");
|
||||
if (!idref.empty()) {
|
||||
spine_.push_back({idref, ""});
|
||||
}
|
||||
}
|
||||
} else if (nodeType == SimpleXmlParser::NodeType::EndElement) {
|
||||
if (name == "metadata") inMetadata = false;
|
||||
else if (name == "manifest") inManifest = false;
|
||||
else if (name == "spine") inSpine = false;
|
||||
lastTag = "";
|
||||
} else if (nodeType == SimpleXmlParser::NodeType::Text && inMetadata) {
|
||||
if (lastTag == "dc:title") metadata_["title"] = parser.getText();
|
||||
else if (lastTag == "dc:creator") metadata_["creator"] = parser.getText();
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve spine hrefs from manifest (normalizePath handles "../" segments)
|
||||
std::string baseDir = pathParent(contentOpfPath_);
|
||||
for (auto& si : spine_) {
|
||||
auto it = manifest.find(si.idref);
|
||||
if (it != manifest.end()) {
|
||||
si.href = normalizePath(baseDir, it->second);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve cover path: EPUB2 <meta name="cover"> takes priority
|
||||
if (!coverMetaId.empty()) {
|
||||
auto it = manifest.find(coverMetaId);
|
||||
if (it != manifest.end()) coverPath_ = it->second;
|
||||
}
|
||||
if (!coverPath_.empty()) {
|
||||
coverPath_ = normalizePath(baseDir, coverPath_);
|
||||
LOG_I(TAG, "Cover: %s", coverPath_.c_str());
|
||||
}
|
||||
|
||||
LOG_I(TAG, "Spine items: %d", (int)spine_.size());
|
||||
|
||||
// Parse NCX table of contents
|
||||
if (!tocId.empty()) {
|
||||
auto it = manifest.find(tocId);
|
||||
if (it != manifest.end()) {
|
||||
parseTocNcx(normalizePath(baseDir, it->second));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void EpubService::parseTocNcx(const std::string& tocPath) {
|
||||
std::string xml = readFile(tocPath, 524288); // 512 KB cap
|
||||
if (xml.empty()) return;
|
||||
|
||||
SimpleXmlParser parser;
|
||||
if (!parser.openFromMemory(xml.c_str(), xml.size())) return;
|
||||
|
||||
std::string baseDir = pathParent(tocPath);
|
||||
bool inNavPoint = false, inNavLabel = false;
|
||||
std::string currentTitle, currentHref;
|
||||
|
||||
while (parser.read()) {
|
||||
auto nodeType = parser.getNodeType();
|
||||
auto name = parser.getName();
|
||||
|
||||
if (nodeType == SimpleXmlParser::NodeType::Element) {
|
||||
if (name == "navPoint") {
|
||||
inNavPoint = true;
|
||||
currentTitle.clear();
|
||||
currentHref.clear();
|
||||
} else if (name == "navLabel") {
|
||||
inNavLabel = true;
|
||||
} else if (name == "content" && inNavPoint) {
|
||||
currentHref = parser.getAttribute("src");
|
||||
}
|
||||
} else if (nodeType == SimpleXmlParser::NodeType::Text && inNavLabel) {
|
||||
currentTitle = parser.getText();
|
||||
} else if (nodeType == SimpleXmlParser::NodeType::EndElement) {
|
||||
if (name == "navPoint") {
|
||||
if (!currentTitle.empty() && !currentHref.empty()) {
|
||||
// Strip fragment identifier before normalizing
|
||||
std::string href = currentHref;
|
||||
size_t hashPos = href.find('#');
|
||||
if (hashPos != std::string::npos) href = href.substr(0, hashPos);
|
||||
if (!href.empty()) {
|
||||
toc_.push_back({currentTitle, normalizePath(baseDir, href)});
|
||||
}
|
||||
}
|
||||
inNavPoint = false;
|
||||
} else if (name == "navLabel") {
|
||||
inNavLabel = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LOG_I(TAG, "TOC items: %d", (int)toc_.size());
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
|
||||
// Forward-declare the C epub reader handle so we don't need to pull in the C header
|
||||
struct epub_reader_c;
|
||||
|
||||
/**
|
||||
* EpubService - handles opening and reading EPUB archive files.
|
||||
* Parses the OPF manifest, spine, and NCX table of contents.
|
||||
* Use open() to create an instance; check isValid() before use.
|
||||
*/
|
||||
class EpubService {
|
||||
public:
|
||||
struct SpineItem {
|
||||
std::string idref;
|
||||
std::string href;
|
||||
};
|
||||
|
||||
struct TocItem {
|
||||
std::string title;
|
||||
std::string href;
|
||||
};
|
||||
|
||||
/**
|
||||
* Open an EPUB file. Returns nullptr on failure.
|
||||
*/
|
||||
static std::shared_ptr<EpubService> open(const std::string& path);
|
||||
~EpubService();
|
||||
|
||||
bool isValid() const { return reader_ != nullptr && !spine_.empty(); }
|
||||
|
||||
const std::vector<SpineItem>& getSpine() const { return spine_; }
|
||||
const std::vector<TocItem>& getToc() const { return toc_; }
|
||||
|
||||
/** Path of the cover image inside the EPUB archive, or "" if not found.
|
||||
* Typically a .jpg or .png. Use readFile() to get the raw bytes. */
|
||||
const std::string& getCoverPath() const { return coverPath_; }
|
||||
|
||||
/** Read a file from the EPUB archive into a string. Returns "" on error.
|
||||
* If maxBytes > 0, reading stops after that many bytes (useful for large chapter HTML). */
|
||||
std::string readFile(const std::string& filename, size_t maxBytes = 0) const;
|
||||
|
||||
/** Get OPF metadata value by key (e.g. "title", "creator"). */
|
||||
std::string getMetadata(const std::string& key) const;
|
||||
|
||||
private:
|
||||
EpubService() = default;
|
||||
EpubService(const EpubService&) = delete;
|
||||
EpubService& operator=(const EpubService&) = delete;
|
||||
EpubService(EpubService&&) = delete;
|
||||
EpubService& operator=(EpubService&&) = delete;
|
||||
|
||||
epub_reader_c* reader_ = nullptr;
|
||||
std::vector<SpineItem> spine_;
|
||||
std::vector<TocItem> toc_;
|
||||
std::map<std::string, std::string> metadata_;
|
||||
std::string contentOpfPath_;
|
||||
std::string coverPath_; // resolved path of cover image within the archive, or ""
|
||||
|
||||
void parseContainer();
|
||||
void parseContentOpf();
|
||||
void parseTocNcx(const std::string& tocPath);
|
||||
|
||||
static std::string pathParent(const std::string& path);
|
||||
};
|
||||
@@ -0,0 +1,439 @@
|
||||
#include "HtmlStrip.h"
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ESC token constants (0x1B = ASCII ESC, safe in UTF-8 text streams)
|
||||
// ---------------------------------------------------------------------------
|
||||
static const char ESC = '\x1B';
|
||||
static const char ALIGN_LEFT = 'L';
|
||||
static const char ALIGN_CENTER= 'C';
|
||||
static const char ALIGN_RIGHT = 'R';
|
||||
static const char BOLD_ON = 'B';
|
||||
static const char BOLD_OFF = 'b';
|
||||
static const char ITALIC_ON = 'I';
|
||||
static const char ITALIC_OFF = 'i';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal inline attribute scanner
|
||||
// Searches the raw attribute string for a named value, e.g.
|
||||
// findAttrValue("style", "text-align", ...) → "center"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Returns true if `haystack` (lower-cased attribute region) contains `needle`.
|
||||
static bool attrContains(const char* haystack, const char* needle) {
|
||||
return strstr(haystack, needle) != nullptr;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Emit a UTF-8 codepoint (up to U+10FFFF) as its byte sequence into `out`.
|
||||
// ---------------------------------------------------------------------------
|
||||
static void emitCodepoint(std::string& out, unsigned long cp) {
|
||||
if (cp < 0x80) {
|
||||
out += (char)cp;
|
||||
} else if (cp < 0x800) {
|
||||
out += (char)(0xC0 | (cp >> 6));
|
||||
out += (char)(0x80 | (cp & 0x3F));
|
||||
} else if (cp < 0x10000) {
|
||||
out += (char)(0xE0 | (cp >> 12));
|
||||
out += (char)(0x80 | ((cp >> 6) & 0x3F));
|
||||
out += (char)(0x80 | (cp & 0x3F));
|
||||
} else if (cp < 0x110000) {
|
||||
out += (char)(0xF0 | (cp >> 18));
|
||||
out += (char)(0x80 | ((cp >> 12) & 0x3F));
|
||||
out += (char)(0x80 | ((cp >> 6) & 0x3F));
|
||||
out += (char)(0x80 | (cp & 0x3F));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main function
|
||||
// ---------------------------------------------------------------------------
|
||||
void stripHtmlToText(const std::string& html, std::string& out) {
|
||||
out.clear();
|
||||
if (html.empty()) return;
|
||||
out.reserve(html.size() / 2);
|
||||
|
||||
// ── Parser state ────────────────────────────────────────────────────────
|
||||
bool inTag = false;
|
||||
bool skipBlock = false;
|
||||
char tagName[8] = {};
|
||||
int tagNameLen = 0;
|
||||
bool tagIsClose = false;
|
||||
bool tagNameDone = false;
|
||||
bool tagIsSelfClose = false;
|
||||
|
||||
// Attribute buffer - collects text between tag name and '>'
|
||||
char attrBuf[192] = {};
|
||||
int attrLen = 0;
|
||||
|
||||
// Inline style tracking (nesting depth)
|
||||
int boldDepth = 0;
|
||||
int italicDepth = 0;
|
||||
// Per-span style tracking - a stack so nested spans each close only what they opened.
|
||||
struct SpanState { bool bold; bool italic; };
|
||||
std::vector<SpanState> spanStack;
|
||||
|
||||
// Per-paragraph alignment (reset at each block element open)
|
||||
char pendingBlockAlign = ALIGN_LEFT;
|
||||
|
||||
int nlPending = 0;
|
||||
bool lastWasSpace = false;
|
||||
bool outputEmpty = true;
|
||||
|
||||
// Word buffer for hyphenation
|
||||
std::string wordBuf;
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
auto trimTrailingSpace = [&]() {
|
||||
if (lastWasSpace && !out.empty()) { out.pop_back(); lastWasSpace = false; }
|
||||
};
|
||||
|
||||
auto reqNL = [&](int n) {
|
||||
if (outputEmpty) return;
|
||||
trimTrailingSpace();
|
||||
nlPending = std::max(nlPending, n);
|
||||
};
|
||||
|
||||
auto addNL = [&](int n) {
|
||||
if (outputEmpty) return;
|
||||
trimTrailingSpace();
|
||||
nlPending = std::min(nlPending + n, 2);
|
||||
};
|
||||
|
||||
// Flush any buffered word directly into the output.
|
||||
auto flushWord = [&]() {
|
||||
if (wordBuf.empty()) return;
|
||||
if (!outputEmpty && nlPending > 0) {
|
||||
for (int k = 0; k < nlPending; ++k) out += '\n';
|
||||
nlPending = 0;
|
||||
lastWasSpace = false;
|
||||
}
|
||||
if (outputEmpty) {
|
||||
out += ESC;
|
||||
out += pendingBlockAlign;
|
||||
outputEmpty = false;
|
||||
}
|
||||
out += wordBuf;
|
||||
lastWasSpace = false;
|
||||
wordBuf.clear();
|
||||
};
|
||||
|
||||
// Emit a single ASCII character, with deferred-newline / space-collapse logic.
|
||||
// Characters that can be part of a word are buffered for hyphenation.
|
||||
auto emit = [&](char c) {
|
||||
if (c == ' ') {
|
||||
flushWord();
|
||||
if (outputEmpty || lastWasSpace || nlPending > 0) return;
|
||||
out += ' ';
|
||||
lastWasSpace = true;
|
||||
} else if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) {
|
||||
wordBuf += c;
|
||||
} else {
|
||||
// Non-alphabetic: flush any buffered word first
|
||||
flushWord();
|
||||
if (!outputEmpty && nlPending > 0) {
|
||||
for (int k = 0; k < nlPending; ++k) out += '\n';
|
||||
nlPending = 0;
|
||||
lastWasSpace = false;
|
||||
}
|
||||
if (outputEmpty) {
|
||||
out += ESC;
|
||||
out += pendingBlockAlign;
|
||||
outputEmpty = false;
|
||||
}
|
||||
out += c;
|
||||
lastWasSpace = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Emit a raw ESC token (bold/italic on/off) - bypasses word buffering.
|
||||
auto emitToken = [&](char code) {
|
||||
flushWord();
|
||||
if (!outputEmpty) {
|
||||
out += ESC;
|
||||
out += code;
|
||||
}
|
||||
// If outputEmpty, defer - the token will be emitted after alignment marker
|
||||
// by the next emit() call. For now, tokens before any content are dropped
|
||||
// (rare in practice: bold/italic before any paragraph text).
|
||||
};
|
||||
|
||||
// Emit a multi-byte UTF-8 sequence directly (after flushing word and newlines).
|
||||
auto emitUtf8 = [&](const char* bytes, int len) {
|
||||
flushWord();
|
||||
if (!outputEmpty && nlPending > 0) {
|
||||
for (int k = 0; k < nlPending; ++k) out += '\n';
|
||||
nlPending = 0;
|
||||
lastWasSpace = false;
|
||||
}
|
||||
if (outputEmpty) {
|
||||
out += ESC;
|
||||
out += pendingBlockAlign;
|
||||
outputEmpty = false;
|
||||
}
|
||||
for (int k = 0; k < len; ++k) out += bytes[k];
|
||||
lastWasSpace = false;
|
||||
};
|
||||
|
||||
// ── Main parse loop ──────────────────────────────────────────────────────
|
||||
for (size_t i = 0; i < html.size(); ++i) {
|
||||
unsigned char c = (unsigned char)html[i];
|
||||
|
||||
if (c == '<') {
|
||||
// HTML comment <!-- ... -->
|
||||
if (i + 3 < html.size() &&
|
||||
html[i+1]=='!' && html[i+2]=='-' && html[i+3]=='-') {
|
||||
size_t end = html.find("-->", i + 4);
|
||||
i = (end != std::string::npos) ? end + 2 : html.size() - 1;
|
||||
continue;
|
||||
}
|
||||
inTag = true;
|
||||
tagNameLen = 0;
|
||||
tagIsClose = false;
|
||||
tagNameDone = false;
|
||||
tagIsSelfClose = false;
|
||||
tagName[0] = '\0';
|
||||
attrLen = 0;
|
||||
attrBuf[0] = '\0';
|
||||
|
||||
} else if (c == '>') {
|
||||
if (inTag) {
|
||||
// Lower-case the attribute buffer for case-insensitive checks
|
||||
for (int k = 0; k < attrLen; ++k)
|
||||
attrBuf[k] = (char)tolower((unsigned char)attrBuf[k]);
|
||||
|
||||
bool isSS = (strcmp(tagName, "style") == 0 ||
|
||||
strcmp(tagName, "script") == 0 ||
|
||||
strcmp(tagName, "head") == 0);
|
||||
bool isP = (strcmp(tagName, "p") == 0);
|
||||
bool isDiv = (strcmp(tagName, "div") == 0);
|
||||
bool isBr = (strcmp(tagName, "br") == 0);
|
||||
bool isLi = (strcmp(tagName, "li") == 0);
|
||||
bool isH = (tagName[0] == 'h' && tagName[1] >= '1' &&
|
||||
tagName[1] <= '6' && tagName[2] == '\0');
|
||||
bool isB = (strcmp(tagName, "b") == 0 ||
|
||||
strcmp(tagName, "strong") == 0);
|
||||
bool isI = (strcmp(tagName, "i") == 0 ||
|
||||
strcmp(tagName, "em") == 0);
|
||||
bool isSpan= (strcmp(tagName, "span") == 0);
|
||||
|
||||
// Determine per-tag alignment and inline style from attributes
|
||||
char tagAlign = 0; // 0 = inherit / don't change
|
||||
bool attrBold = attrContains(attrBuf, "font-weight:bold") ||
|
||||
attrContains(attrBuf, "font-weight: bold");
|
||||
bool attrItalic = attrContains(attrBuf, "font-style:italic") ||
|
||||
attrContains(attrBuf, "font-style: italic");
|
||||
if (attrContains(attrBuf, "text-align:center") ||
|
||||
attrContains(attrBuf, "text-align: center"))
|
||||
tagAlign = ALIGN_CENTER;
|
||||
else if (attrContains(attrBuf, "text-align:right") ||
|
||||
attrContains(attrBuf, "text-align: right"))
|
||||
tagAlign = ALIGN_RIGHT;
|
||||
|
||||
if (isSS) {
|
||||
if (!tagIsClose && !tagIsSelfClose) skipBlock = true;
|
||||
else if (tagIsClose) skipBlock = false;
|
||||
} else if (!skipBlock) {
|
||||
if (isBr) {
|
||||
flushWord();
|
||||
addNL(1);
|
||||
} else if (isH && !tagIsClose) {
|
||||
// h1/h2 → centered, h3-h6 → left
|
||||
pendingBlockAlign = (tagName[1] <= '2') ? ALIGN_CENTER : ALIGN_LEFT;
|
||||
if (tagAlign) pendingBlockAlign = tagAlign;
|
||||
reqNL(2);
|
||||
} else if (isH && tagIsClose) {
|
||||
pendingBlockAlign = ALIGN_LEFT;
|
||||
reqNL(2);
|
||||
} else if ((isP || isDiv) && !tagIsClose) {
|
||||
pendingBlockAlign = tagAlign ? tagAlign : ALIGN_LEFT;
|
||||
reqNL(2);
|
||||
} else if ((isP || isDiv) && tagIsClose) {
|
||||
pendingBlockAlign = ALIGN_LEFT;
|
||||
reqNL(2);
|
||||
} else if (isLi && !tagIsClose) {
|
||||
flushWord();
|
||||
if (!outputEmpty) {
|
||||
trimTrailingSpace();
|
||||
int n = std::max(nlPending, 1);
|
||||
for (int k = 0; k < std::min(n, 2); ++k) out += '\n';
|
||||
nlPending = 0; lastWasSpace = false;
|
||||
}
|
||||
if (outputEmpty) { out += ESC; out += pendingBlockAlign; outputEmpty = false; }
|
||||
out += '-';
|
||||
out += ' ';
|
||||
lastWasSpace = true;
|
||||
} else if (isB && !tagIsClose) {
|
||||
boldDepth++;
|
||||
if (boldDepth == 1) emitToken(BOLD_ON);
|
||||
} else if (isB && tagIsClose) {
|
||||
if (boldDepth > 0) {
|
||||
boldDepth--;
|
||||
if (boldDepth == 0) emitToken(BOLD_OFF);
|
||||
}
|
||||
} else if (isI && !tagIsClose) {
|
||||
italicDepth++;
|
||||
if (italicDepth == 1) emitToken(ITALIC_ON);
|
||||
} else if (isI && tagIsClose) {
|
||||
if (italicDepth > 0) {
|
||||
italicDepth--;
|
||||
if (italicDepth == 0) emitToken(ITALIC_OFF);
|
||||
}
|
||||
} else if (isSpan && !tagIsClose) {
|
||||
SpanState ss = { false, false };
|
||||
if (attrBold && boldDepth == 0) { boldDepth++; ss.bold = true; emitToken(BOLD_ON); }
|
||||
if (attrItalic && italicDepth == 0) { italicDepth++; ss.italic = true; emitToken(ITALIC_ON); }
|
||||
spanStack.push_back(ss);
|
||||
} else if (isSpan && tagIsClose) {
|
||||
// Only close styles that this span opened - never touch depth set by <b>/<i>
|
||||
if (!spanStack.empty()) {
|
||||
SpanState ss = spanStack.back(); spanStack.pop_back();
|
||||
if (ss.bold && boldDepth > 0) { boldDepth--; if (boldDepth == 0) emitToken(BOLD_OFF); }
|
||||
if (ss.italic && italicDepth > 0) { italicDepth--; if (italicDepth == 0) emitToken(ITALIC_OFF); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
inTag = false;
|
||||
|
||||
} else if (inTag) {
|
||||
if (!tagNameDone) {
|
||||
if (tagNameLen == 0 && c == '/') {
|
||||
tagIsClose = true;
|
||||
} else if (c == '/') {
|
||||
tagIsSelfClose = true;
|
||||
tagNameDone = true;
|
||||
} else if (c == ' ' || c == '\t' || c == '\r' || c == '\n') {
|
||||
tagNameDone = true;
|
||||
} else if (tagNameLen < 6) {
|
||||
tagName[tagNameLen++] = (char)tolower((int)c);
|
||||
tagName[tagNameLen] = '\0';
|
||||
} else {
|
||||
tagNameDone = true;
|
||||
}
|
||||
} else {
|
||||
// Collect attribute content (limited buffer, lower-cased on '>')
|
||||
if (attrLen < (int)sizeof(attrBuf) - 1) {
|
||||
attrBuf[attrLen++] = (char)c;
|
||||
attrBuf[attrLen] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
} else if (!skipBlock) {
|
||||
// ── Text content ────────────────────────────────────────────────
|
||||
if (c == '\n' || c == '\r' || c == '\t') {
|
||||
emit(' ');
|
||||
} else if (c == '&') {
|
||||
// Entity decoding - output proper Unicode, not ASCII substitutes
|
||||
if (html.compare(i, 5, "­") == 0) {
|
||||
i += 4; // soft hyphen - discard
|
||||
}
|
||||
else if (html.compare(i, 6, " ") == 0) { emit(' '); i += 5; }
|
||||
else if (html.compare(i, 4, "<") == 0) { emit('<'); i += 3; }
|
||||
else if (html.compare(i, 4, ">") == 0) { emit('>'); i += 3; }
|
||||
else if (html.compare(i, 5, "&") == 0) { emit('&'); i += 4; }
|
||||
else if (html.compare(i, 6, """) == 0) { emit('"'); i += 5; }
|
||||
else if (html.compare(i, 6, "'") == 0) { emit('\''); i += 5; }
|
||||
// Typographic punctuation - emit proper Unicode
|
||||
else if (html.compare(i, 7, "—") == 0) {
|
||||
const char s[] = "\xE2\x80\x94"; emitUtf8(s, 3); i += 6;
|
||||
}
|
||||
else if (html.compare(i, 7, "–") == 0) {
|
||||
const char s[] = "\xE2\x80\x93"; emitUtf8(s, 3); i += 6;
|
||||
}
|
||||
else if (html.compare(i, 8, "…")== 0) {
|
||||
const char s[] = "\xE2\x80\xA6"; emitUtf8(s, 3); i += 7;
|
||||
}
|
||||
else if (html.compare(i, 7, "‘") == 0) {
|
||||
const char s[] = "\xE2\x80\x98"; emitUtf8(s, 3); i += 6;
|
||||
}
|
||||
else if (html.compare(i, 7, "’") == 0) {
|
||||
const char s[] = "\xE2\x80\x99"; emitUtf8(s, 3); i += 6;
|
||||
}
|
||||
else if (html.compare(i, 7, "“") == 0) {
|
||||
const char s[] = "\xE2\x80\x9C"; emitUtf8(s, 3); i += 6;
|
||||
}
|
||||
else if (html.compare(i, 7, "”") == 0) {
|
||||
const char s[] = "\xE2\x80\x9D"; emitUtf8(s, 3); i += 6;
|
||||
}
|
||||
else if (html.compare(i, 2, "&#") == 0) {
|
||||
size_t j = i + 2;
|
||||
bool isHex = (j < html.size() && (html[j]=='x' || html[j]=='X'));
|
||||
if (isHex) ++j;
|
||||
size_t numStart = j;
|
||||
while (j < html.size() && html[j] != ';') ++j;
|
||||
if (j < html.size() && j > numStart) {
|
||||
char numBuf[10] = {};
|
||||
size_t numLen = std::min(j - numStart, (size_t)(sizeof(numBuf)-1));
|
||||
html.copy(numBuf, numLen, numStart);
|
||||
unsigned long cp = isHex ? strtoul(numBuf, nullptr, 16)
|
||||
: strtoul(numBuf, nullptr, 10);
|
||||
if (cp == 0x00AD) {
|
||||
// Soft hyphen - discard
|
||||
} else if (cp == 0x00A0) {
|
||||
emit(' ');
|
||||
} else if (cp >= 0x20 && cp < 0x80) {
|
||||
emit((char)cp);
|
||||
} else if (cp >= 0x80) {
|
||||
// Emit as UTF-8
|
||||
std::string tmp;
|
||||
emitCodepoint(tmp, cp);
|
||||
emitUtf8(tmp.c_str(), (int)tmp.size());
|
||||
}
|
||||
i = j;
|
||||
} else {
|
||||
emit('&');
|
||||
}
|
||||
} else {
|
||||
emit('&');
|
||||
}
|
||||
|
||||
} else if (c < 0x80) {
|
||||
emit((char)c);
|
||||
|
||||
} else if ((c & 0xE0) == 0xC0) {
|
||||
// 2-byte UTF-8
|
||||
bool valid = (i + 1 < html.size() &&
|
||||
((unsigned char)html[i+1] & 0xC0) == 0x80);
|
||||
if (valid) {
|
||||
unsigned char b2 = (unsigned char)html[i+1];
|
||||
if (c == 0xC2 && b2 == 0xA0) {
|
||||
emit(' '); // NBSP → regular space
|
||||
} else if (c == 0xC2 && b2 == 0xAD) {
|
||||
// U+00AD soft hyphen - discard
|
||||
} else {
|
||||
char seq[2] = { (char)c, (char)b2 };
|
||||
emitUtf8(seq, 2);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
} else if ((c & 0xF0) == 0xE0 && i + 2 < html.size()) {
|
||||
// 3-byte UTF-8
|
||||
unsigned char b2 = (unsigned char)html[i+1];
|
||||
unsigned char b3 = (unsigned char)html[i+2];
|
||||
bool valid = ((b2 & 0xC0) == 0x80 && (b3 & 0xC0) == 0x80);
|
||||
if (valid) {
|
||||
char seq[3] = { (char)c, (char)b2, (char)b3 };
|
||||
emitUtf8(seq, 3);
|
||||
i += 2;
|
||||
}
|
||||
|
||||
} else if (c >= 0xF0 && i + 3 < html.size()) {
|
||||
// 4-byte UTF-8 (emoji etc.) - drop
|
||||
i += 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flushWord();
|
||||
|
||||
// Trim trailing whitespace / newlines
|
||||
while (!out.empty() && (out.back() == '\n' || out.back() == ' '))
|
||||
out.pop_back();
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
// Strips HTML/XHTML to a plain text + ESC-token string suitable for rendering.
|
||||
//
|
||||
// Output format:
|
||||
// - Every paragraph begins with an alignment token: ESC + 'L'|'C'|'R'
|
||||
// - Paragraphs are separated by '\n\n'
|
||||
// - Single '\n' = line break within a paragraph
|
||||
// - Bold/italic spans are bracketed by ESC tokens (for future span rendering):
|
||||
// ESC+'B' = bold on, ESC+'b' = bold off
|
||||
// ESC+'I' = italic on, ESC+'i' = italic off
|
||||
// - All inline ESC tokens are 2 bytes (ESC + code); renderers that don't
|
||||
// support inline styles strip them before setting label text.
|
||||
//
|
||||
// HTML handling:
|
||||
// - <h1>/<h2> → center aligned; <h3>-<h6> → left aligned
|
||||
// - <p>, <div> with style="text-align: center/right" → center/right aligned
|
||||
// - <b>, <strong>, <span style="font-weight:bold"> → bold on/off tokens
|
||||
// - <i>, <em>, <span style="font-style:italic"> → italic on/off tokens
|
||||
// - <br> → '\n'
|
||||
// - <li> → '\n- ' prefix
|
||||
// - <style>, <script>, <head> blocks suppressed entirely
|
||||
// - All HTML entities decoded; multi-byte UTF-8 passed through
|
||||
// - ­ → U+00AD soft hyphen (LVGL respects this for line breaking)
|
||||
// - Typographic entities (—, ‘ etc.) → proper Unicode
|
||||
// - Liang hyphenation applied to ASCII words (English, with soft hyphens)
|
||||
|
||||
void stripHtmlToText(const std::string& html, std::string& out);
|
||||
@@ -0,0 +1,317 @@
|
||||
#include "SimpleXmlParser.h"
|
||||
#include <tactility/log.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
static const char* TAG = "SimpleXmlParser";
|
||||
|
||||
SimpleXmlParser::SimpleXmlParser() {
|
||||
buffer_ = (uint8_t*)malloc(BUFFER_SIZE);
|
||||
if (!buffer_) {
|
||||
LOG_E(TAG, "Failed to allocate buffer");
|
||||
abort();
|
||||
}
|
||||
}
|
||||
|
||||
SimpleXmlParser::~SimpleXmlParser() {
|
||||
close();
|
||||
if (buffer_) {
|
||||
free(buffer_);
|
||||
buffer_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
bool SimpleXmlParser::openFromMemory(const char* data, size_t dataSize) {
|
||||
close();
|
||||
if (!buffer_ || !data) return false;
|
||||
|
||||
memoryData_ = data;
|
||||
memorySize_ = dataSize;
|
||||
usingMemory_ = true;
|
||||
|
||||
bufferStartPos_ = 0;
|
||||
bufferLen_ = 0;
|
||||
filePos_ = 0;
|
||||
currentNodeType_ = NodeType::None;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SimpleXmlParser::openFromStream(StreamCallback callback) {
|
||||
close();
|
||||
if (!buffer_ || !callback) return false;
|
||||
|
||||
streamCallback_ = callback;
|
||||
usingStream_ = true;
|
||||
streamPosition_ = 0;
|
||||
streamEOF_ = false;
|
||||
|
||||
bufferStartPos_ = 0;
|
||||
bufferLen_ = 0;
|
||||
filePos_ = 0;
|
||||
currentNodeType_ = NodeType::None;
|
||||
return true;
|
||||
}
|
||||
|
||||
void SimpleXmlParser::close() {
|
||||
memoryData_ = nullptr;
|
||||
memorySize_ = 0;
|
||||
usingMemory_ = false;
|
||||
streamCallback_ = nullptr;
|
||||
usingStream_ = false;
|
||||
streamPosition_ = 0;
|
||||
streamEOF_ = false;
|
||||
bufferStartPos_ = 0;
|
||||
bufferLen_ = 0;
|
||||
filePos_ = 0;
|
||||
currentNodeType_ = NodeType::None;
|
||||
}
|
||||
|
||||
bool SimpleXmlParser::loadBufferAround(size_t pos) {
|
||||
if (usingStream_) {
|
||||
// Simple streaming: we can only move forward
|
||||
if (pos < bufferStartPos_) return false;
|
||||
|
||||
while (pos >= bufferStartPos_ + bufferLen_ && !streamEOF_) {
|
||||
int bytesRead = streamCallback_((char*)buffer_, BUFFER_SIZE);
|
||||
if (bytesRead <= 0) {
|
||||
streamEOF_ = true;
|
||||
return false;
|
||||
}
|
||||
bufferStartPos_ = streamPosition_;
|
||||
bufferLen_ = bytesRead;
|
||||
streamPosition_ += bytesRead;
|
||||
}
|
||||
return pos >= bufferStartPos_ && pos < bufferStartPos_ + bufferLen_;
|
||||
}
|
||||
|
||||
if (usingMemory_) {
|
||||
if (pos >= memorySize_) return false;
|
||||
|
||||
bufferStartPos_ = pos;
|
||||
bufferLen_ = (memorySize_ - pos > BUFFER_SIZE) ? BUFFER_SIZE : (memorySize_ - pos);
|
||||
memcpy(buffer_, memoryData_ + pos, bufferLen_);
|
||||
return bufferLen_ > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
char SimpleXmlParser::getByteAt(size_t pos) {
|
||||
if (usingMemory_) {
|
||||
return (pos < memorySize_) ? memoryData_[pos] : '\0';
|
||||
}
|
||||
if (bufferLen_ > 0 && pos >= bufferStartPos_ && pos < bufferStartPos_ + bufferLen_) {
|
||||
return (char)buffer_[pos - bufferStartPos_];
|
||||
}
|
||||
if (loadBufferAround(pos)) {
|
||||
return (char)buffer_[pos - bufferStartPos_];
|
||||
}
|
||||
return '\0';
|
||||
}
|
||||
|
||||
char SimpleXmlParser::peekChar() {
|
||||
return getByteAt(filePos_);
|
||||
}
|
||||
|
||||
char SimpleXmlParser::readChar() {
|
||||
char c = getByteAt(filePos_);
|
||||
if (c != '\0') filePos_++;
|
||||
return c;
|
||||
}
|
||||
|
||||
bool SimpleXmlParser::skipWhitespace() {
|
||||
while (true) {
|
||||
char c = peekChar();
|
||||
if (c == '\0') return false;
|
||||
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
|
||||
readChar();
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool SimpleXmlParser::matchString(const char* str) {
|
||||
size_t len = strlen(str);
|
||||
size_t savedFilePos = filePos_;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
if (readChar() != str[i]) {
|
||||
filePos_ = savedFilePos;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SimpleXmlParser::read() {
|
||||
if (!usingMemory_ && !usingStream_) return false;
|
||||
|
||||
currentName_.clear();
|
||||
currentValue_.clear();
|
||||
isEmptyElement_ = false;
|
||||
attributes_.clear();
|
||||
|
||||
while (true) {
|
||||
char c = peekChar();
|
||||
if (c == '\0') {
|
||||
currentNodeType_ = NodeType::EndOfFile;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (c == '<') {
|
||||
readChar(); // consume '<'
|
||||
char next = peekChar();
|
||||
if (next == '/') {
|
||||
return readEndElement();
|
||||
} else if (next == '!') {
|
||||
readChar(); // consume '!'
|
||||
if (peekChar() == '-') return readComment();
|
||||
if (matchString("[CDATA[")) return readCDATA();
|
||||
skipToEndOfTag();
|
||||
continue;
|
||||
} else if (next == '?') {
|
||||
return readProcessingInstruction();
|
||||
} else {
|
||||
return readElement();
|
||||
}
|
||||
} else {
|
||||
if (readText()) return true;
|
||||
// whitespace-only text node - continue outer loop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool SimpleXmlParser::readElement() {
|
||||
currentNodeType_ = NodeType::Element;
|
||||
currentName_ = readElementName();
|
||||
parseAttributes();
|
||||
skipWhitespace();
|
||||
if (peekChar() == '/') {
|
||||
readChar();
|
||||
isEmptyElement_ = true;
|
||||
}
|
||||
skipToEndOfTag();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SimpleXmlParser::readEndElement() {
|
||||
currentNodeType_ = NodeType::EndElement;
|
||||
readChar(); // consume '/'
|
||||
currentName_ = readElementName();
|
||||
skipToEndOfTag();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SimpleXmlParser::readText() {
|
||||
currentNodeType_ = NodeType::Text;
|
||||
currentValue_.clear();
|
||||
bool hasNonWhitespace = false;
|
||||
while (true) {
|
||||
char c = peekChar();
|
||||
if (c == '\0' || c == '<') break;
|
||||
if (c != ' ' && c != '\t' && c != '\n' && c != '\r') hasNonWhitespace = true;
|
||||
currentValue_ += readChar();
|
||||
}
|
||||
return hasNonWhitespace; // false = whitespace-only; caller (read()) continues looping
|
||||
}
|
||||
|
||||
bool SimpleXmlParser::readComment() {
|
||||
currentNodeType_ = NodeType::Comment;
|
||||
readChar(); // consume '-'
|
||||
if (readChar() != '-') {
|
||||
skipToEndOfTag();
|
||||
return false;
|
||||
}
|
||||
while (true) {
|
||||
char c = readChar();
|
||||
if (c == '\0') break;
|
||||
if (c == '-' && peekChar() == '-') {
|
||||
readChar();
|
||||
if (peekChar() == '>') {
|
||||
readChar();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SimpleXmlParser::readCDATA() {
|
||||
currentNodeType_ = NodeType::CDATA;
|
||||
while (true) {
|
||||
char c = readChar();
|
||||
if (c == '\0') break;
|
||||
if (c == ']' && peekChar() == ']') {
|
||||
size_t savedPos = filePos_;
|
||||
readChar(); // tentatively consume second ']'
|
||||
if (peekChar() == '>') {
|
||||
readChar(); // consume '>'
|
||||
break;
|
||||
}
|
||||
filePos_ = savedPos; // not end of CDATA - backtrack second ']'
|
||||
}
|
||||
currentValue_ += c;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SimpleXmlParser::readProcessingInstruction() {
|
||||
currentNodeType_ = NodeType::ProcessingInstruction;
|
||||
readChar(); // consume '?'
|
||||
currentName_ = readElementName();
|
||||
while (true) {
|
||||
char c = readChar();
|
||||
if (c == '\0') break;
|
||||
if (c == '?' && peekChar() == '>') {
|
||||
readChar();
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string SimpleXmlParser::readElementName() {
|
||||
std::string name;
|
||||
while (true) {
|
||||
char c = peekChar();
|
||||
if (c == '\0' || c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '>' || c == '/' || c == '=') break;
|
||||
name += readChar();
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
void SimpleXmlParser::parseAttributes() {
|
||||
while (true) {
|
||||
skipWhitespace();
|
||||
char c = peekChar();
|
||||
if (c == '>' || c == '/' || c == '\0') break;
|
||||
std::string name = readElementName();
|
||||
if (name.empty()) break;
|
||||
skipWhitespace();
|
||||
if (readChar() != '=') break;
|
||||
skipWhitespace();
|
||||
char quote = readChar();
|
||||
if (quote != '"' && quote != '\'') break;
|
||||
std::string value;
|
||||
while (true) {
|
||||
char vc = readChar();
|
||||
if (vc == '\0' || vc == quote) break;
|
||||
value += vc;
|
||||
}
|
||||
attributes_.push_back({name, value});
|
||||
}
|
||||
}
|
||||
|
||||
void SimpleXmlParser::skipToEndOfTag() {
|
||||
while (true) {
|
||||
char c = readChar();
|
||||
if (c == '>' || c == '\0') break;
|
||||
}
|
||||
}
|
||||
|
||||
std::string SimpleXmlParser::getAttribute(const char* name) const {
|
||||
for (const auto& attr : attributes_) {
|
||||
if (attr.name == name) return attr.value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <functional>
|
||||
#include <cstdint>
|
||||
|
||||
/**
|
||||
* SimpleXmlParser - A buffered XML parser for reading attributes
|
||||
* Adapted from microreader for Tactility.
|
||||
*/
|
||||
class SimpleXmlParser {
|
||||
public:
|
||||
typedef std::function<int(char* buffer, size_t maxSize)> StreamCallback;
|
||||
|
||||
SimpleXmlParser();
|
||||
~SimpleXmlParser();
|
||||
SimpleXmlParser(const SimpleXmlParser&) = delete;
|
||||
SimpleXmlParser& operator=(const SimpleXmlParser&) = delete;
|
||||
SimpleXmlParser(SimpleXmlParser&&) = delete;
|
||||
SimpleXmlParser& operator=(SimpleXmlParser&&) = delete;
|
||||
|
||||
bool openFromMemory(const char* data, size_t dataSize);
|
||||
bool openFromStream(StreamCallback callback);
|
||||
void close();
|
||||
|
||||
enum class NodeType {
|
||||
None = 0,
|
||||
Element,
|
||||
Text,
|
||||
EndElement,
|
||||
Comment,
|
||||
ProcessingInstruction,
|
||||
CDATA,
|
||||
EndOfFile
|
||||
};
|
||||
|
||||
bool read();
|
||||
NodeType getNodeType() const { return currentNodeType_; }
|
||||
const std::string& getName() const { return currentName_; }
|
||||
bool isEmptyElement() const { return isEmptyElement_; }
|
||||
std::string getAttribute(const char* name) const;
|
||||
|
||||
// For text nodes
|
||||
const std::string& getText() const { return currentValue_; }
|
||||
|
||||
private:
|
||||
const char* memoryData_ = nullptr;
|
||||
size_t memorySize_ = 0;
|
||||
bool usingMemory_ = false;
|
||||
|
||||
StreamCallback streamCallback_;
|
||||
bool usingStream_ = false;
|
||||
size_t streamPosition_ = 0;
|
||||
bool streamEOF_ = false;
|
||||
|
||||
static constexpr size_t BUFFER_SIZE = 4096;
|
||||
uint8_t* buffer_ = nullptr;
|
||||
size_t bufferStartPos_ = 0;
|
||||
size_t bufferLen_ = 0;
|
||||
size_t filePos_ = 0;
|
||||
|
||||
char getByteAt(size_t pos);
|
||||
bool loadBufferAround(size_t pos);
|
||||
bool skipWhitespace();
|
||||
bool matchString(const char* str);
|
||||
char readChar();
|
||||
char peekChar();
|
||||
|
||||
struct Attribute {
|
||||
std::string name;
|
||||
std::string value;
|
||||
};
|
||||
|
||||
NodeType currentNodeType_ = NodeType::None;
|
||||
std::string currentName_;
|
||||
std::string currentValue_;
|
||||
bool isEmptyElement_ = false;
|
||||
std::vector<Attribute> attributes_;
|
||||
|
||||
bool readElement();
|
||||
bool readEndElement();
|
||||
bool readText();
|
||||
bool readComment();
|
||||
bool readCDATA();
|
||||
bool readProcessingInstruction();
|
||||
void parseAttributes();
|
||||
std::string readElementName();
|
||||
void skipToEndOfTag();
|
||||
};
|
||||
@@ -0,0 +1,298 @@
|
||||
#include "epub_parser.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <miniz.h>
|
||||
|
||||
/* Prefer PSRAM for large EPUB buffers when available (ESP32 with SPIRAM).
|
||||
* Falls back to regular malloc so the code compiles on hosts without PSRAM. */
|
||||
#ifdef CONFIG_SPIRAM
|
||||
# include <esp_heap_caps.h>
|
||||
static void* epub_malloc_large(size_t size) {
|
||||
void* p = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||
return p ? p : malloc(size);
|
||||
}
|
||||
#else
|
||||
# define epub_malloc_large(sz) malloc(sz)
|
||||
#endif
|
||||
|
||||
#define ZIP_CENTRAL_HEADER_SIG 0x02014b50
|
||||
#define ZIP_END_CENTRAL_SIG 0x06054b50
|
||||
|
||||
#pragma pack(push, 1)
|
||||
typedef struct {
|
||||
uint32_t signature;
|
||||
uint16_t version_made;
|
||||
uint16_t version_needed;
|
||||
uint16_t flags;
|
||||
uint16_t compression;
|
||||
uint16_t mod_time;
|
||||
uint16_t mod_date;
|
||||
uint32_t crc32;
|
||||
uint32_t compressed_size;
|
||||
uint32_t uncompressed_size;
|
||||
uint16_t filename_len;
|
||||
uint16_t extra_len;
|
||||
uint16_t comment_len;
|
||||
uint16_t disk_start;
|
||||
uint16_t internal_attr;
|
||||
uint32_t external_attr;
|
||||
uint32_t local_header_offset;
|
||||
} zip_central_dir_entry;
|
||||
|
||||
typedef struct {
|
||||
uint32_t signature;
|
||||
uint16_t disk_num;
|
||||
uint16_t central_dir_disk;
|
||||
uint16_t entries_this_disk;
|
||||
uint16_t total_entries;
|
||||
uint32_t central_dir_size;
|
||||
uint32_t central_dir_offset;
|
||||
uint16_t comment_len;
|
||||
} zip_end_central_dir;
|
||||
#pragma pack(pop)
|
||||
|
||||
typedef struct {
|
||||
char* filename;
|
||||
uint32_t compressed_size;
|
||||
uint32_t uncompressed_size;
|
||||
uint32_t local_header_offset;
|
||||
uint16_t compression;
|
||||
} file_entry;
|
||||
|
||||
struct epub_reader_c {
|
||||
FILE* fp;
|
||||
file_entry* files;
|
||||
uint32_t file_count;
|
||||
};
|
||||
|
||||
/* Stream context holds the fully decompressed file in memory.
|
||||
* This avoids the multi-call tinfl dictionary problem that occurs when
|
||||
* pOut_buf_next is reset between calls (losing LZ77 backreference history).
|
||||
* Peak memory during open = compressed_size + uncompressed_size; after open
|
||||
* only uncompressed_size is held. */
|
||||
struct epub_stream_context_c {
|
||||
uint8_t* data; /* decompressed (or stored) file bytes */
|
||||
uint32_t size; /* total bytes */
|
||||
uint32_t pos; /* current read position */
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Internal helpers
|
||||
* --------------------------------------------------------------------------- */
|
||||
|
||||
static int find_eocd(FILE* fp, zip_end_central_dir* eocd) {
|
||||
fseek(fp, 0, SEEK_END);
|
||||
long size = ftell(fp);
|
||||
if (size < 0) return 0;
|
||||
long search_range = (size > 1024) ? 1024 : size;
|
||||
if (search_range < 22) return 0; /* EOCD minimum size */
|
||||
fseek(fp, -search_range, SEEK_END);
|
||||
uint8_t* buf = (uint8_t*)malloc((size_t)search_range);
|
||||
if (!buf) return 0;
|
||||
size_t n = fread(buf, 1, (size_t)search_range, fp);
|
||||
if (n < 22) { free(buf); return 0; }
|
||||
for (int i = (int)n - 22; i >= 0; i--) {
|
||||
uint32_t sig;
|
||||
memcpy(&sig, &buf[i], sizeof(sig)); /* avoid unaligned pointer cast UB */
|
||||
if (sig == ZIP_END_CENTRAL_SIG) {
|
||||
memcpy(eocd, &buf[i], sizeof(zip_end_central_dir));
|
||||
free(buf);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
free(buf);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Seek fp past the local file header to the first byte of (compressed) data. */
|
||||
static int seek_to_local_data(FILE* fp, uint32_t local_header_offset) {
|
||||
if (fseek(fp, (long)local_header_offset, SEEK_SET) != 0) return 0;
|
||||
uint32_t sig;
|
||||
if (fread(&sig, 4, 1, fp) != 1) return 0;
|
||||
/* Skip: version_needed(2) flags(2) compression(2) mod_time(2) mod_date(2)
|
||||
crc32(4) compressed_size(4) uncompressed_size(4) = 22 bytes */
|
||||
if (fseek(fp, 22, SEEK_CUR) != 0) return 0;
|
||||
uint16_t nlen = 0, elen = 0;
|
||||
if (fread(&nlen, 2, 1, fp) != 1) return 0;
|
||||
if (fread(&elen, 2, 1, fp) != 1) return 0;
|
||||
if (fseek(fp, nlen + elen, SEEK_CUR) != 0) return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Public API
|
||||
* --------------------------------------------------------------------------- */
|
||||
|
||||
epub_parser_error epub_open_c(const char* filepath, epub_reader_c** out_reader) {
|
||||
if (!filepath || !out_reader) return EPUB_ERROR_INVALID_PARAM;
|
||||
FILE* fp = fopen(filepath, "rb");
|
||||
if (!fp) return EPUB_ERROR_FILE_NOT_FOUND;
|
||||
zip_end_central_dir eocd;
|
||||
if (!find_eocd(fp, &eocd)) {
|
||||
fclose(fp);
|
||||
return EPUB_ERROR_NOT_AN_EPUB;
|
||||
}
|
||||
epub_reader_c* reader = (epub_reader_c*)calloc(1, sizeof(epub_reader_c));
|
||||
if (!reader) { fclose(fp); return EPUB_ERROR_OUT_OF_MEMORY; }
|
||||
reader->fp = fp;
|
||||
reader->file_count = eocd.total_entries;
|
||||
reader->files = (file_entry*)calloc(reader->file_count, sizeof(file_entry));
|
||||
if (!reader->files) { fclose(fp); free(reader); return EPUB_ERROR_OUT_OF_MEMORY; }
|
||||
fseek(fp, eocd.central_dir_offset, SEEK_SET);
|
||||
for (uint32_t i = 0; i < reader->file_count; i++) {
|
||||
zip_central_dir_entry zcde;
|
||||
if (fread(&zcde, sizeof(zcde), 1, fp) != 1) {
|
||||
for (uint32_t j = 0; j < i; j++) free(reader->files[j].filename);
|
||||
free(reader->files);
|
||||
fclose(fp);
|
||||
free(reader);
|
||||
return EPUB_ERROR_NOT_AN_EPUB;
|
||||
}
|
||||
char* name = (char*)malloc(zcde.filename_len + 1u);
|
||||
if (!name) {
|
||||
for (uint32_t j = 0; j < i; j++) free(reader->files[j].filename);
|
||||
free(reader->files);
|
||||
fclose(fp);
|
||||
free(reader);
|
||||
return EPUB_ERROR_OUT_OF_MEMORY;
|
||||
}
|
||||
if (fread(name, 1, zcde.filename_len, fp) != zcde.filename_len) {
|
||||
free(name);
|
||||
for (uint32_t j = 0; j < i; j++) free(reader->files[j].filename);
|
||||
free(reader->files);
|
||||
fclose(fp);
|
||||
free(reader);
|
||||
return EPUB_ERROR_NOT_AN_EPUB;
|
||||
}
|
||||
name[zcde.filename_len] = '\0';
|
||||
if (fseek(fp, zcde.extra_len + zcde.comment_len, SEEK_CUR) != 0) {
|
||||
free(name);
|
||||
for (uint32_t j = 0; j < i; j++) free(reader->files[j].filename);
|
||||
free(reader->files);
|
||||
fclose(fp);
|
||||
free(reader);
|
||||
return EPUB_ERROR_NOT_AN_EPUB;
|
||||
}
|
||||
reader->files[i].filename = name;
|
||||
reader->files[i].compressed_size = zcde.compressed_size;
|
||||
reader->files[i].uncompressed_size = zcde.uncompressed_size;
|
||||
reader->files[i].local_header_offset = zcde.local_header_offset;
|
||||
reader->files[i].compression = zcde.compression;
|
||||
}
|
||||
*out_reader = reader;
|
||||
return EPUB_OK;
|
||||
}
|
||||
|
||||
void epub_close_c(epub_reader_c* reader) {
|
||||
if (!reader) return;
|
||||
if (reader->fp) fclose(reader->fp);
|
||||
for (uint32_t i = 0; i < reader->file_count; i++) {
|
||||
free(reader->files[i].filename);
|
||||
}
|
||||
free(reader->files);
|
||||
free(reader);
|
||||
}
|
||||
|
||||
uint32_t epub_get_file_count_c(epub_reader_c* reader) {
|
||||
return reader ? reader->file_count : 0;
|
||||
}
|
||||
|
||||
const char* epub_get_filename_c(epub_reader_c* reader, uint32_t index) {
|
||||
if (!reader || index >= reader->file_count) return NULL;
|
||||
return reader->files[index].filename;
|
||||
}
|
||||
|
||||
epub_stream_context_c* epub_stream_open_c(epub_reader_c* reader, const char* filename) {
|
||||
if (!reader || !filename) return NULL;
|
||||
|
||||
/* Find the entry */
|
||||
file_entry* entry = NULL;
|
||||
for (uint32_t i = 0; i < reader->file_count; i++) {
|
||||
if (strcmp(reader->files[i].filename, filename) == 0) {
|
||||
entry = &reader->files[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!entry) return NULL;
|
||||
|
||||
epub_stream_context_c* ctx = (epub_stream_context_c*)calloc(1, sizeof(epub_stream_context_c));
|
||||
if (!ctx) return NULL;
|
||||
|
||||
if (!seek_to_local_data(reader->fp, entry->local_header_offset)) {
|
||||
free(ctx);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (entry->compression == 0) {
|
||||
/* Stored - copy raw bytes directly */
|
||||
ctx->size = entry->uncompressed_size;
|
||||
ctx->data = (uint8_t*)epub_malloc_large(ctx->size);
|
||||
if (!ctx->data) { free(ctx); return NULL; }
|
||||
if (fread(ctx->data, 1, ctx->size, reader->fp) != ctx->size) {
|
||||
free(ctx->data); free(ctx); return NULL;
|
||||
}
|
||||
} else if (entry->compression == 8) {
|
||||
/* Deflate - buffer all compressed bytes then decompress in one shot.
|
||||
* We call tinfl_decompress directly (rather than the _mem_to_mem wrapper)
|
||||
* so we can place the ~10 KB tinfl_decompressor on the heap instead of
|
||||
* the stack, which would overflow the LVGL task.
|
||||
* Both the compressed and decompressed buffers can be hundreds of KB,
|
||||
* so allocate them in PSRAM when available. */
|
||||
uint8_t* compressed = (uint8_t*)epub_malloc_large(entry->compressed_size);
|
||||
if (!compressed) { free(ctx); return NULL; }
|
||||
if (fread(compressed, 1, entry->compressed_size, reader->fp) != entry->compressed_size) {
|
||||
free(compressed); free(ctx); return NULL;
|
||||
}
|
||||
|
||||
ctx->size = entry->uncompressed_size;
|
||||
if (ctx->size > SIZE_MAX - 1) { free(compressed); free(ctx); return NULL; }
|
||||
ctx->data = (uint8_t*)epub_malloc_large((size_t)ctx->size + 1u); /* +1 so callers can NUL-terminate if needed */
|
||||
if (!ctx->data) { free(compressed); free(ctx); return NULL; }
|
||||
|
||||
/* tinfl_decompressor is ~10 KB - must live on the heap, not the stack,
|
||||
* or it overflows the LVGL task's stack. */
|
||||
tinfl_decompressor* decomp = (tinfl_decompressor*)malloc(sizeof(tinfl_decompressor));
|
||||
if (!decomp) { free(compressed); free(ctx->data); free(ctx); return NULL; }
|
||||
tinfl_init(decomp);
|
||||
|
||||
size_t out_len = ctx->size;
|
||||
size_t in_len = entry->compressed_size;
|
||||
tinfl_status status = tinfl_decompress(
|
||||
decomp,
|
||||
(const mz_uint8*)compressed, &in_len,
|
||||
ctx->data, ctx->data, &out_len,
|
||||
TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF /* single-pass, sequential output */
|
||||
);
|
||||
|
||||
free(decomp);
|
||||
free(compressed);
|
||||
|
||||
if (status != TINFL_STATUS_DONE) {
|
||||
free(ctx->data); free(ctx); return NULL;
|
||||
}
|
||||
ctx->size = (uint32_t)out_len;
|
||||
} else {
|
||||
/* Unsupported compression method */
|
||||
free(ctx);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
ctx->pos = 0;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
size_t epub_stream_read_c(epub_stream_context_c* ctx, void* buffer, size_t size) {
|
||||
if (!ctx || !ctx->data || !buffer || ctx->pos >= ctx->size) return 0;
|
||||
size_t available = ctx->size - ctx->pos;
|
||||
size_t to_copy = (available < size) ? available : size;
|
||||
memcpy(buffer, ctx->data + ctx->pos, to_copy);
|
||||
ctx->pos += (uint32_t)to_copy;
|
||||
return to_copy;
|
||||
}
|
||||
|
||||
void epub_stream_close_c(epub_stream_context_c* ctx) {
|
||||
if (!ctx) return;
|
||||
free(ctx->data);
|
||||
free(ctx);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
#ifndef EPUB_PARSER_H
|
||||
#define EPUB_PARSER_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef enum {
|
||||
EPUB_OK = 0,
|
||||
EPUB_ERROR_FILE_NOT_FOUND, /**< The EPUB file itself could not be opened */
|
||||
EPUB_ERROR_NOT_AN_EPUB, /**< File exists but is not a valid ZIP/EPUB */
|
||||
EPUB_ERROR_CORRUPTED, /**< Archive structure is damaged */
|
||||
EPUB_ERROR_OUT_OF_MEMORY, /**< A memory allocation failed */
|
||||
EPUB_ERROR_INVALID_PARAM, /**< A required pointer argument was NULL */
|
||||
EPUB_ERROR_DECOMPRESSION_FAILED, /**< Deflate decompression error */
|
||||
EPUB_ERROR_INTERNAL_FILE_NOT_FOUND /**< A named file inside the EPUB archive was not found */
|
||||
} epub_parser_error;
|
||||
|
||||
typedef struct epub_reader_c epub_reader_c;
|
||||
typedef struct epub_stream_context_c epub_stream_context_c;
|
||||
|
||||
/**
|
||||
* Open an EPUB file for reading.
|
||||
* On success, EPUB_OK is returned and *out_reader is set to a new reader instance.
|
||||
* The reader must be freed with epub_close_c when no longer needed.
|
||||
* Returns an error code on failure; *out_reader is not modified.
|
||||
*/
|
||||
epub_parser_error epub_open_c(const char* filepath, epub_reader_c** out_reader);
|
||||
|
||||
/**
|
||||
* Close and free an epub_reader_c.
|
||||
* Safe to call with NULL (no-op).
|
||||
*/
|
||||
void epub_close_c(epub_reader_c* reader);
|
||||
|
||||
/**
|
||||
* Return the number of files in the EPUB archive.
|
||||
* Returns 0 if reader is NULL.
|
||||
*/
|
||||
uint32_t epub_get_file_count_c(epub_reader_c* reader);
|
||||
|
||||
/**
|
||||
* Return the filename of the archive entry at the given index.
|
||||
* The returned string is owned by the reader and is valid until epub_close_c is called.
|
||||
* Returns NULL if reader is NULL or index >= epub_get_file_count_c(reader).
|
||||
*/
|
||||
const char* epub_get_filename_c(epub_reader_c* reader, uint32_t index);
|
||||
|
||||
/**
|
||||
* Open a named file inside the EPUB archive for streaming.
|
||||
* Returns a stream context on success, or NULL if:
|
||||
* - reader or filename is NULL
|
||||
* - the file is not found in the archive
|
||||
* - a memory allocation fails
|
||||
* The caller must call epub_stream_close_c when done.
|
||||
*/
|
||||
epub_stream_context_c* epub_stream_open_c(epub_reader_c* reader, const char* filename);
|
||||
|
||||
/**
|
||||
* Read up to size bytes from the open stream into buffer.
|
||||
* Returns the number of bytes actually read.
|
||||
* Returns 0 at end-of-file or on a decompression error (the two cases are not distinguished).
|
||||
*/
|
||||
size_t epub_stream_read_c(epub_stream_context_c* ctx, void* buffer, size_t size);
|
||||
|
||||
/**
|
||||
* Close and free a stream context.
|
||||
* Safe to call with NULL (no-op).
|
||||
*/
|
||||
void epub_stream_close_c(epub_stream_context_c* ctx);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,11 @@
|
||||
#include "EpubReader.h"
|
||||
#include <TactilityCpp/App.h>
|
||||
|
||||
extern "C" {
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
registerApp<EpubReader>();
|
||||
return 0;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/*******************************************************************************
|
||||
* Size: 20 px
|
||||
* Bpp: 2
|
||||
* Opts: --no-compress --no-prefilter --bpp 2 --size 20 --font MaterialSymbolsRounded.ttf -r 0xF53E,0xF1C6 --format lvgl -o ..\source-fonts\material_symbols_shared_20.c --force-fast-kern-format
|
||||
******************************************************************************/
|
||||
|
||||
#include "lvgl.h"
|
||||
|
||||
#ifndef MATERIAL_SYMBOLS_SHARED_20
|
||||
#define MATERIAL_SYMBOLS_SHARED_20 1
|
||||
#endif
|
||||
|
||||
#if MATERIAL_SYMBOLS_SHARED_20
|
||||
|
||||
/*-----------------
|
||||
* BITMAPS
|
||||
*----------------*/
|
||||
|
||||
/*Store the image of the glyphs*/
|
||||
static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
|
||||
/* U+F1C6 "" */
|
||||
0x1a, 0xaa, 0xa4, 0x0, 0x7f, 0xff, 0xfe, 0x0,
|
||||
0xb0, 0x0, 0xb, 0x80, 0xb0, 0x15, 0x42, 0xe0,
|
||||
0xb0, 0xff, 0xe0, 0xb8, 0xb0, 0x55, 0x40, 0x2d,
|
||||
0xb0, 0x0, 0x0, 0xe, 0xb0, 0xff, 0xff, 0xe,
|
||||
0xb0, 0xff, 0xff, 0xe, 0xb0, 0x0, 0x0, 0xe,
|
||||
0xb0, 0x55, 0x55, 0xe, 0xb0, 0xff, 0xff, 0xe,
|
||||
0xb0, 0x15, 0x54, 0xe, 0xb0, 0x0, 0x0, 0xe,
|
||||
0x7f, 0xff, 0xff, 0xfd, 0x1a, 0xaa, 0xaa, 0xa4,
|
||||
|
||||
/* U+F53E "" */
|
||||
0x6, 0xaa, 0xaa, 0x43, 0xff, 0xff, 0xfe, 0x74,
|
||||
0xe0, 0x0, 0xeb, 0xe, 0x0, 0xe, 0xb0, 0xe0,
|
||||
0x0, 0xeb, 0xe, 0x0, 0xe, 0xb0, 0xe0, 0x0,
|
||||
0xeb, 0xe, 0x0, 0xe, 0xb0, 0xe0, 0x0, 0xeb,
|
||||
0xe, 0x0, 0xe, 0xb0, 0xe0, 0x0, 0xeb, 0x6f,
|
||||
0xaa, 0xae, 0xbf, 0xff, 0xff, 0xeb, 0x40, 0x0,
|
||||
0x3c, 0xb0, 0x0, 0x3, 0x87, 0xea, 0xaa, 0xbd,
|
||||
0x1f, 0xff, 0xff, 0xe0, 0x0, 0x0, 0x0
|
||||
};
|
||||
|
||||
|
||||
/*---------------------
|
||||
* GLYPH DESCRIPTION
|
||||
*--------------------*/
|
||||
|
||||
static const lv_font_fmt_txt_glyph_dsc_t glyph_dsc[] = {
|
||||
{.bitmap_index = 0, .adv_w = 0, .box_w = 0, .box_h = 0, .ofs_x = 0, .ofs_y = 0} /* id = 0 reserved */,
|
||||
{.bitmap_index = 0, .adv_w = 320, .box_w = 16, .box_h = 16, .ofs_x = 2, .ofs_y = 2},
|
||||
{.bitmap_index = 64, .adv_w = 320, .box_w = 14, .box_h = 18, .ofs_x = 3, .ofs_y = 1}
|
||||
};
|
||||
|
||||
/*---------------------
|
||||
* CHARACTER MAPPING
|
||||
*--------------------*/
|
||||
|
||||
static const uint16_t unicode_list_0[] = {
|
||||
0x0, 0x378
|
||||
};
|
||||
|
||||
/*Collect the unicode lists and glyph_id offsets*/
|
||||
static const lv_font_fmt_txt_cmap_t cmaps[] =
|
||||
{
|
||||
{
|
||||
.range_start = 61894, .range_length = 889, .glyph_id_start = 1,
|
||||
.unicode_list = unicode_list_0, .glyph_id_ofs_list = NULL, .list_length = 2, .type = LV_FONT_FMT_TXT_CMAP_SPARSE_TINY
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
/*--------------------
|
||||
* ALL CUSTOM DATA
|
||||
*--------------------*/
|
||||
|
||||
#if LVGL_VERSION_MAJOR == 8
|
||||
/*Store all the custom data of the font*/
|
||||
static lv_font_fmt_txt_glyph_cache_t cache;
|
||||
#endif
|
||||
|
||||
#if LVGL_VERSION_MAJOR >= 8
|
||||
static const lv_font_fmt_txt_dsc_t font_dsc = {
|
||||
#else
|
||||
static lv_font_fmt_txt_dsc_t font_dsc = {
|
||||
#endif
|
||||
.glyph_bitmap = glyph_bitmap,
|
||||
.glyph_dsc = glyph_dsc,
|
||||
.cmaps = cmaps,
|
||||
.kern_dsc = NULL,
|
||||
.kern_scale = 0,
|
||||
.cmap_num = 1,
|
||||
.bpp = 2,
|
||||
.kern_classes = 0,
|
||||
.bitmap_format = 0,
|
||||
#if LVGL_VERSION_MAJOR == 8
|
||||
.cache = &cache
|
||||
#endif
|
||||
};
|
||||
|
||||
|
||||
|
||||
/*-----------------
|
||||
* PUBLIC FONT
|
||||
*----------------*/
|
||||
|
||||
/*Initialize a public general font descriptor*/
|
||||
#if LVGL_VERSION_MAJOR >= 8
|
||||
const lv_font_t material_symbols_shared_20 = {
|
||||
#else
|
||||
lv_font_t material_symbols_shared_20 = {
|
||||
#endif
|
||||
.get_glyph_dsc = lv_font_get_glyph_dsc_fmt_txt, /*Function pointer to get glyph's data*/
|
||||
.get_glyph_bitmap = lv_font_get_bitmap_fmt_txt, /*Function pointer to get glyph's bitmap*/
|
||||
.line_height = 18, /*The maximum line height required by the font*/
|
||||
.base_line = -1, /*Baseline measured from the bottom of the line*/
|
||||
#if !(LVGL_VERSION_MAJOR == 6 && LVGL_VERSION_MINOR == 0)
|
||||
.subpx = LV_FONT_SUBPX_NONE,
|
||||
#endif
|
||||
#if LV_VERSION_CHECK(7, 4, 0) || LVGL_VERSION_MAJOR >= 8
|
||||
.underline_position = 0,
|
||||
.underline_thickness = 0,
|
||||
#endif
|
||||
.dsc = &font_dsc, /*The custom font data. Will be accessed by `get_glyph_bitmap/dsc` */
|
||||
#if LV_VERSION_CHECK(8, 2, 0) || LVGL_VERSION_MAJOR >= 9
|
||||
.fallback = NULL,
|
||||
#endif
|
||||
.user_data = NULL,
|
||||
};
|
||||
|
||||
|
||||
|
||||
#endif /*#if MATERIAL_SYMBOLS_SHARED_20*/
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/*******************************************************************************
|
||||
* Size: 32 px
|
||||
* Bpp: 2
|
||||
* Opts: --no-compress --no-prefilter --bpp 2 --size 32 --font MaterialSymbolsRounded.ttf -r 0xF53E,0xF1C6 --format lvgl -o ..\source-fonts\material_symbols_shared_32.c --force-fast-kern-format
|
||||
******************************************************************************/
|
||||
|
||||
#include "lvgl.h"
|
||||
|
||||
#ifndef MATERIAL_SYMBOLS_SHARED_32
|
||||
#define MATERIAL_SYMBOLS_SHARED_32 1
|
||||
#endif
|
||||
|
||||
#if MATERIAL_SYMBOLS_SHARED_32
|
||||
|
||||
/*-----------------
|
||||
* BITMAPS
|
||||
*----------------*/
|
||||
|
||||
/*Store the image of the glyphs*/
|
||||
static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
|
||||
/* U+F1C6 "" */
|
||||
0x2f, 0xff, 0xff, 0xff, 0x40, 0x0, 0xbf, 0xff,
|
||||
0xff, 0xff, 0xe0, 0x0, 0xfe, 0xaa, 0xaa, 0xab,
|
||||
0xf8, 0x0, 0xf8, 0x0, 0x0, 0x1, 0xfe, 0x0,
|
||||
0xf8, 0x0, 0x0, 0x0, 0x7f, 0x80, 0xf8, 0xa,
|
||||
0xaa, 0xa0, 0x1f, 0xe0, 0xf8, 0x2f, 0xff, 0xf8,
|
||||
0x7, 0xf8, 0xf8, 0x1f, 0xff, 0xf4, 0x1, 0xfd,
|
||||
0xf8, 0x0, 0x0, 0x0, 0x0, 0x7f, 0xf8, 0x0,
|
||||
0x0, 0x0, 0x0, 0x2f, 0xf8, 0x5, 0x55, 0x55,
|
||||
0x50, 0x2f, 0xf8, 0x2f, 0xff, 0xff, 0xf8, 0x2f,
|
||||
0xf8, 0x2f, 0xff, 0xff, 0xf8, 0x2f, 0xf8, 0x5,
|
||||
0x55, 0x55, 0x50, 0x2f, 0xf8, 0x0, 0x0, 0x0,
|
||||
0x0, 0x2f, 0xf8, 0x0, 0x0, 0x0, 0x0, 0x2f,
|
||||
0xf8, 0x1f, 0xff, 0xff, 0xf4, 0x2f, 0xf8, 0x2f,
|
||||
0xff, 0xff, 0xf8, 0x2f, 0xf8, 0xa, 0xaa, 0xaa,
|
||||
0xa0, 0x2f, 0xf8, 0x0, 0x0, 0x0, 0x0, 0x2f,
|
||||
0xf8, 0x0, 0x0, 0x0, 0x0, 0x2f, 0xfe, 0xaa,
|
||||
0xaa, 0xaa, 0xaa, 0xbf, 0xbf, 0xff, 0xff, 0xff,
|
||||
0xff, 0xfe, 0x2f, 0xff, 0xff, 0xff, 0xff, 0xf8,
|
||||
|
||||
/* U+F53E "" */
|
||||
0x1, 0x6a, 0xaa, 0xaa, 0xa9, 0x0, 0xff, 0xff,
|
||||
0xff, 0xff, 0xfc, 0x3f, 0xff, 0xff, 0xff, 0xff,
|
||||
0xe7, 0xf4, 0xf8, 0x0, 0x0, 0x3e, 0xbc, 0xf,
|
||||
0x80, 0x0, 0x3, 0xeb, 0xc0, 0xf8, 0x0, 0x0,
|
||||
0x3e, 0xbc, 0xf, 0x80, 0x0, 0x3, 0xeb, 0xc0,
|
||||
0xf8, 0x0, 0x0, 0x3e, 0xbc, 0xf, 0x80, 0x0,
|
||||
0x3, 0xeb, 0xc0, 0xf8, 0x0, 0x0, 0x3e, 0xbc,
|
||||
0xf, 0x80, 0x0, 0x3, 0xeb, 0xc0, 0xf8, 0x0,
|
||||
0x0, 0x3e, 0xbc, 0xf, 0x80, 0x0, 0x3, 0xeb,
|
||||
0xc0, 0xf8, 0x0, 0x0, 0x3e, 0xbc, 0xf, 0x80,
|
||||
0x0, 0x3, 0xeb, 0xc0, 0xf8, 0x0, 0x0, 0x3e,
|
||||
0xbc, 0xf, 0x80, 0x0, 0x3, 0xeb, 0xc0, 0xf8,
|
||||
0x0, 0x0, 0x3e, 0xbe, 0xff, 0xff, 0xff, 0xff,
|
||||
0xeb, 0xff, 0xff, 0xff, 0xff, 0xfe, 0xbf, 0xea,
|
||||
0xaa, 0xaa, 0xbf, 0xdb, 0xd0, 0x0, 0x0, 0x3,
|
||||
0xf0, 0xbc, 0x0, 0x0, 0x0, 0x3e, 0xb, 0xd0,
|
||||
0x0, 0x0, 0x3, 0xf0, 0x3f, 0xaa, 0xaa, 0xaa,
|
||||
0xbf, 0xc2, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x7,
|
||||
0xff, 0xff, 0xff, 0xff, 0xd0, 0x0, 0x0, 0x0,
|
||||
0x0, 0x0
|
||||
};
|
||||
|
||||
|
||||
/*---------------------
|
||||
* GLYPH DESCRIPTION
|
||||
*--------------------*/
|
||||
|
||||
static const lv_font_fmt_txt_glyph_dsc_t glyph_dsc[] = {
|
||||
{.bitmap_index = 0, .adv_w = 0, .box_w = 0, .box_h = 0, .ofs_x = 0, .ofs_y = 0} /* id = 0 reserved */,
|
||||
{.bitmap_index = 0, .adv_w = 512, .box_w = 24, .box_h = 24, .ofs_x = 4, .ofs_y = 4},
|
||||
{.bitmap_index = 144, .adv_w = 512, .box_w = 22, .box_h = 28, .ofs_x = 5, .ofs_y = 2}
|
||||
};
|
||||
|
||||
/*---------------------
|
||||
* CHARACTER MAPPING
|
||||
*--------------------*/
|
||||
|
||||
static const uint16_t unicode_list_0[] = {
|
||||
0x0, 0x378
|
||||
};
|
||||
|
||||
/*Collect the unicode lists and glyph_id offsets*/
|
||||
static const lv_font_fmt_txt_cmap_t cmaps[] =
|
||||
{
|
||||
{
|
||||
.range_start = 61894, .range_length = 889, .glyph_id_start = 1,
|
||||
.unicode_list = unicode_list_0, .glyph_id_ofs_list = NULL, .list_length = 2, .type = LV_FONT_FMT_TXT_CMAP_SPARSE_TINY
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
/*--------------------
|
||||
* ALL CUSTOM DATA
|
||||
*--------------------*/
|
||||
|
||||
#if LVGL_VERSION_MAJOR == 8
|
||||
/*Store all the custom data of the font*/
|
||||
static lv_font_fmt_txt_glyph_cache_t cache;
|
||||
#endif
|
||||
|
||||
#if LVGL_VERSION_MAJOR >= 8
|
||||
static const lv_font_fmt_txt_dsc_t font_dsc = {
|
||||
#else
|
||||
static lv_font_fmt_txt_dsc_t font_dsc = {
|
||||
#endif
|
||||
.glyph_bitmap = glyph_bitmap,
|
||||
.glyph_dsc = glyph_dsc,
|
||||
.cmaps = cmaps,
|
||||
.kern_dsc = NULL,
|
||||
.kern_scale = 0,
|
||||
.cmap_num = 1,
|
||||
.bpp = 2,
|
||||
.kern_classes = 0,
|
||||
.bitmap_format = 0,
|
||||
#if LVGL_VERSION_MAJOR == 8
|
||||
.cache = &cache
|
||||
#endif
|
||||
};
|
||||
|
||||
|
||||
|
||||
/*-----------------
|
||||
* PUBLIC FONT
|
||||
*----------------*/
|
||||
|
||||
/*Initialize a public general font descriptor*/
|
||||
#if LVGL_VERSION_MAJOR >= 8
|
||||
const lv_font_t material_symbols_shared_32 = {
|
||||
#else
|
||||
lv_font_t material_symbols_shared_32 = {
|
||||
#endif
|
||||
.get_glyph_dsc = lv_font_get_glyph_dsc_fmt_txt, /*Function pointer to get glyph's data*/
|
||||
.get_glyph_bitmap = lv_font_get_bitmap_fmt_txt, /*Function pointer to get glyph's bitmap*/
|
||||
.line_height = 28, /*The maximum line height required by the font*/
|
||||
.base_line = -2, /*Baseline measured from the bottom of the line*/
|
||||
#if !(LVGL_VERSION_MAJOR == 6 && LVGL_VERSION_MINOR == 0)
|
||||
.subpx = LV_FONT_SUBPX_NONE,
|
||||
#endif
|
||||
#if LV_VERSION_CHECK(7, 4, 0) || LVGL_VERSION_MAJOR >= 8
|
||||
.underline_position = 0,
|
||||
.underline_thickness = 0,
|
||||
#endif
|
||||
.dsc = &font_dsc, /*The custom font data. Will be accessed by `get_glyph_bitmap/dsc` */
|
||||
#if LV_VERSION_CHECK(8, 2, 0) || LVGL_VERSION_MAJOR >= 9
|
||||
.fallback = NULL,
|
||||
#endif
|
||||
.user_data = NULL,
|
||||
};
|
||||
|
||||
|
||||
|
||||
#endif /*#if MATERIAL_SYMBOLS_SHARED_32*/
|
||||
|
||||
Reference in New Issue
Block a user