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:
Shadowtrance
2026-06-07 23:56:32 +10:00
committed by GitHub
parent 1f959c8bbf
commit dbf850c434
127 changed files with 11156 additions and 174 deletions
+349
View File
@@ -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;
}
}
}
}
}
}
}
+125
View File
@@ -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);
}
+294
View File
@@ -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());
}
+69
View File
@@ -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);
};
+439
View File
@@ -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, "&shy;") == 0) {
i += 4; // soft hyphen - discard
}
else if (html.compare(i, 6, "&nbsp;") == 0) { emit(' '); i += 5; }
else if (html.compare(i, 4, "&lt;") == 0) { emit('<'); i += 3; }
else if (html.compare(i, 4, "&gt;") == 0) { emit('>'); i += 3; }
else if (html.compare(i, 5, "&amp;") == 0) { emit('&'); i += 4; }
else if (html.compare(i, 6, "&quot;") == 0) { emit('"'); i += 5; }
else if (html.compare(i, 6, "&apos;") == 0) { emit('\''); i += 5; }
// Typographic punctuation - emit proper Unicode
else if (html.compare(i, 7, "&mdash;") == 0) {
const char s[] = "\xE2\x80\x94"; emitUtf8(s, 3); i += 6;
}
else if (html.compare(i, 7, "&ndash;") == 0) {
const char s[] = "\xE2\x80\x93"; emitUtf8(s, 3); i += 6;
}
else if (html.compare(i, 8, "&hellip;")== 0) {
const char s[] = "\xE2\x80\xA6"; emitUtf8(s, 3); i += 7;
}
else if (html.compare(i, 7, "&lsquo;") == 0) {
const char s[] = "\xE2\x80\x98"; emitUtf8(s, 3); i += 6;
}
else if (html.compare(i, 7, "&rsquo;") == 0) {
const char s[] = "\xE2\x80\x99"; emitUtf8(s, 3); i += 6;
}
else if (html.compare(i, 7, "&ldquo;") == 0) {
const char s[] = "\xE2\x80\x9C"; emitUtf8(s, 3); i += 6;
}
else if (html.compare(i, 7, "&rdquo;") == 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();
}
+30
View File
@@ -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
// - &shy; → U+00AD soft hyphen (LVGL respects this for line breaking)
// - Typographic entities (&mdash;, &lsquo; 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();
};
+298
View File
@@ -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);
}
+79
View File
@@ -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
+11
View File
@@ -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*/