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,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());
|
||||
}
|
||||
Reference in New Issue
Block a user