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