From 3ac611f63355ccff0eda990fa5a61950c2d6bfe4 Mon Sep 17 00:00:00 2001 From: Rajarshee Chatterjee Date: Tue, 11 Aug 2026 14:51:38 +0530 Subject: [PATCH] fix: Normalize EPUB SVG Images in Nitro Module (Closes #1622) --- modules/nitro-epub/README.md | 1 + modules/nitro-epub/android/CMakeLists.txt | 1 + .../cpp/import/EpubChapterNormalizer.cpp | 135 ++++++++++++++++++ .../cpp/import/EpubChapterNormalizer.hpp | 5 + modules/nitro-epub/cpp/import/EpubParser.cpp | 2 + .../nitro-epub/cpp/tests/EpubParserTest.cpp | 32 ++++- 6 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 modules/nitro-epub/cpp/import/EpubChapterNormalizer.cpp create mode 100644 modules/nitro-epub/cpp/import/EpubChapterNormalizer.hpp diff --git a/modules/nitro-epub/README.md b/modules/nitro-epub/README.md index 4a2ff168..03dc6e9b 100644 --- a/modules/nitro-epub/README.md +++ b/modules/nitro-epub/README.md @@ -6,6 +6,7 @@ Modules. ## Capabilities - Parse an extracted EPUB into novel metadata and chapter paths. +- Normalize raster-only SVG image wrappers into HTML images during import. - Export downloaded chapter files as an EPUB 3 archive. - Copy local chapter images into the archive and remove missing images. - Generate EPUB navigation, NCX compatibility navigation, metadata, CSS, and diff --git a/modules/nitro-epub/android/CMakeLists.txt b/modules/nitro-epub/android/CMakeLists.txt index b0ba51ed..4da25e74 100644 --- a/modules/nitro-epub/android/CMakeLists.txt +++ b/modules/nitro-epub/android/CMakeLists.txt @@ -8,6 +8,7 @@ set (CMAKE_CXX_STANDARD 20) # Define C++ library and add all sources add_library(${PACKAGE_NAME} SHARED src/main/cpp/cpp-adapter.cpp + ../cpp/import/EpubChapterNormalizer.cpp ../cpp/import/EpubParser.cpp ../cpp/export/EpubContentProcessor.cpp ../cpp/export/EpubDocuments.cpp diff --git a/modules/nitro-epub/cpp/import/EpubChapterNormalizer.cpp b/modules/nitro-epub/cpp/import/EpubChapterNormalizer.cpp new file mode 100644 index 00000000..8f967afb --- /dev/null +++ b/modules/nitro-epub/cpp/import/EpubChapterNormalizer.cpp @@ -0,0 +1,135 @@ +#include "EpubChapterNormalizer.hpp" + +#include "../pugixml.hpp" + +#include +#include +#include +#include +#include + +namespace { + +std::string getLocalName(const std::string& qualifiedName) { + const size_t separator = qualifiedName.find(':'); + return separator == std::string::npos + ? qualifiedName + : qualifiedName.substr(separator + 1); +} + +std::string toLower(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](char character) { + return static_cast( + std::tolower(static_cast(character))); + }); + return value; +} + +std::string trim(std::string value) { + const auto isNotWhitespace = [](char character) { + return !std::isspace(static_cast(character)); + }; + const auto start = std::find_if(value.begin(), value.end(), isNotWhitespace); + const auto end = std::find_if(value.rbegin(), value.rend(), isNotWhitespace) + .base(); + return start < end ? std::string(start, end) : ""; +} + +std::string getImageReference(const pugi::xml_node& image) { + for (const pugi::xml_attribute attribute : image.attributes()) { + if (toLower(getLocalName(attribute.name())) == "href") { + return attribute.value(); + } + } + return ""; +} + +void collectSvgNodes(const pugi::xml_node& parent, + std::vector& svgNodes) { + for (const pugi::xml_node child : parent.children()) { + if (child.type() != pugi::node_element) { + continue; + } + if (toLower(getLocalName(child.name())) == "svg") { + svgNodes.push_back(child); + } + collectSvgNodes(child, svgNodes); + } +} + +bool normalizeImageWrapper(const pugi::xml_node& svg) { + std::vector images; + std::string description; + + for (const pugi::xml_node child : svg.children()) { + if (child.type() != pugi::node_element) { + continue; + } + + const std::string childName = toLower(getLocalName(child.name())); + if (childName == "image") { + if (getImageReference(child).empty()) { + return false; + } + images.push_back(child); + } else if (childName == "title" || childName == "desc") { + if (description.empty()) { + description = trim(child.text().as_string()); + } + } else { + return false; + } + } + + if (images.empty()) { + return false; + } + + pugi::xml_node parent = svg.parent(); + for (const pugi::xml_node image : images) { + pugi::xml_node htmlImage = parent.insert_child_before("img", svg); + for (const pugi::xml_attribute attribute : image.attributes()) { + if (toLower(getLocalName(attribute.name())) != "href") { + htmlImage.append_attribute(attribute.name()) = attribute.value(); + } + } + htmlImage.append_attribute("src") = getImageReference(image).c_str(); + if (!description.empty() && !htmlImage.attribute("alt")) { + htmlImage.append_attribute("alt") = description.c_str(); + } + } + + return parent.remove_child(svg); +} + +} // namespace + +bool normalizeEpubChapter(const std::string& chapterPath) { + pugi::xml_document document; + if (!document.load_file(chapterPath.c_str(), pugi::parse_full)) { + return false; + } + + std::vector svgNodes; + collectSvgNodes(document, svgNodes); + + bool changed = false; + for (const pugi::xml_node svg : svgNodes) { + changed = normalizeImageWrapper(svg) || changed; + } + if (!changed) { + return false; + } + + const std::string temporaryPath = chapterPath + ".lnreader-normalized"; + const unsigned int format = pugi::format_raw | pugi::format_no_declaration; + if (!document.save_file(temporaryPath.c_str(), "", format)) { + std::remove(temporaryPath.c_str()); + return false; + } + if (std::rename(temporaryPath.c_str(), chapterPath.c_str()) != 0) { + std::remove(temporaryPath.c_str()); + return false; + } + return true; +} diff --git a/modules/nitro-epub/cpp/import/EpubChapterNormalizer.hpp b/modules/nitro-epub/cpp/import/EpubChapterNormalizer.hpp new file mode 100644 index 00000000..1f645c6b --- /dev/null +++ b/modules/nitro-epub/cpp/import/EpubChapterNormalizer.hpp @@ -0,0 +1,5 @@ +#pragma once + +#include + +bool normalizeEpubChapter(const std::string& chapterPath); diff --git a/modules/nitro-epub/cpp/import/EpubParser.cpp b/modules/nitro-epub/cpp/import/EpubParser.cpp index 33497cfa..d62a6643 100644 --- a/modules/nitro-epub/cpp/import/EpubParser.cpp +++ b/modules/nitro-epub/cpp/import/EpubParser.cpp @@ -4,6 +4,7 @@ #include #include #include +#include "EpubChapterNormalizer.hpp" #include "EpubParser.hpp" #include @@ -344,6 +345,7 @@ void parse_opf_from_folder(const std::string &base_dir, std::string chapter_href = id_to_href[idref]; Chapter chapter; chapter.path = join(opf_dir, chapter_href); + normalizeEpubChapter(chapter.path); if (path_to_label.count(chapter.path)) { diff --git a/modules/nitro-epub/cpp/tests/EpubParserTest.cpp b/modules/nitro-epub/cpp/tests/EpubParserTest.cpp index 6e85cc1c..cdd5f41b 100644 --- a/modules/nitro-epub/cpp/tests/EpubParserTest.cpp +++ b/modules/nitro-epub/cpp/tests/EpubParserTest.cpp @@ -15,6 +15,12 @@ void writeFile(const std::filesystem::path& path, const std::string& content) { file << content; } +std::string readFile(const std::filesystem::path& path) { + std::ifstream file(path); + return {std::istreambuf_iterator(file), + std::istreambuf_iterator()}; +} + bool containsPath(const std::vector& paths, const std::filesystem::path& expectedPath) { return std::find(paths.begin(), paths.end(), expectedPath.string()) != @@ -72,8 +78,12 @@ int main(int argc, char** argv) { )xml"); - writeFile(fixtureDirectory / "OEBPS/Text/chapter.xhtml", - "Chapter"); + writeFile( + fixtureDirectory / "OEBPS/Text/chapter.xhtml", + R"xml( +Chapter art + +)xml"); writeFile( fixtureDirectory / "OEBPS/Text/cover.xhtml", R"xml()xml"); @@ -96,5 +106,23 @@ int main(int argc, char** argv) { assert(metadata.chapters.front().path == (fixtureDirectory / "OEBPS/Text/cover.xhtml").string()); + const std::string coverChapter = + readFile(fixtureDirectory / "OEBPS/Text/cover.xhtml"); + assert(coverChapter.find("