## Summary * **What is the goal of this PR?** * Fix EPUBs where internal file references are written in URL-style escaped form, like spaces appearing as `%20`, so the reader can find the right files instead of treating those references as missing. * **What changes are included?** * Added a small shared helper that converts those escaped EPUB-internal paths back into their normal filenames before we try to look them up. * Applied that cleanup step across the EPUB parsing flow wherever we resolve internal references, including cover images, manifest items, TOC links, spine entries, and inline HTML images. * Kept the change narrowly focused on EPUB-internal asset resolution rather than changing broader URL or networking behavior. ## Additional Context * The user-facing bug here is that some books package their internal filenames in an escaped form, so a file like `Chapter 1.xhtml` may be referenced more like `Chapter%201.xhtml`. The reader was treating that escaped text as the literal filename, which means otherwise-valid books could lose images, covers, or chapter targets because the lookup no longer matched the real file inside the EPUB. * Risk is intentionally low. The helper only rewrites valid `%XX` escape sequences and leaves malformed input alone, so it should improve compatibility with escaped filenames without broadening the parser’s behavior in unrelated cases. ## Local Testing Performed * This was tested on my device with the user-provided optimized epub that was not rendering images within the text prior to this fix (cover image and chapter headers were rendering fine): [orv_main_baseline.epub.zip](https://github.com/user-attachments/files/28529545/orv_main_baseline.epub.zip) * This was also tested by the user with a local build and the affected epub and confirmed to be working ## Steps for Testing * Try to open the affected epub (linked above) or any epub that has similar percent-encoding on a build prior to this fix. * Apply this fix, clear book cache, and re-open the affected book. * Images should render properly. --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**< YES >**_
403 lines
13 KiB
C++
403 lines
13 KiB
C++
#include "ContentOpfParser.h"
|
|
|
|
#include <FsHelpers.h>
|
|
#include <Logging.h>
|
|
#include <Serialization.h>
|
|
#include <XmlParserUtils.h>
|
|
|
|
#include <cctype>
|
|
|
|
#include "Epub/BookMetadataCache.h"
|
|
|
|
namespace {
|
|
constexpr char MEDIA_TYPE_NCX[] = "application/x-dtbncx+xml";
|
|
constexpr char MEDIA_TYPE_CSS[] = "text/css";
|
|
constexpr char MEDIA_TYPE_IMAGE_PREFIX[] = "image/";
|
|
constexpr char itemCacheFile[] = "/.items.bin";
|
|
|
|
bool startsWithImageMediaType(const std::string& mediaType) {
|
|
constexpr size_t prefixLen = sizeof(MEDIA_TYPE_IMAGE_PREFIX) - 1;
|
|
if (mediaType.size() < prefixLen) {
|
|
return false;
|
|
}
|
|
|
|
for (size_t i = 0; i < prefixLen; ++i) {
|
|
const char c = static_cast<char>(std::tolower(static_cast<unsigned char>(mediaType[i])));
|
|
if (c != MEDIA_TYPE_IMAGE_PREFIX[i]) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
} // namespace
|
|
|
|
bool ContentOpfParser::setup() {
|
|
parser = XML_ParserCreate(nullptr);
|
|
if (!parser) {
|
|
LOG_DBG("COF", "Couldn't allocate memory for parser");
|
|
return false;
|
|
}
|
|
|
|
XML_SetUserData(parser, this);
|
|
XML_SetElementHandler(parser, startElement, endElement);
|
|
XML_SetCharacterDataHandler(parser, characterData);
|
|
return true;
|
|
}
|
|
|
|
ContentOpfParser::~ContentOpfParser() {
|
|
destroyXmlParser(parser);
|
|
if (tempItemStore) {
|
|
tempItemStore.close();
|
|
}
|
|
const auto itemCachePath = cachePath + itemCacheFile;
|
|
if (Storage.exists(itemCachePath.c_str())) {
|
|
Storage.remove(itemCachePath.c_str());
|
|
}
|
|
}
|
|
|
|
size_t ContentOpfParser::write(const uint8_t data) { return write(&data, 1); }
|
|
|
|
size_t ContentOpfParser::write(const uint8_t* buffer, const size_t size) {
|
|
if (!parser) return 0;
|
|
|
|
const uint8_t* currentBufferPos = buffer;
|
|
auto remainingInBuffer = size;
|
|
|
|
while (remainingInBuffer > 0) {
|
|
void* const buf = XML_GetBuffer(parser, 1024);
|
|
|
|
if (!buf) {
|
|
LOG_ERR("COF", "Couldn't allocate memory for buffer");
|
|
destroyXmlParser(parser);
|
|
return 0;
|
|
}
|
|
|
|
const auto toRead = remainingInBuffer < 1024 ? remainingInBuffer : 1024;
|
|
memcpy(buf, currentBufferPos, toRead);
|
|
|
|
if (XML_ParseBuffer(parser, static_cast<int>(toRead), remainingSize == toRead) == XML_STATUS_ERROR) {
|
|
LOG_DBG("COF", "Parse error at line %lu: %s", XML_GetCurrentLineNumber(parser),
|
|
XML_ErrorString(XML_GetErrorCode(parser)));
|
|
destroyXmlParser(parser);
|
|
return 0;
|
|
}
|
|
|
|
currentBufferPos += toRead;
|
|
remainingInBuffer -= toRead;
|
|
remainingSize -= toRead;
|
|
}
|
|
|
|
return size;
|
|
}
|
|
|
|
void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) {
|
|
auto* self = static_cast<ContentOpfParser*>(userData);
|
|
(void)atts;
|
|
|
|
if (self->state == START && (strcmp(name, "package") == 0 || strcmp(name, "opf:package") == 0)) {
|
|
self->state = IN_PACKAGE;
|
|
return;
|
|
}
|
|
|
|
if (self->state == IN_PACKAGE && (strcmp(name, "metadata") == 0 || strcmp(name, "opf:metadata") == 0)) {
|
|
self->state = IN_METADATA;
|
|
return;
|
|
}
|
|
|
|
if (self->state == IN_METADATA && strcmp(name, "dc:title") == 0) {
|
|
// Only capture the first dc:title element; subsequent ones are subtitles
|
|
if (self->title.empty()) {
|
|
self->state = IN_BOOK_TITLE;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (self->state == IN_METADATA && strcmp(name, "dc:creator") == 0) {
|
|
self->state = IN_BOOK_AUTHOR;
|
|
return;
|
|
}
|
|
|
|
if (self->state == IN_METADATA && strcmp(name, "dc:language") == 0) {
|
|
self->state = IN_BOOK_LANGUAGE;
|
|
return;
|
|
}
|
|
|
|
if (self->state == IN_PACKAGE && (strcmp(name, "manifest") == 0 || strcmp(name, "opf:manifest") == 0)) {
|
|
self->state = IN_MANIFEST;
|
|
if (!Storage.openFileForWrite("COF", self->cachePath + itemCacheFile, self->tempItemStore)) {
|
|
LOG_ERR("COF", "Couldn't open temp items file for writing. This is probably going to be a fatal error.");
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (self->state == IN_PACKAGE && (strcmp(name, "spine") == 0 || strcmp(name, "opf:spine") == 0)) {
|
|
self->state = IN_SPINE;
|
|
if (!Storage.openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) {
|
|
LOG_ERR("COF", "Couldn't open temp items file for reading. This is probably going to be a fatal error.");
|
|
}
|
|
|
|
// Sort item index for binary search if we have enough items
|
|
if (self->itemIndex.size() >= LARGE_SPINE_THRESHOLD) {
|
|
std::sort(self->itemIndex.begin(), self->itemIndex.end(), [](const ItemIndexEntry& a, const ItemIndexEntry& b) {
|
|
return a.idHash < b.idHash || (a.idHash == b.idHash && a.idLen < b.idLen);
|
|
});
|
|
self->useItemIndex = true;
|
|
LOG_DBG("COF", "Using fast index for %zu manifest items", self->itemIndex.size());
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (self->state == IN_PACKAGE && (strcmp(name, "guide") == 0 || strcmp(name, "opf:guide") == 0)) {
|
|
self->state = IN_GUIDE;
|
|
// TODO Remove print
|
|
LOG_DBG("COF", "Entering guide state.");
|
|
if (!Storage.openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) {
|
|
LOG_ERR("COF", "Couldn't open temp items file for reading. This is probably going to be a fatal error.");
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (self->state == IN_METADATA && (strcmp(name, "meta") == 0 || strcmp(name, "opf:meta") == 0)) {
|
|
bool isCover = false;
|
|
std::string coverItemId;
|
|
|
|
for (int i = 0; atts[i]; i += 2) {
|
|
if (strcmp(atts[i], "name") == 0 && strcmp(atts[i + 1], "cover") == 0) {
|
|
isCover = true;
|
|
} else if (strcmp(atts[i], "content") == 0) {
|
|
coverItemId = atts[i + 1];
|
|
}
|
|
}
|
|
|
|
if (isCover) {
|
|
self->coverItemId = coverItemId;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (self->state == IN_MANIFEST && (strcmp(name, "item") == 0 || strcmp(name, "opf:item") == 0)) {
|
|
std::string itemId;
|
|
std::string href;
|
|
std::string mediaType;
|
|
std::string properties;
|
|
|
|
for (int i = 0; atts[i]; i += 2) {
|
|
if (strcmp(atts[i], "id") == 0) {
|
|
itemId = atts[i + 1];
|
|
} else if (strcmp(atts[i], "href") == 0) {
|
|
href = FsHelpers::normalisePath(FsHelpers::decodeUriEscapes(self->baseContentPath + atts[i + 1]));
|
|
} else if (strcmp(atts[i], "media-type") == 0) {
|
|
mediaType = atts[i + 1];
|
|
} else if (strcmp(atts[i], "properties") == 0) {
|
|
properties = atts[i + 1];
|
|
}
|
|
}
|
|
|
|
// Record index entry for fast lookup later
|
|
if (self->tempItemStore) {
|
|
ItemIndexEntry entry;
|
|
entry.idHash = fnvHash(itemId);
|
|
entry.idLen = static_cast<uint16_t>(itemId.size());
|
|
entry.fileOffset = static_cast<uint32_t>(self->tempItemStore.position());
|
|
self->itemIndex.push_back(entry);
|
|
}
|
|
|
|
// Write items down to SD card
|
|
serialization::writeString(self->tempItemStore, itemId);
|
|
serialization::writeString(self->tempItemStore, href);
|
|
|
|
if (itemId == self->coverItemId) {
|
|
// Some EPUBs set meta name="cover" to an XHTML wrapper item.
|
|
// Only treat it as a cover image when the manifest media-type is image/*.
|
|
if (startsWithImageMediaType(mediaType)) {
|
|
self->coverItemHref = href;
|
|
} else {
|
|
LOG_DBG("COF", "Ignoring meta cover item '%s' with non-image media type: %s", itemId.c_str(),
|
|
mediaType.c_str());
|
|
}
|
|
}
|
|
|
|
if (mediaType == MEDIA_TYPE_NCX) {
|
|
if (self->tocNcxPath.empty()) {
|
|
self->tocNcxPath = href;
|
|
} else {
|
|
LOG_DBG("COF", "Warning: Multiple NCX files found in manifest. Ignoring duplicate: %s", href.c_str());
|
|
}
|
|
}
|
|
|
|
// Collect CSS files
|
|
if (mediaType == MEDIA_TYPE_CSS) {
|
|
self->cssFiles.push_back(href);
|
|
}
|
|
|
|
// EPUB 3: Check for nav document (properties contains "nav")
|
|
if (!properties.empty() && self->tocNavPath.empty()) {
|
|
// Properties is space-separated, check if "nav" is present as a word
|
|
if (properties == "nav" || properties.find("nav ") == 0 || properties.find(" nav") != std::string::npos) {
|
|
self->tocNavPath = href;
|
|
LOG_DBG("COF", "Found EPUB 3 nav document: %s", href.c_str());
|
|
}
|
|
}
|
|
|
|
// EPUB 3: Check for cover image (properties contains "cover-image")
|
|
if (!properties.empty() && self->coverItemHref.empty()) {
|
|
if (properties == "cover-image" || properties.find("cover-image ") == 0 ||
|
|
properties.find(" cover-image") != std::string::npos) {
|
|
self->coverItemHref = href;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// NOTE: This relies on spine appearing after item manifest (which is pretty safe as it's part of the EPUB spec)
|
|
// Only run the spine parsing if there's a cache to add it to
|
|
if (self->cache) {
|
|
if (self->state == IN_SPINE && (strcmp(name, "itemref") == 0 || strcmp(name, "opf:itemref") == 0)) {
|
|
for (int i = 0; atts[i]; i += 2) {
|
|
if (strcmp(atts[i], "idref") == 0) {
|
|
const std::string idref = atts[i + 1];
|
|
std::string href;
|
|
bool found = false;
|
|
|
|
if (self->useItemIndex) {
|
|
// Fast path: binary search
|
|
uint32_t targetHash = fnvHash(idref);
|
|
uint16_t targetLen = static_cast<uint16_t>(idref.size());
|
|
|
|
auto it = std::lower_bound(self->itemIndex.begin(), self->itemIndex.end(),
|
|
ItemIndexEntry{targetHash, targetLen, 0},
|
|
[](const ItemIndexEntry& a, const ItemIndexEntry& b) {
|
|
return a.idHash < b.idHash || (a.idHash == b.idHash && a.idLen < b.idLen);
|
|
});
|
|
|
|
// Check for match (may need to check a few due to hash collisions)
|
|
while (it != self->itemIndex.end() && it->idHash == targetHash) {
|
|
self->tempItemStore.seek(it->fileOffset);
|
|
std::string itemId;
|
|
serialization::readString(self->tempItemStore, itemId);
|
|
if (itemId == idref) {
|
|
serialization::readString(self->tempItemStore, href);
|
|
found = true;
|
|
break;
|
|
}
|
|
++it;
|
|
}
|
|
} else {
|
|
// Slow path: linear scan (for small manifests, keeps original behavior)
|
|
// TODO: This lookup is slow as need to scan through all items each time.
|
|
// It can take up to 200ms per item when getting to 1500 items.
|
|
self->tempItemStore.seek(0);
|
|
std::string itemId;
|
|
while (self->tempItemStore.available()) {
|
|
serialization::readString(self->tempItemStore, itemId);
|
|
serialization::readString(self->tempItemStore, href);
|
|
if (itemId == idref) {
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (found && self->cache) {
|
|
self->cache->createSpineEntry(href);
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
// parse the guide
|
|
if (self->state == IN_GUIDE && (strcmp(name, "reference") == 0 || strcmp(name, "opf:reference") == 0)) {
|
|
std::string type;
|
|
std::string guideHref;
|
|
for (int i = 0; atts[i]; i += 2) {
|
|
if (strcmp(atts[i], "type") == 0) {
|
|
type = atts[i + 1];
|
|
} else if (strcmp(atts[i], "href") == 0) {
|
|
guideHref = FsHelpers::normalisePath(FsHelpers::decodeUriEscapes(self->baseContentPath + atts[i + 1]));
|
|
}
|
|
}
|
|
if (!guideHref.empty()) {
|
|
if (type == "text" || (type == "start" && !self->textReferenceHref.empty())) {
|
|
LOG_DBG("COF", "Found %s reference in guide: %s", type.c_str(), guideHref.c_str());
|
|
self->textReferenceHref = guideHref;
|
|
} else if ((type == "cover" || type == "cover-page") && self->guideCoverPageHref.empty()) {
|
|
LOG_DBG("COF", "Found cover reference in guide: %s", guideHref.c_str());
|
|
self->guideCoverPageHref = guideHref;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
void XMLCALL ContentOpfParser::characterData(void* userData, const XML_Char* s, const int len) {
|
|
auto* self = static_cast<ContentOpfParser*>(userData);
|
|
|
|
if (self->state == IN_BOOK_TITLE) {
|
|
self->title.append(s, len);
|
|
return;
|
|
}
|
|
|
|
if (self->state == IN_BOOK_AUTHOR) {
|
|
if (!self->author.empty()) {
|
|
self->author.append(", "); // Add separator for multiple authors
|
|
}
|
|
self->author.append(s, len);
|
|
return;
|
|
}
|
|
|
|
if (self->state == IN_BOOK_LANGUAGE) {
|
|
self->language.append(s, len);
|
|
return;
|
|
}
|
|
}
|
|
|
|
void XMLCALL ContentOpfParser::endElement(void* userData, const XML_Char* name) {
|
|
auto* self = static_cast<ContentOpfParser*>(userData);
|
|
(void)name;
|
|
|
|
if (self->state == IN_SPINE && (strcmp(name, "spine") == 0 || strcmp(name, "opf:spine") == 0)) {
|
|
self->state = IN_PACKAGE;
|
|
self->tempItemStore.close();
|
|
return;
|
|
}
|
|
|
|
if (self->state == IN_GUIDE && (strcmp(name, "guide") == 0 || strcmp(name, "opf:guide") == 0)) {
|
|
self->state = IN_PACKAGE;
|
|
self->tempItemStore.close();
|
|
return;
|
|
}
|
|
|
|
if (self->state == IN_MANIFEST && (strcmp(name, "manifest") == 0 || strcmp(name, "opf:manifest") == 0)) {
|
|
self->state = IN_PACKAGE;
|
|
self->tempItemStore.close();
|
|
return;
|
|
}
|
|
|
|
if (self->state == IN_BOOK_TITLE && strcmp(name, "dc:title") == 0) {
|
|
self->state = IN_METADATA;
|
|
return;
|
|
}
|
|
|
|
if (self->state == IN_BOOK_AUTHOR && strcmp(name, "dc:creator") == 0) {
|
|
self->state = IN_METADATA;
|
|
return;
|
|
}
|
|
|
|
if (self->state == IN_BOOK_LANGUAGE && strcmp(name, "dc:language") == 0) {
|
|
self->state = IN_METADATA;
|
|
return;
|
|
}
|
|
|
|
if (self->state == IN_METADATA && (strcmp(name, "metadata") == 0 || strcmp(name, "opf:metadata") == 0)) {
|
|
self->state = IN_PACKAGE;
|
|
return;
|
|
}
|
|
|
|
if (self->state == IN_PACKAGE && (strcmp(name, "package") == 0 || strcmp(name, "opf:package") == 0)) {
|
|
self->state = START;
|
|
return;
|
|
}
|
|
}
|