fix(epub): decode percent-encoded internal asset paths so assets render correctly (#2249)
## 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 >**_
This commit is contained in:
+1
-1
@@ -118,7 +118,7 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata, const
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!imageRef.empty()) {
|
if (!imageRef.empty()) {
|
||||||
bookMetadata.coverItemHref = FsHelpers::normalisePath(coverPageBase + imageRef);
|
bookMetadata.coverItemHref = FsHelpers::normalisePath(FsHelpers::decodeUriEscapes(coverPageBase + imageRef));
|
||||||
LOG_DBG("EBP", "Found cover image from guide: %s", bookMetadata.coverItemHref.c_str());
|
LOG_DBG("EBP", "Found cover image from guide: %s", bookMetadata.coverItemHref.c_str());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
#include "FsHelpers.h"
|
#include "FsHelpers.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr uint8_t BOOK_CACHE_VERSION = 6;
|
constexpr uint8_t BOOK_CACHE_VERSION = 7;
|
||||||
constexpr char bookBinFile[] = "/book.bin";
|
constexpr char bookBinFile[] = "/book.bin";
|
||||||
constexpr char tmpSpineBinFile[] = "/spine.bin.tmp";
|
constexpr char tmpSpineBinFile[] = "/spine.bin.tmp";
|
||||||
constexpr char tmpTocBinFile[] = "/toc.bin.tmp";
|
constexpr char tmpTocBinFile[] = "/toc.bin.tmp";
|
||||||
|
|||||||
@@ -445,7 +445,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
|
|
||||||
{
|
{
|
||||||
// Resolve the image path relative to the HTML file
|
// Resolve the image path relative to the HTML file
|
||||||
std::string resolvedPath = FsHelpers::normalisePath(self->contentBase + src);
|
std::string resolvedPath = FsHelpers::normalisePath(FsHelpers::decodeUriEscapes(self->contentBase + src));
|
||||||
|
|
||||||
if (ImageDecoderFactory::isFormatSupported(resolvedPath)) {
|
if (ImageDecoderFactory::isFormatSupported(resolvedPath)) {
|
||||||
// Create a unique filename for the cached image
|
// Create a unique filename for the cached image
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
|
|||||||
if (strcmp(atts[i], "id") == 0) {
|
if (strcmp(atts[i], "id") == 0) {
|
||||||
itemId = atts[i + 1];
|
itemId = atts[i + 1];
|
||||||
} else if (strcmp(atts[i], "href") == 0) {
|
} else if (strcmp(atts[i], "href") == 0) {
|
||||||
href = FsHelpers::normalisePath(self->baseContentPath + atts[i + 1]);
|
href = FsHelpers::normalisePath(FsHelpers::decodeUriEscapes(self->baseContentPath + atts[i + 1]));
|
||||||
} else if (strcmp(atts[i], "media-type") == 0) {
|
} else if (strcmp(atts[i], "media-type") == 0) {
|
||||||
mediaType = atts[i + 1];
|
mediaType = atts[i + 1];
|
||||||
} else if (strcmp(atts[i], "properties") == 0) {
|
} else if (strcmp(atts[i], "properties") == 0) {
|
||||||
@@ -315,7 +315,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
|
|||||||
if (strcmp(atts[i], "type") == 0) {
|
if (strcmp(atts[i], "type") == 0) {
|
||||||
type = atts[i + 1];
|
type = atts[i + 1];
|
||||||
} else if (strcmp(atts[i], "href") == 0) {
|
} else if (strcmp(atts[i], "href") == 0) {
|
||||||
guideHref = FsHelpers::normalisePath(self->baseContentPath + atts[i + 1]);
|
guideHref = FsHelpers::normalisePath(FsHelpers::decodeUriEscapes(self->baseContentPath + atts[i + 1]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!guideHref.empty()) {
|
if (!guideHref.empty()) {
|
||||||
|
|||||||
@@ -126,13 +126,14 @@ void XMLCALL TocNavParser::endElement(void* userData, const XML_Char* name) {
|
|||||||
if (strcmp(name, "a") == 0 && self->state == IN_ANCHOR) {
|
if (strcmp(name, "a") == 0 && self->state == IN_ANCHOR) {
|
||||||
// Create TOC entry when closing anchor tag (we have all data now)
|
// Create TOC entry when closing anchor tag (we have all data now)
|
||||||
if (!self->currentLabel.empty() && !self->currentHref.empty()) {
|
if (!self->currentLabel.empty() && !self->currentHref.empty()) {
|
||||||
std::string href = FsHelpers::normalisePath(self->baseContentPath + self->currentHref);
|
const std::string rawTarget = self->baseContentPath + self->currentHref;
|
||||||
|
const size_t pos = rawTarget.find('#');
|
||||||
|
const std::string rawPath = pos == std::string::npos ? rawTarget : rawTarget.substr(0, pos);
|
||||||
|
std::string href = FsHelpers::normalisePath(FsHelpers::decodeUriEscapes(rawPath));
|
||||||
std::string anchor;
|
std::string anchor;
|
||||||
|
|
||||||
const size_t pos = href.find('#');
|
|
||||||
if (pos != std::string::npos) {
|
if (pos != std::string::npos) {
|
||||||
anchor = href.substr(pos + 1);
|
anchor = FsHelpers::decodeUriEscapes(rawTarget.substr(pos + 1));
|
||||||
href = href.substr(0, pos);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (self->cache) {
|
if (self->cache) {
|
||||||
|
|||||||
@@ -145,13 +145,14 @@ void XMLCALL TocNcxParser::endElement(void* userData, const XML_Char* name) {
|
|||||||
// This is the safest place to push the data, assuming <navLabel> always comes before <content>.
|
// This is the safest place to push the data, assuming <navLabel> always comes before <content>.
|
||||||
// NCX spec says navLabel comes before content.
|
// NCX spec says navLabel comes before content.
|
||||||
if (!self->currentLabel.empty() && !self->currentSrc.empty()) {
|
if (!self->currentLabel.empty() && !self->currentSrc.empty()) {
|
||||||
std::string href = FsHelpers::normalisePath(self->baseContentPath + self->currentSrc);
|
const std::string rawTarget = self->baseContentPath + self->currentSrc;
|
||||||
|
const size_t pos = rawTarget.find('#');
|
||||||
|
const std::string rawPath = pos == std::string::npos ? rawTarget : rawTarget.substr(0, pos);
|
||||||
|
std::string href = FsHelpers::normalisePath(FsHelpers::decodeUriEscapes(rawPath));
|
||||||
std::string anchor;
|
std::string anchor;
|
||||||
|
|
||||||
const size_t pos = href.find('#');
|
|
||||||
if (pos != std::string::npos) {
|
if (pos != std::string::npos) {
|
||||||
anchor = href.substr(pos + 1);
|
anchor = FsHelpers::decodeUriEscapes(rawTarget.substr(pos + 1));
|
||||||
href = href.substr(0, pos);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (self->cache) {
|
if (self->cache) {
|
||||||
|
|||||||
@@ -7,6 +7,34 @@
|
|||||||
|
|
||||||
namespace FsHelpers {
|
namespace FsHelpers {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
bool isHexDigit(const char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); }
|
||||||
|
|
||||||
|
uint8_t hexValue(const char c) {
|
||||||
|
if (c >= '0' && c <= '9') return static_cast<uint8_t>(c - '0');
|
||||||
|
if (c >= 'a' && c <= 'f') return static_cast<uint8_t>(10 + (c - 'a'));
|
||||||
|
return static_cast<uint8_t>(10 + (c - 'A'));
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::string decodeUriEscapes(const std::string& path) {
|
||||||
|
std::string decoded;
|
||||||
|
decoded.reserve(path.size());
|
||||||
|
|
||||||
|
for (size_t i = 0; i < path.size(); i++) {
|
||||||
|
if (path[i] == '%' && i + 2 < path.size() && isHexDigit(path[i + 1]) && isHexDigit(path[i + 2])) {
|
||||||
|
const uint8_t value = static_cast<uint8_t>((hexValue(path[i + 1]) << 4) | hexValue(path[i + 2]));
|
||||||
|
decoded += static_cast<char>(value);
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded += path[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
std::string normalisePath(const std::string& path) {
|
std::string normalisePath(const std::string& path) {
|
||||||
std::vector<std::string> components;
|
std::vector<std::string> components;
|
||||||
std::string component;
|
std::string component;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
|
|
||||||
namespace FsHelpers {
|
namespace FsHelpers {
|
||||||
|
|
||||||
|
std::string decodeUriEscapes(const std::string& path);
|
||||||
|
|
||||||
std::string normalisePath(const std::string& path);
|
std::string normalisePath(const std::string& path);
|
||||||
|
|
||||||
void sortFileList(std::vector<std::string>& strs);
|
void sortFileList(std::vector<std::string>& strs);
|
||||||
|
|||||||
Reference in New Issue
Block a user