diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2897ed0dbd..d73aac1d4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,7 +132,7 @@ jobs: uses: actions/cache@v4 with: path: build/test/_deps/googletest-src - key: ${{ runner.os }}-googletest-v1.15.2 + key: ${{ runner.os }}-googletest-${{ hashFiles('test/CMakeLists.txt') }} - name: Configure run: cmake -S test -B build/test -G Ninja -DCMAKE_BUILD_TYPE=Release diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 2a0d0a2e5a..3e2fb827dd 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -256,6 +256,38 @@ bool Epub::parseTocNavFile() const { return true; } +void Epub::discoverCssFilesFromZip() { + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + LOG_ERR("EBP", "Cannot discover CSS from ZIP because book metadata cache is not loaded"); + return; + } + + ZipFile zf(filepath); + + if (!zf.loadAllFileStatSlims()) { + LOG_ERR("EBP", "Failed to load ZIP file stat slims for CSS discovery"); + return; + } + + size_t lastSlash = contentBasePath.find_last_of('/'); + + std::string opfDir = (lastSlash != std::string::npos) ? contentBasePath.substr(0, lastSlash + 1) : ""; + + zf.enumerateFilePaths([&](std::string_view filePath) { + if (!opfDir.empty() && filePath.find(opfDir) != 0) { + return; // Skip files that are not in the same directory as OPF manifest, as CSS files are typically located + // there or in subfolders + } + + if (FsHelpers::hasCssExtension(filePath)) { + if (std::find(cssFiles.begin(), cssFiles.end(), filePath) == cssFiles.end()) { + LOG_DBG("EBP", "Discovered CSS file via ZIP enumeration: %.*s", (int)filePath.size(), filePath.data()); + cssFiles.push_back(std::string{filePath}); + } + } + }); +} + void Epub::parseCssFiles() const { // Maximum CSS file size we'll attempt to parse (uncompressed) // Larger files risk memory exhaustion on ESP32 @@ -330,9 +362,9 @@ void Epub::parseCssFiles() const { if (!cssParser->saveToCache()) { LOG_ERR("EBP", "Failed to save CSS rules to cache"); } - cssParser->clear(); LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size()); + cssParser->clear(); } // load in the meta data for the epub file @@ -355,6 +387,10 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) { if (!parseContentOpf(bookMetadataCache->coreMetadata)) { LOG_ERR("EBP", "Could not parse content.opf from cached bookMetadata for CSS files"); // continue anyway - book will work without CSS and we'll still load any inline style CSS + } else { + // Handle case where CSS files are not listed in OPF manifest + // but are still referenced by HTML files - discover and parse them too + discoverCssFilesFromZip(); } parseCssFiles(); // Invalidate section caches so they are rebuilt with the new CSS @@ -458,6 +494,9 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) { } if (!skipLoadingCss) { + // Handle case where CSS files are not listed in OPF manifest + // but are still referenced by HTML files - discover and parse them too + discoverCssFilesFromZip(); // Parse CSS files after cache reload parseCssFiles(); Storage.removeDir((cachePath + "/sections").c_str()); diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index ba1dbba7e9..da165e7db7 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -37,6 +37,7 @@ class Epub { bool parseTocNcxFile() const; bool parseTocNavFile() const; void parseCssFiles() const; + void discoverCssFilesFromZip(); public: explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) { diff --git a/lib/FsHelpers/FsHelpers.cpp b/lib/FsHelpers/FsHelpers.cpp index a73f890104..fe48587f16 100644 --- a/lib/FsHelpers/FsHelpers.cpp +++ b/lib/FsHelpers/FsHelpers.cpp @@ -129,6 +129,8 @@ bool hasTxtExtension(std::string_view fileName) { return checkFileExtension(file bool hasMarkdownExtension(std::string_view fileName) { return checkFileExtension(fileName, ".md"); } +bool hasCssExtension(std::string_view fileName) { return checkFileExtension(fileName, ".css"); } + std::string extractFolderPath(const std::string& filePath) { const auto lastSlash = filePath.find_last_of('/'); if (lastSlash == std::string::npos || lastSlash == 0) { diff --git a/lib/FsHelpers/FsHelpers.h b/lib/FsHelpers/FsHelpers.h index 8edc30343d..8dfbdec641 100644 --- a/lib/FsHelpers/FsHelpers.h +++ b/lib/FsHelpers/FsHelpers.h @@ -39,6 +39,9 @@ bool hasTxtExtension(std::string_view fileName); // Check for .md extension (case-insensitive) bool hasMarkdownExtension(std::string_view fileName); +// Check for .css extension (case-insensitive) +bool hasCssExtension(std::string_view fileName); + std::string extractFolderPath(const std::string& filePath); /** diff --git a/lib/ZipFile/ZipFile.h b/lib/ZipFile/ZipFile.h index 2a32e974e1..6c4f93961b 100644 --- a/lib/ZipFile/ZipFile.h +++ b/lib/ZipFile/ZipFile.h @@ -3,6 +3,7 @@ #include #include +#include #include class ZipFile { @@ -69,4 +70,11 @@ class ZipFile { // These functions will open and close the zip as needed uint8_t* readFileToMemory(const char* filename, size_t* size = nullptr, bool trailingNullByte = false); bool readFileToStream(const char* filename, Print& out, size_t chunkSize); + + template + void enumerateFilePaths(F&& callback) const { + for (const auto& entry : fileStatSlimCache) { + callback(std::string_view{entry.first}); + } + } }; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 577c8e5461..8e491db541 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -14,7 +14,7 @@ include(FetchContent) FetchContent_Declare( googletest GIT_REPOSITORY https://github.com/google/googletest.git - GIT_TAG v1.15.2 + GIT_TAG v1.17.0 ) set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) diff --git a/test/README b/test/README index f9856e1eff..2317a3aa9b 100644 --- a/test/README +++ b/test/README @@ -3,6 +3,7 @@ Host-side gtest unit tests for crosspoint-reader. Build and run: cmake -S test -B build/test + cmake --build build/test ctest --test-dir build/test --output-on-failure -j Run a single suite directly: @@ -10,5 +11,5 @@ Run a single suite directly: cmake --build build/test --target StreamingJsonParserTest build/test/streaming_json_parser/StreamingJsonParserTest --gtest_filter='*' -Google Test is fetched via CMake FetchContent (pinned to v1.15.2 in -test/CMakeLists.txt) on first configure. +Google Test is fetched via CMake FetchContent on first configure; the pinned +version lives in test/CMakeLists.txt. diff --git a/test/differential_rounding/DifferentialRoundingTest.cpp b/test/differential_rounding/DifferentialRoundingTest.cpp index 275a023e2c..9efed6a84c 100644 --- a/test/differential_rounding/DifferentialRoundingTest.cpp +++ b/test/differential_rounding/DifferentialRoundingTest.cpp @@ -177,10 +177,10 @@ TEST(EpdFont, KernLookup) { } TEST(EpdFont, GlyphLookup) { - EXPECT_NE(testFont().getGlyph('T'), nullptr); - EXPECT_NE(testFont().getGlyph('a'), nullptr); - EXPECT_NE(testFont().getGlyph('o'), nullptr); - EXPECT_NE(testFont().getGlyph('x'), nullptr); + ASSERT_NE(testFont().getGlyph('T'), nullptr); + ASSERT_NE(testFont().getGlyph('a'), nullptr); + ASSERT_NE(testFont().getGlyph('o'), nullptr); + ASSERT_NE(testFont().getGlyph('x'), nullptr); EXPECT_EQ(testFont().getGlyph('T')->advanceX, 137); EXPECT_EQ(testFont().getGlyph('a')->advanceX, 130); EXPECT_EQ(testFont().getGlyph('o')->advanceX, 145); diff --git a/test/release_json_parser/ReleaseJsonParserTest.cpp b/test/release_json_parser/ReleaseJsonParserTest.cpp index b8ba4c856f..babe5c764b 100644 --- a/test/release_json_parser/ReleaseJsonParserTest.cpp +++ b/test/release_json_parser/ReleaseJsonParserTest.cpp @@ -151,6 +151,11 @@ TEST(ReleaseJsonParser, PrettyAndMinifiedAgree) { ReleaseJsonParser minified; minified.feed(kRealisticMinified, strlen(kRealisticMinified)); + ASSERT_TRUE(pretty.foundTag()); + ASSERT_TRUE(pretty.foundFirmware()); + ASSERT_TRUE(minified.foundTag()); + ASSERT_TRUE(minified.foundFirmware()); + EXPECT_STREQ(pretty.getTagName(), minified.getTagName()); EXPECT_STREQ(pretty.getFirmwareUrl(), minified.getFirmwareUrl()); EXPECT_EQ(pretty.getFirmwareSize(), minified.getFirmwareSize()); @@ -282,6 +287,8 @@ TEST(ReleaseJsonParser, ChunkedFeedingVariousChunkSizes) { EXPECT_TRUE(p.foundTag()) << "chunkSize=" << chunkSize; EXPECT_TRUE(p.foundFirmware()) << "chunkSize=" << chunkSize; EXPECT_STREQ(p.getTagName(), "v2.4.1") << "chunkSize=" << chunkSize; + EXPECT_STREQ(p.getFirmwareUrl(), "https://github.com/znelson/lightpoint/releases/download/v2.4.1/firmware.bin") + << "chunkSize=" << chunkSize; EXPECT_EQ(p.getFirmwareSize(), 1572864u) << "chunkSize=" << chunkSize; } } diff --git a/test/streaming_json_parser/StreamingJsonParserTest.cpp b/test/streaming_json_parser/StreamingJsonParserTest.cpp index d77605d474..0c1aba8083 100644 --- a/test/streaming_json_parser/StreamingJsonParserTest.cpp +++ b/test/streaming_json_parser/StreamingJsonParserTest.cpp @@ -154,7 +154,7 @@ TEST(StreamingJsonParser, UnicodeEscapeAscii) { // \u escapes for ASCII codepoints (1-byte UTF-8) decode to the raw bytes. auto events = parse(R"({"u": "\u0041\u0042"})"); - ASSERT_GE(events.size(), 3u); + ASSERT_EQ(events.size(), 4u); EXPECT_EQ(events[2].type, EventType::STRING); EXPECT_EQ(events[2].value, "AB"); } @@ -163,7 +163,7 @@ TEST(StreamingJsonParser, UnicodeEscapeTwoByteUtf8) { // U+00E9 (e-acute) decodes to the 2-byte UTF-8 sequence C3 A9. auto events = parse(R"({"k": "caf\u00E9"})"); - ASSERT_GE(events.size(), 3u); + ASSERT_EQ(events.size(), 4u); EXPECT_EQ(events[2].type, EventType::STRING); EXPECT_EQ(events[2].value, std::string("caf\xC3\xA9")); } @@ -172,7 +172,7 @@ TEST(StreamingJsonParser, UnicodeEscapeThreeByteUtf8) { // U+20AC (euro sign) decodes to the 3-byte UTF-8 sequence E2 82 AC. auto events = parse(R"({"k": "\u20AC"})"); - ASSERT_GE(events.size(), 3u); + ASSERT_EQ(events.size(), 4u); EXPECT_EQ(events[2].type, EventType::STRING); EXPECT_EQ(events[2].value, std::string("\xE2\x82\xAC")); } @@ -181,7 +181,7 @@ TEST(StreamingJsonParser, UnicodeEscapeMixedHexCase) { // Lower and upper case hex digits must both decode. auto events = parse(R"({"k": "\u00e9\u00E9"})"); - ASSERT_GE(events.size(), 3u); + ASSERT_EQ(events.size(), 4u); EXPECT_EQ(events[2].type, EventType::STRING); EXPECT_EQ(events[2].value, std::string("\xC3\xA9\xC3\xA9")); } @@ -190,7 +190,7 @@ TEST(StreamingJsonParser, UnicodeEscapeSurrogateBecomesReplacement) { // Bare surrogate values (no pair handling) emit U+FFFD (EF BF BD). auto events = parse(R"({"k": "\uD800"})"); - ASSERT_GE(events.size(), 3u); + ASSERT_EQ(events.size(), 4u); EXPECT_EQ(events[2].type, EventType::STRING); EXPECT_EQ(events[2].value, std::string("\xEF\xBF\xBD")); } @@ -200,7 +200,7 @@ TEST(StreamingJsonParser, UnicodeEscapeInvalidHexBecomesReplacement) { // reprocessed as a normal string char so the rest of the string parses. auto events = parse(R"({"k": "\u00ZZok"})"); - ASSERT_GE(events.size(), 3u); + ASSERT_EQ(events.size(), 4u); EXPECT_EQ(events[2].type, EventType::STRING); EXPECT_EQ(events[2].value, std::string("\xEF\xBF\xBD" "ZZok")); @@ -209,7 +209,7 @@ TEST(StreamingJsonParser, UnicodeEscapeInvalidHexBecomesReplacement) { TEST(StreamingJsonParser, UnicodeEscapeMixedWithLiterals) { auto events = parse(R"({"k": "a\u00E9b"})"); - ASSERT_GE(events.size(), 3u); + ASSERT_EQ(events.size(), 4u); EXPECT_EQ(events[2].type, EventType::STRING); EXPECT_EQ(events[2].value, std::string("a\xC3\xA9" "b")); @@ -247,7 +247,7 @@ TEST(StreamingJsonParser, UnicodeEscapeSplitAcrossChunks) { TEST(StreamingJsonParser, Numbers) { auto events = parse(R"({"int": 42, "neg": -7, "flt": 3.14, "exp": 1e10, "nexp": -2.5E-3})"); - ASSERT_GE(events.size(), 11u); + ASSERT_EQ(events.size(), 12u); EXPECT_EQ(events[2].type, EventType::NUMBER); EXPECT_EQ(events[2].value, "42"); EXPECT_EQ(events[4].type, EventType::NUMBER); @@ -263,7 +263,7 @@ TEST(StreamingJsonParser, Numbers) { TEST(StreamingJsonParser, BooleansAndNull) { auto events = parse(R"({"t": true, "f": false, "n": null})"); - ASSERT_GE(events.size(), 7u); + ASSERT_EQ(events.size(), 8u); EXPECT_EQ(events[2].type, EventType::BOOL_TRUE); EXPECT_EQ(events[4].type, EventType::BOOL_FALSE); EXPECT_EQ(events[6].type, EventType::NULL_VAL); @@ -454,7 +454,7 @@ TEST(StreamingJsonParser, TruncatedInputNoCrash) { TEST(StreamingJsonParser, AllEscapeSequences) { auto events = parse(R"({"e": "\b\f\n\r\t\"\\\/"})"); - ASSERT_GE(events.size(), 3u); + ASSERT_EQ(events.size(), 4u); EXPECT_EQ(events[2].type, EventType::STRING); EXPECT_EQ(events[2].value, std::string("\b\f\n\r\t\"\\/")); } @@ -509,7 +509,7 @@ TEST(StreamingJsonParser, NestingOverflow) { TEST(StreamingJsonParser, NumberZero) { auto events = parse(R"({"z": 0})"); - ASSERT_GE(events.size(), 3u); + ASSERT_EQ(events.size(), 4u); EXPECT_EQ(events[2].type, EventType::NUMBER); EXPECT_EQ(events[2].value, "0"); }