From 1c1333118962f456ff56a3c599f52520d2d204d9 Mon Sep 17 00:00:00 2001 From: Zach Nelson Date: Mon, 6 Apr 2026 18:30:24 -0500 Subject: [PATCH 01/37] fix: Support hyphenation for EPUBs using ISO 639-2 language codes (#1461) ## Summary EPUBs that use ISO 639-2 three-letter language codes in their `dc:language` metadata (e.g. `eng`) got no hyphenation. The hyphenator registry only matched ISO 639-1 two-letter codes (`"en"`, `"fr"`, etc.), so `"eng"` produced a null hyphenator and every word in the book was treated as unhyphenatable. Added a normalization step in `hyphenatorForLanguage` that maps ISO 639-2 codes (both bibliographic and terminological variants) to their two-letter equivalents before the registry lookup. Discovered via *Project Hail Mary* (Random House), which uses `eng`. --- ### 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? _**PARTIALLY**_ --- lib/Epub/Epub/Section.cpp | 2 +- lib/Epub/Epub/hyphenation/Hyphenator.cpp | 25 ++++++++++++++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 9365df208..7c0b3c12b 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -10,7 +10,7 @@ #include "parsers/ChapterHtmlSlimParser.h" namespace { -constexpr uint8_t SECTION_FILE_VERSION = 18; +constexpr uint8_t SECTION_FILE_VERSION = 19; constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) + sizeof(uint8_t) + sizeof(uint32_t) + sizeof(uint32_t); diff --git a/lib/Epub/Epub/hyphenation/Hyphenator.cpp b/lib/Epub/Epub/hyphenation/Hyphenator.cpp index 4ae5307ec..ad5de454c 100644 --- a/lib/Epub/Epub/hyphenation/Hyphenator.cpp +++ b/lib/Epub/Epub/hyphenation/Hyphenator.cpp @@ -12,11 +12,24 @@ const LanguageHyphenator* Hyphenator::cachedHyphenator_ = nullptr; namespace { -// Maps a BCP-47 language tag to a language-specific hyphenator. +// Normalize ISO 639-2 (three-letter) codes to ISO 639-1 (two-letter) codes used by the +// hyphenation registry. EPUBs may use either form in their dc:language metadata (e.g. +// "eng" instead of "en"). Both the bibliographic ("fre"/"ger") and terminological +// ("fra"/"deu") ISO 639-2 variants are mapped. +struct Iso639Mapping { + const char* iso639_2; + const char* iso639_1; +}; +static constexpr Iso639Mapping kIso639Mappings[] = { + {"eng", "en"}, {"fra", "fr"}, {"fre", "fr"}, {"deu", "de"}, {"ger", "de"}, + {"rus", "ru"}, {"spa", "es"}, {"ita", "it"}, {"ukr", "uk"}, +}; + +// Maps a BCP-47 or ISO 639-2 language tag to a language-specific hyphenator. const LanguageHyphenator* hyphenatorForLanguage(const std::string& langTag) { if (langTag.empty()) return nullptr; - // Extract primary subtag and normalize to lowercase (e.g., "en-US" -> "en"). + // Extract primary subtag and normalize to lowercase (e.g., "en-US" -> "en", "ENG" -> "en"). std::string primary; primary.reserve(langTag.size()); for (char c : langTag) { @@ -26,6 +39,14 @@ const LanguageHyphenator* hyphenatorForLanguage(const std::string& langTag) { } if (primary.empty()) return nullptr; + // Normalize ISO 639-2 three-letter codes to two-letter equivalents. + for (const auto& mapping : kIso639Mappings) { + if (primary == mapping.iso639_2) { + primary = mapping.iso639_1; + break; + } + } + return getLanguageHyphenatorForPrimaryTag(primary); } From 11984f8fefc308c57a32d580b47d14b9a53e5621 Mon Sep 17 00:00:00 2001 From: Zach Nelson Date: Tue, 7 Apr 2026 08:30:52 -0500 Subject: [PATCH 02/37] refactor: Use C++20 'requires' in ActivityResult constructor (#1420) ## Summary **What is the goal of this PR?** Replace SFINAE std::enable_if_t with a C++20 `requires` clause for clearer constraint expression and better compiler diagnostics on mismatch. --- ### 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? _**NO**_ --- src/activities/ActivityResult.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/activities/ActivityResult.h b/src/activities/ActivityResult.h index 4062fce97..0416285ee 100644 --- a/src/activities/ActivityResult.h +++ b/src/activities/ActivityResult.h @@ -59,7 +59,8 @@ struct ActivityResult { explicit ActivityResult() = default; - template >> + template + requires std::is_constructible_v // cppcheck-suppress noExplicitConstructor ActivityResult(ResultType&& result) : data{std::forward(result)} {} }; From f429f9035c7301636c94b201bf27962d0289fabf Mon Sep 17 00:00:00 2001 From: Zach Nelson Date: Tue, 7 Apr 2026 09:13:21 -0500 Subject: [PATCH 03/37] refactor: Use default member initializers for JpegContext and PngContext (#1435) ## Summary **What is the goal of this PR?** Replace verbose constructor initializer lists with in-class default member initializers in JpegContext and PngContext --- ### 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? _**NO**_ --- .../converters/JpegToFramebufferConverter.cpp | 35 ++++++----------- .../converters/PngToFramebufferConverter.cpp | 38 ++++++------------- 2 files changed, 23 insertions(+), 50 deletions(-) diff --git a/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp b/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp index 83e6b547f..4cf55ae3b 100644 --- a/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp +++ b/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp @@ -19,38 +19,25 @@ namespace { // The draw callback receives this via pDraw->pUser (set by setUserPointer()). // The file I/O callbacks receive the FsFile* via pFile->fHandle (set by jpegOpen()). struct JpegContext { - GfxRenderer* renderer; - const RenderConfig* config; - int screenWidth; - int screenHeight; + GfxRenderer* renderer{nullptr}; + const RenderConfig* config{nullptr}; + int screenWidth{0}; + int screenHeight{0}; // Source dimensions after JPEGDEC's built-in scaling - int scaledSrcWidth; - int scaledSrcHeight; + int scaledSrcWidth{0}; + int scaledSrcHeight{0}; // Final output dimensions - int dstWidth; - int dstHeight; + int dstWidth{0}; + int dstHeight{0}; // Fine scale in 16.16 fixed-point (ESP32-C3 has no FPU) - int32_t fineScaleFP; // src -> dst mapping - int32_t invScaleFP; // dst -> src mapping + int32_t fineScaleFP{1 << 16}; // src -> dst mapping + int32_t invScaleFP{1 << 16}; // dst -> src mapping PixelCache cache; - bool caching; - - JpegContext() - : renderer(nullptr), - config(nullptr), - screenWidth(0), - screenHeight(0), - scaledSrcWidth(0), - scaledSrcHeight(0), - dstWidth(0), - dstHeight(0), - fineScaleFP(1 << 16), - invScaleFP(1 << 16), - caching(false) {} + bool caching{false}; }; // File I/O callbacks use pFile->fHandle to access the FsFile*, diff --git a/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp b/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp index c80cb23a6..0cc1616ab 100644 --- a/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp +++ b/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp @@ -19,37 +19,23 @@ namespace { // The draw callback receives this via pDraw->pUser (set by png.decode()). // The file I/O callbacks receive the FsFile* via pFile->fHandle (set by pngOpen()). struct PngContext { - GfxRenderer* renderer; - const RenderConfig* config; - int screenWidth; - int screenHeight; + GfxRenderer* renderer{nullptr}; + const RenderConfig* config{nullptr}; + int screenWidth{0}; + int screenHeight{0}; // Scaling state - float scale; - int srcWidth; - int srcHeight; - int dstWidth; - int dstHeight; - int lastDstY; // Track last rendered destination Y to avoid duplicates + float scale{1.f}; + int srcWidth{0}; + int srcHeight{0}; + int dstWidth{0}; + int dstHeight{0}; + int lastDstY{-1}; // Track last rendered destination Y to avoid duplicates PixelCache cache; - bool caching; + bool caching{false}; - uint8_t* grayLineBuffer; - - PngContext() - : renderer(nullptr), - config(nullptr), - screenWidth(0), - screenHeight(0), - scale(1.0f), - srcWidth(0), - srcHeight(0), - dstWidth(0), - dstHeight(0), - lastDstY(-1), - caching(false), - grayLineBuffer(nullptr) {} + uint8_t* grayLineBuffer{nullptr}; }; // File I/O callbacks use pFile->fHandle to access the FsFile*, From fa3c7d96a0020687e1e22dd720e285402987d99b Mon Sep 17 00:00:00 2001 From: Andrei Ignatev <64772919+a-ignatev@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:21:12 +0200 Subject: [PATCH 04/37] fix: correct Russian auto-turn translations (#1566) ## Summary * **What is the goal of this PR?** Fix inaccurate Russian UI text for the reader auto-turn feature and make the affected Russian labels consistent with how adjacent UI strings are formatted. * **What changes are included?** Updated Russian translations in `lib/I18n/translations/russian.yaml`: - changed `Auto Turn` text from wording that implied screen rotation to wording that means automatic page turning - adjusted a few Russian prefix/separator strings to include spacing where the UI concatenates labels with dynamic values ## Screenshots | Before| After | |--------|--------| | image| image| --- ### 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? _**NO**_ --- lib/I18n/translations/russian.yaml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index 82a1c188a..b1354ab67 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -175,8 +175,8 @@ STR_UNNAMED: "Без имени" STR_NO_SERVER_URL: "URL сервера не настроен" STR_FETCH_FEED_FAILED: "Не удалось получить ленту" STR_PARSE_FEED_FAILED: "Не удалось обработать ленту" -STR_NETWORK_PREFIX: "Сеть:" -STR_IP_ADDRESS_PREFIX: "IP-адрес:" +STR_NETWORK_PREFIX: "Сеть: " +STR_IP_ADDRESS_PREFIX: "IP-адрес: " STR_ERROR_GENERAL_FAILURE: "Ошибка: Общая ошибка" STR_ERROR_NETWORK_NOT_FOUND: "Ошибка: Сеть не найдена" STR_ERROR_CONNECTION_TIMEOUT: "Ошибка: Тайм-аут соединения" @@ -257,10 +257,10 @@ STR_GO_HOME_BUTTON: "На главную" STR_SYNC_PROGRESS: "Синхронизировать прогресс" STR_DELETE_CACHE: "Удалить кэш книги" STR_DELETE: "Удалить" -STR_CHAPTER_PREFIX: "Глава:" +STR_CHAPTER_PREFIX: "Глава: " STR_DISPLAY_QR: "Показать страницу в виде QR-кода" -STR_PAGES_SEPARATOR: "стр. |" -STR_BOOK_PREFIX: "Книга:" +STR_PAGES_SEPARATOR: " стр. | " +STR_BOOK_PREFIX: "Книга: " STR_CALIBRE_URL_HINT: "Для Calibre добавьте /opds к URL" STR_PERCENT_STEP_HINT: "Влево/Вправо: 1% Вверх/Вниз: 10%" STR_SYNCING_TIME: "Синхронизация времени..." @@ -282,11 +282,11 @@ STR_NO_REMOTE_MSG: "Удалённый прогресс не найден" STR_UPLOAD_PROMPT: "Отправить текущую позицию?" STR_UPLOAD_SUCCESS: "Прогресс отправлен!" STR_SYNC_FAILED_MSG: "Ошибка синхронизации" -STR_SECTION_PREFIX: "Раздел" +STR_SECTION_PREFIX: "Раздел " STR_UPLOAD: "Отправить" STR_BOOK_S_STYLE: "Стиль книги" STR_EMBEDDED_STYLE: "Встроенный стиль" STR_OPDS_SERVER_URL: "URL OPDS сервера" STR_SCREENSHOT_BUTTON: "Сделать снимок экрана" -STR_AUTO_TURN_ENABLED: "Авто-поворот включён: " -STR_AUTO_TURN_PAGES_PER_MIN: "Авто-поворот (Страниц в минуту)" +STR_AUTO_TURN_ENABLED: "Автоперелистывание: " +STR_AUTO_TURN_PAGES_PER_MIN: "Автоперелистывание (стр./мин)" From cff3e12a0ac2bd496cc23588e9fc0b49edfd98d0 Mon Sep 17 00:00:00 2001 From: Mirus Date: Tue, 7 Apr 2026 17:21:32 +0300 Subject: [PATCH 05/37] fix: Update Ukrainian translations for footnotes (issue 1409) (#1585) ## Summary * **What is the goal of this PR?** Solve the issue https://github.com/crosspoint-reader/crosspoint-reader/issues/1409 * **What changes are included?** Updated translation for footnotes ## Additional Context * Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on). --- ### AI Usage Did you use AI tools to help write this code? _**NO**_ --- lib/I18n/translations/ukrainian.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/I18n/translations/ukrainian.yaml b/lib/I18n/translations/ukrainian.yaml index 9eefa0222..c6a03112a 100644 --- a/lib/I18n/translations/ukrainian.yaml +++ b/lib/I18n/translations/ukrainian.yaml @@ -284,8 +284,8 @@ STR_UPLOAD: "Завантажити" STR_BOOK_S_STYLE: "Стиль книги" STR_EMBEDDED_STYLE: "Вбудований стиль" STR_OPDS_SERVER_URL: "URL сервера OPDS" -STR_FOOTNOTES: "Зноски" -STR_NO_FOOTNOTES: "На цій сторінці немає зносок" +STR_FOOTNOTES: "Примітки" +STR_NO_FOOTNOTES: "На цій сторінці немає приміток" STR_LINK: "[посилання]" STR_SCREENSHOT_BUTTON: "Знімок екрана" STR_AUTO_TURN_ENABLED: "Автоперегортання увімк: " From 6cd19f561905e199fb5d630b9381d601b9babc56 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Tue, 7 Apr 2026 19:16:59 -0500 Subject: [PATCH 06/37] fix: epub images not rendering correctly on x3 (#1572) Replace hardcoded DISPLAY_WIDTH, DISPLAY_HEIGHT, and DISPLAY_WIDTH_BYTES constants with runtime values from display object to support multiple device models (X3 and X4) with different screen dimensions. --- lib/Epub/Epub/converters/DirectPixelWriter.h | 15 ++++++++++----- src/util/ScreenshotUtil.cpp | 10 +++++----- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/lib/Epub/Epub/converters/DirectPixelWriter.h b/lib/Epub/Epub/converters/DirectPixelWriter.h index 3c742871d..1154b49fb 100644 --- a/lib/Epub/Epub/converters/DirectPixelWriter.h +++ b/lib/Epub/Epub/converters/DirectPixelWriter.h @@ -15,6 +15,7 @@ struct DirectPixelWriter { uint8_t* fb; GfxRenderer::RenderMode mode; + uint16_t displayWidthBytes; // Runtime framebuffer stride (X4: 100, X3: 99) // Orientation is collapsed into a linear transform: // phyX = phyXBase + x * phyXStepX + y * phyXStepY @@ -29,12 +30,16 @@ struct DirectPixelWriter { void init(GfxRenderer& renderer) { fb = renderer.getFrameBuffer(); mode = renderer.getRenderMode(); + displayWidthBytes = display.getDisplayWidthBytes(); + + const int phyW = display.getDisplayWidth(); + const int phyH = display.getDisplayHeight(); switch (renderer.getOrientation()) { case GfxRenderer::Portrait: // phyX = y, phyY = (DISPLAY_HEIGHT-1) - x phyXBase = 0; - phyYBase = HalDisplay::DISPLAY_HEIGHT - 1; + phyYBase = phyH - 1; phyXStepX = 0; phyYStepX = -1; phyXStepY = 1; @@ -42,8 +47,8 @@ struct DirectPixelWriter { break; case GfxRenderer::LandscapeClockwise: // phyX = (DISPLAY_WIDTH-1) - x, phyY = (DISPLAY_HEIGHT-1) - y - phyXBase = HalDisplay::DISPLAY_WIDTH - 1; - phyYBase = HalDisplay::DISPLAY_HEIGHT - 1; + phyXBase = phyW - 1; + phyYBase = phyH - 1; phyXStepX = -1; phyYStepX = 0; phyXStepY = 0; @@ -51,7 +56,7 @@ struct DirectPixelWriter { break; case GfxRenderer::PortraitInverted: // phyX = (DISPLAY_WIDTH-1) - y, phyY = x - phyXBase = HalDisplay::DISPLAY_WIDTH - 1; + phyXBase = phyW - 1; phyYBase = 0; phyXStepX = 0; phyYStepX = 1; @@ -115,7 +120,7 @@ struct DirectPixelWriter { const int phyX = rowPhyXBase + logicalX * phyXStepX; const int phyY = rowPhyYBase + logicalX * phyYStepX; - const uint16_t byteIndex = phyY * HalDisplay::DISPLAY_WIDTH_BYTES + (phyX >> 3); + const uint16_t byteIndex = phyY * displayWidthBytes + (phyX >> 3); const uint8_t bitMask = 1 << (7 - (phyX & 7)); if (state) { diff --git a/src/util/ScreenshotUtil.cpp b/src/util/ScreenshotUtil.cpp index 6713bd305..41f075447 100644 --- a/src/util/ScreenshotUtil.cpp +++ b/src/util/ScreenshotUtil.cpp @@ -14,8 +14,8 @@ void ScreenshotUtil::takeScreenshot(GfxRenderer& renderer) { const uint8_t* fb = renderer.getFrameBuffer(); if (fb) { String filename_str = "/screenshots/screenshot-" + String(millis()) + ".bmp"; - if (ScreenshotUtil::saveFramebufferAsBmp(filename_str.c_str(), fb, HalDisplay::DISPLAY_WIDTH, - HalDisplay::DISPLAY_HEIGHT)) { + if (ScreenshotUtil::saveFramebufferAsBmp(filename_str.c_str(), fb, display.getDisplayWidth(), + display.getDisplayHeight())) { LOG_DBG("SCR", "Screenshot saved to %s", filename_str.c_str()); } else { LOG_ERR("SCR", "Failed to save screenshot"); @@ -26,7 +26,7 @@ void ScreenshotUtil::takeScreenshot(GfxRenderer& renderer) { // Display a border around the screen to indicate a screenshot was taken if (renderer.storeBwBuffer()) { - renderer.drawRect(6, 6, HalDisplay::DISPLAY_HEIGHT - 12, HalDisplay::DISPLAY_WIDTH - 12, 2, true); + renderer.drawRect(6, 6, display.getDisplayHeight() - 12, display.getDisplayWidth() - 12, 2, true); renderer.displayBuffer(); delay(1000); renderer.restoreBwBuffer(); @@ -76,8 +76,8 @@ bool ScreenshotUtil::saveFramebufferAsBmp(const char* filename, const uint8_t* f } const uint32_t rowSizePadded = (phyWidth + 31) / 32 * 4; - // Max row size for 480px width = 60 bytes; use fixed buffer to avoid VLA - constexpr size_t kMaxRowSize = 64; + // Max row size for 528px width (X3) = 68 bytes; use fixed buffer to avoid VLA + constexpr size_t kMaxRowSize = 68; if (rowSizePadded > kMaxRowSize) { LOG_ERR("SCR", "Row size %u exceeds buffer capacity", rowSizePadded); file.close(); From 1398aeb1edb3a2819e90d52bd96f339c8e073491 Mon Sep 17 00:00:00 2001 From: Zach Nelson Date: Tue, 7 Apr 2026 22:20:50 -0500 Subject: [PATCH 07/37] fix: Use differential rounding for consistent inter-glyph spacing (#1413) ## Summary **What is the goal of this PR?** A tweak to the fixed-point x-advance and kerning calculations to ensure that the spacing between any two glyphs is always calculated consistently. I noticed that sometimes I'd see common character pairs like "oo" more than once on a page, and the distance between the two snapped to different pixels depending on the running accumulated error for the line of text. This change uses a differential rounding approach where each glyph's x-advance plus the kerning relative to the next glyph are combined in fixed-point precision, then snapped to a pixel to draw the next glyph. This results in a consistent inter-glyph spacing any time the same two glyphs show up adjacent to each other, regardless of the accumulated error across the line. --- ### 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? _**PARTIALLY**_ --- lib/EpdFont/EpdFont.cpp | 13 +- lib/EpdFont/EpdFontData.h | 10 +- lib/GfxRenderer/GfxRenderer.cpp | 45 ++- .../DifferentialRoundingTest.cpp | 381 ++++++++++++++++++ test/run_differential_rounding_test.sh | 30 ++ 5 files changed, 452 insertions(+), 27 deletions(-) create mode 100644 test/differential_rounding/DifferentialRoundingTest.cpp create mode 100755 test/run_differential_rounding_test.sh diff --git a/lib/EpdFont/EpdFont.cpp b/lib/EpdFont/EpdFont.cpp index c80a8573a..bfe746eff 100644 --- a/lib/EpdFont/EpdFont.cpp +++ b/lib/EpdFont/EpdFont.cpp @@ -15,10 +15,10 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star return; } - int32_t cursorXFP = fp4::fromPixel(startX); // 12.4 fixed-point accumulator int lastBaseX = startX; int lastBaseAdvanceFP = 0; // 12.4 fixed-point int lastBaseTop = 0; + int32_t prevAdvanceFP = 0; // 12.4 fixed-point: prev glyph's advance + next kern for snap constexpr int MIN_COMBINING_GAP_PX = 1; uint32_t cp; uint32_t prevCp = 0; @@ -31,7 +31,9 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star const EpdGlyph* glyph = getGlyph(cp); if (!glyph) { + lastBaseX += fp4::toPixel(prevAdvanceFP); // flush pending advance before resetting prevCp = 0; + prevAdvanceFP = 0; continue; } @@ -44,11 +46,11 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star } if (!isCombining && prevCp != 0) { - cursorXFP += getKerning(prevCp, cp); // 4.4 fixed-point kern + const auto kernFP = getKerning(prevCp, cp); // 4.4 fixed-point kern + lastBaseX += fp4::toPixel(prevAdvanceFP + kernFP); } - const int cursorXPixels = fp4::toPixel(cursorXFP); // snap 12.4 fixed-point to nearest pixel - const int glyphBaseX = isCombining ? (lastBaseX + fp4::toPixel(lastBaseAdvanceFP / 2)) : cursorXPixels; + const int glyphBaseX = isCombining ? (lastBaseX + fp4::toPixel(lastBaseAdvanceFP / 2)) : lastBaseX; const int glyphBaseY = startY - raiseBy; *minX = std::min(*minX, glyphBaseX + glyph->left); @@ -57,10 +59,9 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star *maxY = std::max(*maxY, glyphBaseY + glyph->top); if (!isCombining) { - lastBaseX = cursorXPixels; lastBaseAdvanceFP = glyph->advanceX; // 12.4 fixed-point lastBaseTop = glyph->top; - cursorXFP += glyph->advanceX; // 12.4 fixed-point advance + prevAdvanceFP = lastBaseAdvanceFP; prevCp = cp; } } diff --git a/lib/EpdFont/EpdFontData.h b/lib/EpdFont/EpdFontData.h index 9f3b691e1..0b70e57fd 100644 --- a/lib/EpdFont/EpdFontData.h +++ b/lib/EpdFont/EpdFontData.h @@ -7,10 +7,12 @@ /// Font metrics use "fixed-point 4" (4 fractional bits, i.e. 1/16-pixel /// resolution). Both the 12.4 glyph advances (uint16_t) and the 4.4 kern /// values (int8_t) share the same 4 fractional bits, so they can be freely -/// added into a single int32_t accumulator during text layout. The -/// accumulator is snapped to the nearest whole pixel only at render time, -/// which avoids the per-character rounding errors that plagued integer-only -/// layout. +/// added before snapping to whole pixels. +/// +/// Rendering and measurement use "differential rounding": each glyph step +/// (previous advance + current kern) is combined in fixed-point and snapped +/// to a pixel as one unit. This guarantees identical character pairs always +/// produce the same pixel spacing, regardless of position on the line. /// /// The helpers below eliminate the raw bit-shifts that would otherwise be /// scattered across every layout / measurement call site. diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 50aa2db14..1e7bc7124 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -215,10 +215,10 @@ void GfxRenderer::drawCenteredText(const int fontId, const int y, const char* te void GfxRenderer::drawText(const int fontId, const int x, const int y, const char* text, const bool black, const EpdFontFamily::Style style) const { const int yPos = y + getFontAscenderSize(fontId); - int32_t xPosFP = fp4::fromPixel(x); // 12.4 fixed-point accumulator int lastBaseX = x; int lastBaseAdvanceFP = 0; // 12.4 fixed-point int lastBaseTop = 0; + int32_t prevAdvanceFP = 0; // 12.4 fixed-point: prev glyph's advance + next kern for snap // cannot draw a NULL / empty string if (text == nullptr || *text == '\0') { @@ -258,19 +258,22 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha } cp = font.applyLigatures(cp, text, style); - const int kernFP = (prevCp != 0) ? font.getKerning(prevCp, cp, style) : 0; // 4.4 fixed-point kern - xPosFP += kernFP; - lastBaseX = fp4::toPixel(xPosFP); // snap 12.4 fixed-point to nearest pixel + // Differential rounding: snap (previous advance + current kern) as one unit so + // identical character pairs always produce the same pixel step regardless of + // where they fall on the line. + if (prevCp != 0) { + const auto kernFP = font.getKerning(prevCp, cp, style); // 4.4 fixed-point kern + lastBaseX += fp4::toPixel(prevAdvanceFP + kernFP); // snap 12.4 fixed-point to nearest pixel + } + const EpdGlyph* glyph = font.getGlyph(cp, style); lastBaseAdvanceFP = glyph ? glyph->advanceX : 0; lastBaseTop = glyph ? glyph->top : 0; + prevAdvanceFP = lastBaseAdvanceFP; renderCharImpl(*this, renderMode, font, cp, lastBaseX, yPos, black, style); - if (glyph) { - xPosFP += glyph->advanceX; // 12.4 fixed-point advance - } prevCp = cp; } } @@ -1007,21 +1010,28 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFami uint32_t cp; uint32_t prevCp = 0; - int32_t widthFP = 0; // 12.4 fixed-point accumulator + int widthPx = 0; + int32_t prevAdvanceFP = 0; // 12.4 fixed-point: prev glyph's advance + next kern for snap const auto& font = fontIt->second; while ((cp = utf8NextCodepoint(reinterpret_cast(&text)))) { if (utf8IsCombiningMark(cp)) { continue; } cp = font.applyLigatures(cp, text, style); + + // Differential rounding: snap (previous advance + current kern) together, + // matching drawText so measurement and rendering agree exactly. if (prevCp != 0) { - widthFP += font.getKerning(prevCp, cp, style); // 4.4 fixed-point kern + const auto kernFP = font.getKerning(prevCp, cp, style); // 4.4 fixed-point kern + widthPx += fp4::toPixel(prevAdvanceFP + kernFP); // snap 12.4 fixed-point to nearest pixel } + const EpdGlyph* glyph = font.getGlyph(cp, style); - if (glyph) widthFP += glyph->advanceX; // 12.4 fixed-point advance + prevAdvanceFP = glyph ? glyph->advanceX : 0; prevCp = cp; } - return fp4::toPixel(widthFP); // snap 12.4 fixed-point to nearest pixel + widthPx += fp4::toPixel(prevAdvanceFP); // final glyph's advance + return widthPx; } int GfxRenderer::getFontAscenderSize(const int fontId) const { @@ -1068,10 +1078,10 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y const auto& font = fontIt->second; - int32_t yPosFP = fp4::fromPixel(y); // 12.4 fixed-point accumulator int lastBaseY = y; int lastBaseAdvanceFP = 0; // 12.4 fixed-point int lastBaseTop = 0; + int32_t prevAdvanceFP = 0; // 12.4 fixed-point: prev glyph's advance + next kern for snap constexpr int MIN_COMBINING_GAP_PX = 1; uint32_t cp; @@ -1094,20 +1104,21 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y } cp = font.applyLigatures(cp, text, style); + + // Differential rounding: snap (previous advance + current kern) as one unit, + // subtracting for the rotated coordinate direction. if (prevCp != 0) { - yPosFP -= font.getKerning(prevCp, cp, style); // 4.4 fixed-point kern (subtract for rotated) + const auto kernFP = font.getKerning(prevCp, cp, style); // 4.4 fixed-point kern + lastBaseY -= fp4::toPixel(prevAdvanceFP + kernFP); // snap 12.4 fixed-point to nearest pixel } - lastBaseY = fp4::toPixel(yPosFP); // snap 12.4 fixed-point to nearest pixel const EpdGlyph* glyph = font.getGlyph(cp, style); lastBaseAdvanceFP = glyph ? glyph->advanceX : 0; // 12.4 fixed-point lastBaseTop = glyph ? glyph->top : 0; + prevAdvanceFP = lastBaseAdvanceFP; renderCharImpl(*this, renderMode, font, cp, x, lastBaseY, black, style); - if (glyph) { - yPosFP -= glyph->advanceX; // 12.4 fixed-point advance (subtract for rotated) - } prevCp = cp; } } diff --git a/test/differential_rounding/DifferentialRoundingTest.cpp b/test/differential_rounding/DifferentialRoundingTest.cpp new file mode 100644 index 000000000..fd4548399 --- /dev/null +++ b/test/differential_rounding/DifferentialRoundingTest.cpp @@ -0,0 +1,381 @@ +#include +#include +#include +#include + +#include "lib/EpdFont/EpdFont.h" +#include "lib/EpdFont/EpdFontData.h" + +static int testsPassed = 0; +static int testsFailed = 0; + +#define ASSERT_EQ(a, b) \ + do { \ + if ((a) != (b)) { \ + fprintf(stderr, " FAIL: %s:%d: %s == %d, expected %d\n", __FILE__, __LINE__, #a, (a), (b)); \ + testsFailed++; \ + return; \ + } \ + } while (0) + +#define ASSERT_TRUE(cond) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, " FAIL: %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + testsFailed++; \ + return; \ + } \ + } while (0) + +#define PASS() testsPassed++ + +// ============================================================================ +// Synthetic test font +// +// Glyphs: 'T' (0x54), 'a' (0x61), 'o' (0x6F), 'x' (0x78) +// - 'x' advance is 136 FP (8.5px) -- frac = 8, exactly at the rounding +// boundary where absolute vs differential snapping diverges for "oo". +// - No U+FFFD replacement glyph, so unknown codepoints trigger the +// null-glyph path in getTextBounds. +// +// Kern pairs (4.4 fixed-point): +// T->a: -5 (-0.3125px) T->o: -7 (-0.4375px) +// o->a: -2 (-0.125px) o->o: -3 (-0.1875px) +// ============================================================================ + +// clang-format off +static const EpdGlyph kGlyphs[] = { + // idx width height advanceX left top dataLength dataOffset + /* 0 'T' */ { 8, 12, 137, 0, 12, 0, 0 }, + /* 1 'a' */ { 7, 8, 130, 0, 8, 0, 0 }, + /* 2 'o' */ { 8, 8, 145, 0, 8, 0, 0 }, + /* 3 'x' */ { 7, 8, 136, 0, 8, 0, 0 }, +}; + +static const EpdUnicodeInterval kIntervals[] = { + { 0x54, 0x54, 0 }, // 'T' -> glyph[0] + { 0x61, 0x61, 1 }, // 'a' -> glyph[1] + { 0x6F, 0x6F, 2 }, // 'o' -> glyph[2] + { 0x78, 0x78, 3 }, // 'x' -> glyph[3] +}; + +static const EpdKernClassEntry kKernLeft[] = { + { 0x54, 1 }, // 'T' -> left class 1 + { 0x6F, 2 }, // 'o' -> left class 2 +}; + +static const EpdKernClassEntry kKernRight[] = { + { 0x61, 1 }, // 'a' -> right class 1 + { 0x6F, 2 }, // 'o' -> right class 2 +}; + +// Flat matrix: leftClassCount(2) x rightClassCount(2), 4.4 fixed-point +// [L1,R1]=kern(T,a) [L1,R2]=kern(T,o) [L2,R1]=kern(o,a) [L2,R2]=kern(o,o) +static const int8_t kKernMatrix[] = { -5, -7, -2, -3 }; + +static const EpdFontData kTestFontData = { + .bitmap = nullptr, + .glyph = kGlyphs, + .intervals = kIntervals, + .intervalCount = 4, + .advanceY = 16, + .ascender = 12, + .descender = 0, + .is2Bit = false, + .groups = nullptr, + .groupCount = 0, + .glyphToGroup = nullptr, + .kernLeftClasses = kKernLeft, + .kernRightClasses = kKernRight, + .kernMatrix = kKernMatrix, + .kernLeftEntryCount = 2, + .kernRightEntryCount = 2, + .kernLeftClassCount = 2, + .kernRightClassCount = 2, + .ligaturePairs = nullptr, + .ligaturePairCount = 0, +}; +// clang-format on + +static EpdFont testFont(&kTestFontData); + +// Helper: return width from getTextDimensions +static int textWidth(const char* str) { + int w = 0, h = 0; + testFont.getTextDimensions(str, &w, &h); + return w; +} + +static int textHeight(const char* str) { + int w = 0, h = 0; + testFont.getTextDimensions(str, &w, &h); + return h; +} + +// ============================================================================ +// Part 1: Pure fp4 math tests +// ============================================================================ + +// Simulate the old absolute-snap gap for comparison +static int absoluteGap(int32_t startFP, int32_t advanceFP, int32_t kernFP) { + int32_t nextFP = startFP + advanceFP + kernFP; + return fp4::toPixel(nextFP) - fp4::toPixel(startFP); +} + +void testFp4Basics() { + printf("testFp4Basics...\n"); + + for (int px = 0; px < 500; px++) { + ASSERT_EQ(fp4::toPixel(fp4::fromPixel(px)), px); + } + + ASSERT_EQ(fp4::toPixel(0), 0); + ASSERT_EQ(fp4::toPixel(7), 0); // 0.4375 -> 0 + ASSERT_EQ(fp4::toPixel(8), 1); // 0.5 -> 1 (round half up) + ASSERT_EQ(fp4::toPixel(15), 1); // 0.9375 -> 1 + ASSERT_EQ(fp4::toPixel(16), 1); // 1.0 -> 1 + ASSERT_EQ(fp4::toPixel(24), 2); // 1.5 -> 2 + ASSERT_EQ(fp4::toPixel(-8), 0); // -0.5 -> 0 + ASSERT_EQ(fp4::toPixel(-9), -1); // -0.5625 -> -1 + ASSERT_EQ(fp4::toPixel(-16), -1); + + ASSERT_EQ(fp4::toPixel(137 + (-9)), 8); // 128 = 8.0 exact + ASSERT_EQ(fp4::toPixel(137 + (-5)), 8); // 132 = 8.25 + ASSERT_EQ(fp4::toPixel(137 + (-1)), 9); // 136 = 8.5 (half rounds up) + + printf(" All fp4 basics passed\n"); + PASS(); +} + +void testOldApproachInconsistency() { + printf("testOldApproachInconsistency...\n"); + + // 'oo' pair: advance=145 (9.0625px), kern=-3 (-0.1875px), combined=142 (8.875px) + const int32_t advance = 145; + const int32_t kern = -3; + + int minGap = 999, maxGap = -999; + for (int startPx = 0; startPx < 100; startPx++) { + for (int frac = 0; frac < 16; frac++) { + int32_t startFP = fp4::fromPixel(startPx) + frac; + int gap = absoluteGap(startFP, advance, kern); + if (gap < minGap) minGap = gap; + if (gap > maxGap) maxGap = gap; + } + } + + ASSERT_TRUE(maxGap - minGap >= 1); + printf(" Old absolute gap range: [%d, %d] -- varies by %d px\n", minGap, maxGap, maxGap - minGap); + + int diffStep = fp4::toPixel(advance + kern); + printf(" Differential step: always %d px\n", diffStep); + PASS(); +} + +void testExhaustiveKernRange() { + printf("testExhaustiveKernRange...\n"); + + const int32_t baseAdvance = 128; + int checked = 0; + + for (int advFrac = 0; advFrac < 16; advFrac++) { + int32_t advance = baseAdvance + advFrac; + for (int kern = -128; kern <= 127; kern++) { + int step = fp4::toPixel(advance + static_cast(kern)); + float idealPx = fp4::toFloat(advance + kern); + if (std::abs(step - idealPx) >= 1.0f) { + fprintf(stderr, " FAIL: advance=%d, kern=%d, step=%d, ideal=%.4f\n", advance, kern, step, idealPx); + testsFailed++; + return; + } + checked++; + } + } + + printf(" Checked %d (advance, kern) combinations -- all within 1px of ideal\n", checked); + PASS(); +} + +// ============================================================================ +// Part 2: Integration tests using real EpdFont::getTextDimensions +// ============================================================================ + +void testKernLookup() { + printf("testKernLookup...\n"); + + ASSERT_EQ(testFont.getKerning('T', 'a'), -5); + ASSERT_EQ(testFont.getKerning('T', 'o'), -7); + ASSERT_EQ(testFont.getKerning('o', 'a'), -2); + ASSERT_EQ(testFont.getKerning('o', 'o'), -3); + ASSERT_EQ(testFont.getKerning('a', 'o'), 0); // 'a' has no left class + ASSERT_EQ(testFont.getKerning('x', 'o'), 0); // 'x' has no left class + ASSERT_EQ(testFont.getKerning('T', 'x'), 0); // 'x' has no right class + ASSERT_EQ(testFont.getKerning('T', 'T'), 0); // 'T' has no right class + + printf(" All kern lookups correct\n"); + PASS(); +} + +void testGlyphLookup() { + printf("testGlyphLookup...\n"); + + ASSERT_TRUE(testFont.getGlyph('T') != nullptr); + ASSERT_TRUE(testFont.getGlyph('a') != nullptr); + ASSERT_TRUE(testFont.getGlyph('o') != nullptr); + ASSERT_TRUE(testFont.getGlyph('x') != nullptr); + ASSERT_EQ(testFont.getGlyph('T')->advanceX, 137); + ASSERT_EQ(testFont.getGlyph('a')->advanceX, 130); + ASSERT_EQ(testFont.getGlyph('o')->advanceX, 145); + ASSERT_EQ(testFont.getGlyph('x')->advanceX, 136); + + // No U+FFFD in font, so unknown codepoints return nullptr + ASSERT_TRUE(testFont.getGlyph('Z') == nullptr); + ASSERT_TRUE(testFont.getGlyph('b') == nullptr); + + printf(" All glyph lookups correct\n"); + PASS(); +} + +// Known-value regression tests. Expected widths are computed by hand using +// differential rounding. If someone reverts to absolute snapping, specific +// test cases will fail. +// +// Layout trace for each string (all glyphs have left=0): +// width = max glyph right edge = lastBaseX + glyph.width +// +// Differential step from glyph A to glyph B: +// step = fp4::toPixel(advanceA + kern(A,B)) +void testKnownWidths() { + printf("testKnownWidths...\n"); + + // "o": single glyph at x=0, width=8 + // w = 0 + 8 = 8 + ASSERT_EQ(textWidth("o"), 8); + + // "oo": step = toPixel(145 + (-3)) = toPixel(142) = 9 + // o1 at 0, o2 at 9. w = 9 + 8 = 17 + ASSERT_EQ(textWidth("oo"), 17); + + // "ooo": two steps of 9 + // o1 at 0, o2 at 9, o3 at 18. w = 18 + 8 = 26 + ASSERT_EQ(textWidth("ooo"), 26); + + // "To": step = toPixel(137 + (-7)) = toPixel(130) = 8 + // T at 0, o at 8. w = 8 + 8 = 16 + ASSERT_EQ(textWidth("To"), 16); + + // "Ta": step = toPixel(137 + (-5)) = toPixel(132) = 8 + // T at 0, a at 8. w = 8 + 7 = 15 + ASSERT_EQ(textWidth("Ta"), 15); + + // "oa": step = toPixel(145 + (-2)) = toPixel(143) = 9 + // o at 0, a at 9. w = 9 + 7 = 16 + ASSERT_EQ(textWidth("oa"), 16); + + // "Too": T at 0. + // step T->o = toPixel(137 + (-7)) = 8. o1 at 8. + // step o->o = toPixel(145 + (-3)) = 9. o2 at 17. + // w = 17 + 8 = 25 + ASSERT_EQ(textWidth("Too"), 25); + + // "xo": step = toPixel(136 + 0) = toPixel(136) = 9 (no kern: x has no left class) + // x at 0, o at 9. w = 9 + 8 = 17 + ASSERT_EQ(textWidth("xo"), 17); + + printf(" All known widths correct\n"); + PASS(); +} + +// "oo" pair consistency: the pixel gap between two o's must be the same +// regardless of what prefix precedes them. This is THE key property of +// differential rounding. With absolute snapping, "xoo" would produce a +// different oo gap than "oo" because 'x' advance (136 FP) puts the first +// 'o' at fractional phase 8, crossing the rounding boundary differently. +void testPairConsistencyViaFont() { + printf("testPairConsistencyViaFont...\n"); + + // The oo gap = width(prefix + "oo") - width(prefix + "o") + // This isolates the pixel distance contributed by the second 'o'. + const int oo_gap_bare = textWidth("oo") - textWidth("o"); + const int oo_gap_after_x = textWidth("xoo") - textWidth("xo"); + const int oo_gap_after_T = textWidth("Too") - textWidth("To"); + const int oo_gap_after_o = textWidth("ooo") - textWidth("oo"); + + printf(" oo gap (bare): %d\n", oo_gap_bare); + printf(" oo gap (after x): %d\n", oo_gap_after_x); + printf(" oo gap (after T): %d\n", oo_gap_after_T); + printf(" oo gap (after o): %d\n", oo_gap_after_o); + + // All must be identical + ASSERT_EQ(oo_gap_after_x, oo_gap_bare); + ASSERT_EQ(oo_gap_after_T, oo_gap_bare); + ASSERT_EQ(oo_gap_after_o, oo_gap_bare); + + printf(" All oo gaps identical (%d px) regardless of prefix\n", oo_gap_bare); + PASS(); +} + +// Null-glyph handling: when a codepoint has no glyph (and no replacement +// glyph), the pending advance from the previous glyph must still be flushed. +// Without the flush fix, the glyph after the null would overlap the one before. +void testNullGlyphAdvancePreserved() { + printf("testNullGlyphAdvancePreserved...\n"); + + // 'Z' (0x5A) is not in our font and there's no U+FFFD, so getGlyph returns null. + // "oZo" should lay out as: o1 at 0, Z skipped (advance flushed), o2 at 9. + // toPixel(145) = 9 (o's advance, no kern since Z resets prevCp). + // w = 9 + 8 = 17 + int w = textWidth("oZo"); + printf(" width(\"oZo\") = %d\n", w); + + // Without the flush fix, o2 would land at 0 (overlapping o1), giving w = 8. + ASSERT_TRUE(w > 8); + ASSERT_EQ(w, 17); + + // Multi-null: "oZZo" -- two consecutive nulls, advance still preserved. + w = textWidth("oZZo"); + printf(" width(\"oZZo\") = %d\n", w); + ASSERT_EQ(w, 17); + + // Null at start: "Zo" -- no pending advance to flush, o renders at 0. + w = textWidth("Zo"); + printf(" width(\"Zo\") = %d\n", w); + ASSERT_EQ(w, 8); + + printf(" Null-glyph advance correctly preserved\n"); + PASS(); +} + +void testHeightCalculation() { + printf("testHeightCalculation...\n"); + + // 'T' is tallest: top=12, height=12 -> extent [0, 12) + // 'o' and 'a': top=8, height=8 -> extent [0, 8) + ASSERT_EQ(textHeight("o"), 8); + ASSERT_EQ(textHeight("T"), 12); + ASSERT_EQ(textHeight("To"), 12); + ASSERT_EQ(textHeight("oo"), 8); + + printf(" All heights correct\n"); + PASS(); +} + +int main() { + printf("=== Differential Rounding Tests ===\n\n"); + + // Part 1: Pure fp4 math + testFp4Basics(); + testOldApproachInconsistency(); + testExhaustiveKernRange(); + + // Part 2: Integration tests against real EpdFont + testKernLookup(); + testGlyphLookup(); + testKnownWidths(); + testPairConsistencyViaFont(); + testNullGlyphAdvancePreserved(); + testHeightCalculation(); + + printf("\n=== Results: %d passed, %d failed ===\n", testsPassed, testsFailed); + return testsFailed > 0 ? 1 : 0; +} diff --git a/test/run_differential_rounding_test.sh b/test/run_differential_rounding_test.sh new file mode 100755 index 000000000..dd7aa5c7a --- /dev/null +++ b/test/run_differential_rounding_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUILD_DIR="$ROOT_DIR/build/differential_rounding" +BINARY="$BUILD_DIR/DifferentialRoundingTest" + +mkdir -p "$BUILD_DIR" + +SOURCES=( + "$ROOT_DIR/test/differential_rounding/DifferentialRoundingTest.cpp" + "$ROOT_DIR/lib/EpdFont/EpdFont.cpp" + "$ROOT_DIR/lib/Utf8/Utf8.cpp" +) + +CXXFLAGS=( + -std=c++20 + -O2 + -Wall + -Wextra + -pedantic + -I"$ROOT_DIR" + -I"$ROOT_DIR/lib" + -I"$ROOT_DIR/lib/EpdFont" + -I"$ROOT_DIR/lib/Utf8" +) + +c++ "${CXXFLAGS[@]}" "${SOURCES[@]}" -o "$BINARY" + +"$BINARY" "$@" From c656673b9a887667c360b748ba6df31d64b880df Mon Sep 17 00:00:00 2001 From: CSCMe Date: Wed, 8 Apr 2026 05:25:08 +0200 Subject: [PATCH 08/37] refactor: logPrintf and predefined log level strings (#1546) ## Summary * More consistent string formatting in logPrintf * Moved [ and ] from log level strings into new format string * Behaviour change: Early exit if user string format fails ## Additional Context * Should not have performance implications, debug monitor etc work as before * Clamp may be unnecessary due to information snprintf currently never being able to exceed max buffer length, but not having it would bug me --- ### 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? _**PARTIALLY, to reason about correctness**_ --- lib/Logging/Logging.cpp | 39 ++++++++++++++------------------------- lib/Logging/Logging.h | 6 +++--- 2 files changed, 17 insertions(+), 28 deletions(-) diff --git a/lib/Logging/Logging.cpp b/lib/Logging/Logging.cpp index d670f5fbd..3306f2542 100644 --- a/lib/Logging/Logging.cpp +++ b/lib/Logging/Logging.cpp @@ -38,37 +38,26 @@ void logPrintf(const char* level, const char* origin, const char* format, ...) { va_start(args, format); char buf[MAX_ENTRY_LEN]; char* c = buf; - // add the timestamp + // add timestamp, level and origin { unsigned long ms = millis(); - int len = snprintf(c, sizeof(buf), "[%lu] ", ms); + int len = snprintf(c, sizeof(buf), "[%lu] [%s] [%s] ", ms, level, origin); + // erro while writing => return if (len < 0) { - return; // encoding error, skip logging + va_end(args); + return; } - c += len; - } - // add the level - { - const char* p = level; - size_t remaining = sizeof(buf) - (c - buf); - while (*p && remaining > 1) { - *c++ = *p++; - remaining--; - } - if (remaining > 1) { - *c++ = ' '; - } - } - // add the origin - { - int len = snprintf(c, sizeof(buf) - (c - buf), "[%s] ", origin); - if (len < 0) { - return; // encoding error, skip logging - } - c += len; + // clamp c to be in buffer range + c += std::min(len, MAX_ENTRY_LEN); } // add the user message - vsnprintf(c, sizeof(buf) - (c - buf), format, args); + { + int len = vsnprintf(c, sizeof(buf) - (c - buf), format, args); + if (len < 0) { + va_end(args); + return; + } + } va_end(args); if (logSerial) { logSerial.print(buf); diff --git a/lib/Logging/Logging.h b/lib/Logging/Logging.h index 47e8eb7dc..8784a3418 100644 --- a/lib/Logging/Logging.h +++ b/lib/Logging/Logging.h @@ -33,19 +33,19 @@ void logPrintf(const char* level, const char* origin, const char* format, ...); #ifdef ENABLE_SERIAL_LOG #if LOG_LEVEL >= 0 -#define LOG_ERR(origin, format, ...) logPrintf("[ERR]", origin, format "\n", ##__VA_ARGS__) +#define LOG_ERR(origin, format, ...) logPrintf("ERR", origin, format "\n", ##__VA_ARGS__) #else #define LOG_ERR(origin, format, ...) #endif #if LOG_LEVEL >= 1 -#define LOG_INF(origin, format, ...) logPrintf("[INF]", origin, format "\n", ##__VA_ARGS__) +#define LOG_INF(origin, format, ...) logPrintf("INF", origin, format "\n", ##__VA_ARGS__) #else #define LOG_INF(origin, format, ...) #endif #if LOG_LEVEL >= 2 -#define LOG_DBG(origin, format, ...) logPrintf("[DBG]", origin, format "\n", ##__VA_ARGS__) +#define LOG_DBG(origin, format, ...) logPrintf("DBG", origin, format "\n", ##__VA_ARGS__) #else #define LOG_DBG(origin, format, ...) #endif From b898d53f7b8726f9a0c74ff064df61c1cc102f16 Mon Sep 17 00:00:00 2001 From: martin brook Date: Wed, 8 Apr 2026 09:22:25 +0100 Subject: [PATCH 09/37] chore: drop JPEGDEC patch in favour of upstream fix (#1465) ## Summary The progressive JPEG fixes (AC table skip, MCU_SKIP guard) from PR #1136 have been fixed upstream in bitbank2/JPEGDEC@8628297. Pin to that commit and remove the pre-build patch script. ## Additional Context Tested on Strange Pictures epub --- ### 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? _**< PARTIALLY >**_ --- platformio.ini | 3 +- scripts/patch_jpegdec.py | 117 --------------------------------------- 2 files changed, 1 insertion(+), 119 deletions(-) delete mode 100644 scripts/patch_jpegdec.py diff --git a/platformio.ini b/platformio.ini index cb8fd125b..c289d1133 100644 --- a/platformio.ini +++ b/platformio.ini @@ -50,7 +50,6 @@ board_build.partitions = partitions.csv extra_scripts = pre:scripts/build_html.py pre:scripts/gen_i18n.py - pre:scripts/patch_jpegdec.py pre:scripts/git_branch.py ; Libraries @@ -62,7 +61,7 @@ lib_deps = bblanchon/ArduinoJson @ 7.4.2 ricmoo/QRCode @ 0.0.1 bitbank2/PNGdec @ ^1.0.0 - bitbank2/JPEGDEC @ ^1.8.0 + https://github.com/bitbank2/JPEGDEC.git#86282979224c8a32fd51e091ed5a35b0c699a52b links2004/WebSockets @ 2.7.3 [env:default] diff --git a/scripts/patch_jpegdec.py b/scripts/patch_jpegdec.py deleted file mode 100644 index 4dd5a554e..000000000 --- a/scripts/patch_jpegdec.py +++ /dev/null @@ -1,117 +0,0 @@ -""" -PlatformIO pre-build script: patch JPEGDEC library for progressive JPEG support. - -Two patches are applied: - -1. JPEGMakeHuffTables: Skip AC Huffman table construction for progressive JPEGs. - JPEGDEC 1.8.x fails to open progressive JPEGs because JPEGMakeHuffTables() - cannot build AC tables with 11+-bit codes (the "slow tables" path is disabled). - Since progressive decode only uses DC coefficients, AC tables are not needed. - -2. JPEGDecodeMCU_P: Guard pMCU writes against MCU_SKIP (-8). - The non-progressive JPEGDecodeMCU checks `iMCU >= 0` before writing to pMCU, - but JPEGDecodeMCU_P does not. When EIGHT_BIT_GRAYSCALE mode skips chroma - channels by passing MCU_SKIP, the unguarded write goes to a wild pointer - (sMCUs[0xFFFFF8]) and crashes. - -Both patches are applied idempotently so it is safe to run on every build. -""" - -Import("env") -import os - -def patch_jpegdec(env): - # Find the JPEGDEC library in libdeps - libdeps_dir = os.path.join(env["PROJECT_DIR"], ".pio", "libdeps") - if not os.path.isdir(libdeps_dir): - return - for env_dir in os.listdir(libdeps_dir): - jpeg_inl = os.path.join(libdeps_dir, env_dir, "JPEGDEC", "src", "jpeg.inl") - if os.path.isfile(jpeg_inl): - _apply_ac_table_patch(jpeg_inl) - _apply_mcu_skip_patch(jpeg_inl) - -def _apply_ac_table_patch(filepath): - MARKER = "// CrossPoint patch: skip AC tables for progressive JPEG" - with open(filepath, "r") as f: - content = f.read() - - if MARKER in content: - return # already patched - - OLD = """\ - } - // now do AC components (up to 4 tables of 16-bit codes)""" - - NEW = """\ - } - """ + MARKER + """ - // Progressive JPEG: only DC coefficients are decoded (first scan), so AC - // Huffman tables are not needed. Skip building them to avoid failing on - // 11+-bit AC codes that the optimized table builder cannot handle. - if (pJPEG->ucMode == 0xc2) - return 1; - // now do AC components (up to 4 tables of 16-bit codes)""" - - if OLD not in content: - print("WARNING: JPEGDEC AC table patch target not found in %s — library may have been updated" % filepath) - return - - content = content.replace(OLD, NEW, 1) - with open(filepath, "w") as f: - f.write(content) - print("Patched JPEGDEC: skip AC tables for progressive JPEG: %s" % filepath) - -def _apply_mcu_skip_patch(filepath): - MARKER = "// CrossPoint patch: guard pMCU write for MCU_SKIP" - with open(filepath, "r") as f: - content = f.read() - - if MARKER in content: - return # already patched - - # Patch 1: Guard the unconditional pMCU[0] write in JPEGDecodeMCU_P. - # This is the DC coefficient store that crashes when iMCU = MCU_SKIP (-8). - OLD_DC = """\ - pMCU[0] = (short)*iDCPredictor; // store in MCU[0] - } - // Now get the other 63 AC coefficients""" - - NEW_DC = """\ - """ + MARKER + """ - if (iMCU >= 0) - pMCU[0] = (short)*iDCPredictor; // store in MCU[0] - } - // Now get the other 63 AC coefficients""" - - if OLD_DC not in content: - print("WARNING: JPEGDEC MCU_SKIP patch target not found in %s — library may have been updated" % filepath) - return - - content = content.replace(OLD_DC, NEW_DC, 1) - - # Patch 2: Guard the successive approximation pMCU[0] write. - # This path is taken on subsequent scans (cApproxBitsHigh != 0), which we - # don't normally hit (we only decode first scan), but guard it for safety. - OLD_SA = """\ - pMCU[0] |= iPositive; - } - goto mcu_done; // that's it""" - - NEW_SA = """\ - if (iMCU >= 0) - pMCU[0] |= iPositive; - } - goto mcu_done; // that's it""" - - if OLD_SA in content: - content = content.replace(OLD_SA, NEW_SA, 1) - - with open(filepath, "w") as f: - f.write(content) - print("Patched JPEGDEC: guard pMCU writes for MCU_SKIP in JPEGDecodeMCU_P: %s" % filepath) - -# Apply patches immediately when this pre: script runs, before compilation starts. -# Previously used env.AddPreAction("buildprog", ...) which deferred patching until -# the link step — after the library was already compiled from unpatched source. -patch_jpegdec(env) From d29b8ee2f9f053e10fcd8831e79ab72e6da3344b Mon Sep 17 00:00:00 2001 From: nscheung <6036668+nscheung@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:09:28 -0700 Subject: [PATCH 10/37] feat: Adjust Navigation at End of Book (#1425) ## Summary * **What is the goal of this PR? (e.g., Implements the new feature for file uploading.)** Currently, pressing forward at the end of a book loops back to the last page. This change will instead sends you to the home page instead. * **What changes are included?** Applies the change to the three supported format: EPUB, XTC, TXT ## Additional Context * This is more of a QOL improvement than a new feature. If there's interest, we could extend this to track a completed state for ebooks. --- ### 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**_ --- src/activities/reader/EpubReaderActivity.cpp | 12 ++++++++---- src/activities/reader/TxtReaderActivity.cpp | 10 +++++++--- src/activities/reader/XtcReaderActivity.cpp | 10 +++++++--- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 16d403aaa..65dbe17c8 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -181,11 +181,15 @@ void EpubReaderActivity::loop() { return; } - // any botton press when at end of the book goes back to the last page + // At end of the book, forward button goes home and back button returns to last page if (currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount()) { - currentSpineIndex = epub->getSpineItemsCount() - 1; - nextPageNumber = UINT16_MAX; - requestUpdate(); + if (nextTriggered) { + onGoHome(); + } else { + currentSpineIndex = epub->getSpineItemsCount() - 1; + nextPageNumber = UINT16_MAX; + requestUpdate(); + } return; } diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index c723bcb15..6bc3ba5ad 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -79,9 +79,13 @@ void TxtReaderActivity::loop() { if (prevTriggered && currentPage > 0) { currentPage--; requestUpdate(); - } else if (nextTriggered && currentPage < totalPages - 1) { - currentPage++; - requestUpdate(); + } else if (nextTriggered) { + if (currentPage < totalPages - 1) { + currentPage++; + requestUpdate(); + } else { + onGoHome(); + } } } diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index 84cc51da2..d2b6f18ea 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -98,10 +98,14 @@ void XtcReaderActivity::loop() { return; } - // Handle end of book + // At end of the book, forward button goes home and back button returns to last page if (currentPage >= xtc->getPageCount()) { - currentPage = xtc->getPageCount() - 1; - requestUpdate(); + if (nextTriggered) { + onGoHome(); + } else { + currentPage = xtc->getPageCount() - 1; + requestUpdate(); + } return; } From ed0811c898c1ed7c2afcc74b9e5ccb2e4fbf3d26 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Wed, 8 Apr 2026 20:26:12 +0200 Subject: [PATCH 11/37] fix: Fix failing very first wifi connection attempt (#1521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary * **What is the goal of this PR?** The very first Wifi connection attempt with saved credentials failed, subsequent attempts succeeded. This PR fixes the first-attempt-issue. * **What changes are included?** ## Additional Context Claude analysis for WifiSelectionActivity In attemptConnection() ,there's no WiFi.disconnect() before WiFi.begin() — unlike the scan path earlier which does do a disconnect first. The root cause on ESP32 is the built-in auto-connect feature: the ESP32 WiFi stack saves credentials to NVS flash and automatically starts trying to connect on boot before your application code runs. When your attemptConnection() then calls WiFi.begin(), the stack is already in a transitional CONNECTING state, and the new begin() call either gets ignored or collides with the in-progress attempt. The fix is to add WiFi.disconnect(true) + a short delay in attemptConnection() before calling WiFi.begin(), and optionally call WiFi.persistent(false) to stop the ESP32 from auto-connecting on its own. --- ### 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? _**< PARTIALLY >**_ --- src/activities/network/WifiSelectionActivity.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index 85650df9e..235a55c1f 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -217,7 +217,10 @@ void WifiSelectionActivity::attemptConnection() { connectionError.clear(); requestUpdate(); + WiFi.persistent(false); // Credentials are managed by WifiCredentialStore; suppress SDK NVS auto-connect WiFi.mode(WIFI_STA); + WiFi.disconnect(true, true); // Abort any in-progress SDK auto-connect and clear NVS-saved SSID + delay(100); // Set hostname so routers show "CrossPoint-Reader-AABBCCDDEEFF" instead of "esp32-XXXXXXXXXXXX" String mac = WiFi.macAddress(); From 825ef56ad8e8e4ae85ce5e6cfe0984a6bc316aff Mon Sep 17 00:00:00 2001 From: Justinian Date: Thu, 9 Apr 2026 02:58:59 +0100 Subject: [PATCH 12/37] feat: X3 grayscale antialiasing improvements (#1607) ## Summary Improves text antialiasing quality on the Xteink X3 (SSD1677) display to bring it closer to X4 rendering quality. Addresses white lines through letter strokes and ghosting artifacts during page turns and screen transitions. ## Changes ### Display Driver (open-x4-sdk) - Dedicated X3 grayscale LUTs with tuned VDL drive strengths for dark gray (2 time units) and light gray (3 time units), with active GND hold on non-gray transitions to prevent floating source crosstalk - Tight scan timing: TP2/TP3 reduced to 1 (total gate-on 7 units vs 17), minimizing parasitic charge leakage that caused white lines through letter strokes - Fast diff BB reinforcement: Added mild VDH reinforcing pulse to lut_x3_bb_full so black pixels are actively driven during differential refreshes, clearing gray residue/ghosting - displayGrayBuffer() updated to use the dedicated gray LUT bank instead of full refresh LUTs for X3 ### Rendering Pipeline - Re-enabled light gray rendering for X3 text and images, now safe with dedicated gray LUTs providing proper 4-level gray - Removed isLightGrayRestricted() gating that was limiting X3 to 3-level gray - Runtime display dimensions in DirectPixelWriter and ScreenshotUtil, replaced hardcoded constants with runtime getters to support X3 792x528 resolution ### Note The open-x4-sdk submodule references a commit on juicecultus/community-sdk. A corresponding PR to open-x4-epaper/community-sdk should be merged first so the submodule ref resolves on upstream. ## Testing Tested on physical X3 hardware. White lines through letters significantly reduced, in-book ghosting improved via BB reinforcement, antialiasing visually closer to X4 quality. ## AI Disclosure Yes, AI was used to assist with the development of these changes. --------- --- lib/Epub/Epub/converters/DirectPixelWriter.h | 12 ++++++------ lib/GfxRenderer/GfxRenderer.cpp | 6 +++--- lib/GfxRenderer/GfxRenderer.h | 3 +++ open-x4-sdk | 2 +- src/util/ScreenshotUtil.cpp | 8 ++++---- 5 files changed, 17 insertions(+), 14 deletions(-) diff --git a/lib/Epub/Epub/converters/DirectPixelWriter.h b/lib/Epub/Epub/converters/DirectPixelWriter.h index 1154b49fb..bc66c2f78 100644 --- a/lib/Epub/Epub/converters/DirectPixelWriter.h +++ b/lib/Epub/Epub/converters/DirectPixelWriter.h @@ -30,14 +30,14 @@ struct DirectPixelWriter { void init(GfxRenderer& renderer) { fb = renderer.getFrameBuffer(); mode = renderer.getRenderMode(); - displayWidthBytes = display.getDisplayWidthBytes(); + displayWidthBytes = renderer.getDisplayWidthBytes(); - const int phyW = display.getDisplayWidth(); - const int phyH = display.getDisplayHeight(); + const int phyW = renderer.getDisplayWidth(); + const int phyH = renderer.getDisplayHeight(); switch (renderer.getOrientation()) { case GfxRenderer::Portrait: - // phyX = y, phyY = (DISPLAY_HEIGHT-1) - x + // phyX = y, phyY = (phyH-1) - x phyXBase = 0; phyYBase = phyH - 1; phyXStepX = 0; @@ -46,7 +46,7 @@ struct DirectPixelWriter { phyYStepY = 0; break; case GfxRenderer::LandscapeClockwise: - // phyX = (DISPLAY_WIDTH-1) - x, phyY = (DISPLAY_HEIGHT-1) - y + // phyX = (phyW-1) - x, phyY = (phyH-1) - y phyXBase = phyW - 1; phyYBase = phyH - 1; phyXStepX = -1; @@ -55,7 +55,7 @@ struct DirectPixelWriter { phyYStepY = -1; break; case GfxRenderer::PortraitInverted: - // phyX = (DISPLAY_WIDTH-1) - y, phyY = x + // phyX = (phyW-1) - y, phyY = x phyXBase = phyW - 1; phyYBase = 0; phyXStepX = 0; diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 1e7bc7124..c1a4b869f 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -131,9 +131,9 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode if (renderMode == GfxRenderer::BW && bmpVal < 3) { // Black (also paints over the grays in BW mode) renderer.drawPixel(screenX, screenY, pixelState); - } else if (renderMode == GfxRenderer::GRAYSCALE_MSB && (bmpVal == 1 || (gpio.deviceIsX4() && bmpVal == 2))) { + } else if (renderMode == GfxRenderer::GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) { // Light gray (also mark the MSB if it's going to be a dark gray too) - // X3 AA tuning: keep only the darker antialias level to avoid washed text + // Dedicated X3 gray LUTs now provide proper 4-level gray on both devices // We have to flag pixels in reverse for the gray buffers, as 0 leave alone, 1 update renderer.drawPixel(screenX, screenY, false); } else if (renderMode == GfxRenderer::GRAYSCALE_LSB && bmpVal == 1) { @@ -686,7 +686,7 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con if (renderMode == BW && val < 3) { drawPixel(screenX, screenY); - } else if (renderMode == GRAYSCALE_MSB && (val == 1 || (gpio.deviceIsX4() && val == 2))) { + } else if (renderMode == GRAYSCALE_MSB && (val == 1 || val == 2)) { drawPixel(screenX, screenY, false); } else if (renderMode == GRAYSCALE_LSB && val == 1) { drawPixel(screenX, screenY, false); diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 01556522d..e683e3122 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -157,4 +157,7 @@ class GfxRenderer { // Low level functions uint8_t* getFrameBuffer() const; size_t getBufferSize() const; + uint16_t getDisplayWidth() const { return panelWidth; } + uint16_t getDisplayHeight() const { return panelHeight; } + uint16_t getDisplayWidthBytes() const { return panelWidthBytes; } }; diff --git a/open-x4-sdk b/open-x4-sdk index 157d724d7..a64a3c29b 160000 --- a/open-x4-sdk +++ b/open-x4-sdk @@ -1 +1 @@ -Subproject commit 157d724d7a7389d49fe108e6dd5da2455a5340ba +Subproject commit a64a3c29bebc59b2ccdfe15492cfc4b5e4c26360 diff --git a/src/util/ScreenshotUtil.cpp b/src/util/ScreenshotUtil.cpp index 41f075447..0f14f5060 100644 --- a/src/util/ScreenshotUtil.cpp +++ b/src/util/ScreenshotUtil.cpp @@ -14,8 +14,8 @@ void ScreenshotUtil::takeScreenshot(GfxRenderer& renderer) { const uint8_t* fb = renderer.getFrameBuffer(); if (fb) { String filename_str = "/screenshots/screenshot-" + String(millis()) + ".bmp"; - if (ScreenshotUtil::saveFramebufferAsBmp(filename_str.c_str(), fb, display.getDisplayWidth(), - display.getDisplayHeight())) { + if (ScreenshotUtil::saveFramebufferAsBmp(filename_str.c_str(), fb, renderer.getDisplayWidth(), + renderer.getDisplayHeight())) { LOG_DBG("SCR", "Screenshot saved to %s", filename_str.c_str()); } else { LOG_ERR("SCR", "Failed to save screenshot"); @@ -26,7 +26,7 @@ void ScreenshotUtil::takeScreenshot(GfxRenderer& renderer) { // Display a border around the screen to indicate a screenshot was taken if (renderer.storeBwBuffer()) { - renderer.drawRect(6, 6, display.getDisplayHeight() - 12, display.getDisplayWidth() - 12, 2, true); + renderer.drawRect(6, 6, renderer.getDisplayHeight() - 12, renderer.getDisplayWidth() - 12, 2, true); renderer.displayBuffer(); delay(1000); renderer.restoreBwBuffer(); @@ -76,7 +76,7 @@ bool ScreenshotUtil::saveFramebufferAsBmp(const char* filename, const uint8_t* f } const uint32_t rowSizePadded = (phyWidth + 31) / 32 * 4; - // Max row size for 528px width (X3) = 68 bytes; use fixed buffer to avoid VLA + // Max row size for 528px height (X3) after rotation = 68 bytes; use fixed buffer to avoid VLA constexpr size_t kMaxRowSize = 68; if (rowSizePadded > kMaxRowSize) { LOG_ERR("SCR", "Row size %u exceeds buffer capacity", rowSizePadded); From 5349e81723b68d7d33f5223ef0bfdedf96d0d810 Mon Sep 17 00:00:00 2001 From: CaptainFrito Date: Thu, 9 Apr 2026 21:54:46 +0700 Subject: [PATCH 13/37] feat: Display file extensions in File Browser (#1019) ## Summary ![IMG_8136 Medium](https://github.com/user-attachments/assets/e33a8a71-4126-4bdc-8d3a-32132acba712) ![IMG_8137 Medium](https://github.com/user-attachments/assets/2289fbee-71e8-446c-ad28-9dd4a9a21396) ![IMG_8138 Medium](https://github.com/user-attachments/assets/0e912f72-9be1-4708-a909-4dab192d1aea) ## Additional Context Do we want a setting to toggle this? --- ### AI Usage Did you use AI tools to help write this code? _**NO**_ --- src/activities/home/FileBrowserActivity.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/activities/home/FileBrowserActivity.cpp b/src/activities/home/FileBrowserActivity.cpp index 3b14031b5..9ac714ebb 100644 --- a/src/activities/home/FileBrowserActivity.cpp +++ b/src/activities/home/FileBrowserActivity.cpp @@ -249,6 +249,14 @@ std::string getFileName(std::string filename) { return filename.substr(0, pos); } +std::string getFileExtension(std::string filename) { + if (filename.back() == '/') { + return ""; + } + const auto pos = filename.rfind('.'); + return filename.substr(pos); +} + void FileBrowserActivity::render(RenderLock&&) { renderer.clearScreen(); @@ -267,7 +275,8 @@ void FileBrowserActivity::render(RenderLock&&) { GUI.drawList( renderer, Rect{0, contentTop, pageWidth, contentHeight}, files.size(), selectorIndex, [this](int index) { return getFileName(files[index]); }, nullptr, - [this](int index) { return UITheme::getFileIcon(files[index]); }); + [this](int index) { return UITheme::getFileIcon(files[index]); }, + [this](int index) { return getFileExtension(files[index]); }, false); } // Help text From b3b43bb3738ab23b14ca9ca881c037050e1107de Mon Sep 17 00:00:00 2001 From: Zach Nelson Date: Thu, 9 Apr 2026 13:28:31 -0500 Subject: [PATCH 14/37] refactor: RAII scoped open/close for ZipFile (#1433) ## Summary **What is the goal of this PR?** Added `ScopedOpenClose` RAII guard to eliminate repetitive `wasOpen`/`close()` boilerplate across all ZipFile methods. --- ### 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? _**PARTIALLY**_ --- lib/ZipFile/ZipFile.cpp | 172 ++++++++++------------------------------ 1 file changed, 43 insertions(+), 129 deletions(-) diff --git a/lib/ZipFile/ZipFile.cpp b/lib/ZipFile/ZipFile.cpp index 58fa64d5a..f7342e5e6 100644 --- a/lib/ZipFile/ZipFile.cpp +++ b/lib/ZipFile/ZipFile.cpp @@ -18,6 +18,28 @@ namespace { constexpr uint16_t ZIP_METHOD_STORED = 0; constexpr uint16_t ZIP_METHOD_DEFLATED = 8; +// RAII zip: opens the zip if not already open, closes on destruction only if +// it performed the open. Removes the wasOpen/close boilerplate from every method. +class ScopedOpenClose final { + public: + [[nodiscard]] explicit ScopedOpenClose(ZipFile& zf) : zf(zf), needsClose(!zf.isOpen()) { + if (needsClose) ok = zf.open(); + } + ~ScopedOpenClose() { + if (needsClose && ok) zf.close(); + } + ScopedOpenClose(const ScopedOpenClose&) = delete; + ScopedOpenClose& operator=(const ScopedOpenClose&) = delete; + ScopedOpenClose(ScopedOpenClose&&) = delete; + ScopedOpenClose& operator=(ScopedOpenClose&&) = delete; + explicit operator bool() const { return ok || !needsClose; } + + private: + ZipFile& zf; + bool needsClose = false; + bool ok = true; // true when zip was already open (no open() call needed) +}; + int zipReadCallback(uzlib_uncomp* uncomp) { auto* ctx = reinterpret_cast(uncomp); if (ctx->fileRemaining == 0) return -1; @@ -35,17 +57,10 @@ int zipReadCallback(uzlib_uncomp* uncomp) { } // namespace bool ZipFile::loadAllFileStatSlims() { - const bool wasOpen = isOpen(); - if (!wasOpen && !open()) { - return false; - } + const ScopedOpenClose zip{*this}; + if (!zip) return false; - if (!loadZipDetails()) { - if (!wasOpen) { - close(); - } - return false; - } + if (!loadZipDetails()) return false; file.seek(zipDetails.centralDirOffset); @@ -89,9 +104,6 @@ bool ZipFile::loadAllFileStatSlims() { lastCentralDirPos = zipDetails.centralDirOffset; lastCentralDirPosValid = true; - if (!wasOpen) { - close(); - } return true; } @@ -105,17 +117,10 @@ bool ZipFile::loadFileStatSlim(const char* filename, FileStatSlim* fileStat) { return false; } - const bool wasOpen = isOpen(); - if (!wasOpen && !open()) { - return false; - } + const ScopedOpenClose zip{*this}; + if (!zip) return false; - if (!loadZipDetails()) { - if (!wasOpen) { - close(); - } - return false; - } + if (!loadZipDetails()) return false; // Phase 1: Try scanning from cursor position first uint32_t startPos = lastCentralDirPosValid ? lastCentralDirPos : zipDetails.centralDirOffset; @@ -179,17 +184,12 @@ bool ZipFile::loadFileStatSlim(const char* filename, FileStatSlim* fileStat) { file.seekCur(m + k); } - if (!wasOpen) { - close(); - } return found; } long ZipFile::getDataOffset(const FileStatSlim& fileStat) { - const bool wasOpen = isOpen(); - if (!wasOpen && !open()) { - return -1; - } + const ScopedOpenClose zip{*this}; + if (!zip) return -1; constexpr auto localHeaderSize = 30; @@ -198,9 +198,6 @@ long ZipFile::getDataOffset(const FileStatSlim& fileStat) { file.seek(fileOffset); const size_t read = file.read(pLocalHeader, localHeaderSize); - if (!wasOpen) { - close(); - } if (read != localHeaderSize) { LOG_ERR("ZIP", "Something went wrong reading the local header"); @@ -223,17 +220,12 @@ bool ZipFile::loadZipDetails() { return true; } - const bool wasOpen = isOpen(); - if (!wasOpen && !open()) { - return false; - } + const ScopedOpenClose zip{*this}; + if (!zip) return false; const size_t fileSize = file.size(); if (fileSize < 22) { LOG_ERR("ZIP", "File too small to be a valid zip"); - if (!wasOpen) { - close(); - } return false; // Minimum EOCD size is 22 bytes } @@ -243,9 +235,6 @@ bool ZipFile::loadZipDetails() { const auto buffer = static_cast(malloc(scanRange)); if (!buffer) { LOG_ERR("ZIP", "Failed to allocate memory for EOCD scan buffer"); - if (!wasOpen) { - close(); - } return false; } @@ -265,9 +254,6 @@ bool ZipFile::loadZipDetails() { if (foundOffset == -1) { LOG_ERR("ZIP", "EOCD signature not found in zip file"); free(buffer); - if (!wasOpen) { - close(); - } return false; } @@ -280,9 +266,6 @@ bool ZipFile::loadZipDetails() { zipDetails.isSet = true; free(buffer); - if (!wasOpen) { - close(); - } return true; } @@ -317,17 +300,10 @@ int ZipFile::fillUncompressedSizes(std::vector& targets, std::vector return 0; } - const bool wasOpen = isOpen(); - if (!wasOpen && !open()) { - return 0; - } + const ScopedOpenClose zip{*this}; + if (!zip) return 0; - if (!loadZipDetails()) { - if (!wasOpen) { - close(); - } - return 0; - } + if (!loadZipDetails()) return 0; file.seek(zipDetails.centralDirOffset); @@ -384,34 +360,18 @@ int ZipFile::fillUncompressedSizes(std::vector& targets, std::vector file.seekCur(m + k); } - if (!wasOpen) { - close(); - } - return matched; } uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const bool trailingNullByte) { - const bool wasOpen = isOpen(); - if (!wasOpen && !open()) { - return nullptr; - } + const ScopedOpenClose zip{*this}; + if (!zip) return nullptr; FileStatSlim fileStat = {}; - if (!loadFileStatSlim(filename, &fileStat)) { - if (!wasOpen) { - close(); - } - return nullptr; - } + if (!loadFileStatSlim(filename, &fileStat)) return nullptr; const long fileOffset = getDataOffset(fileStat); - if (fileOffset < 0) { - if (!wasOpen) { - close(); - } - return nullptr; - } + if (fileOffset < 0) return nullptr; file.seek(fileOffset); @@ -421,18 +381,12 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo const auto data = static_cast(malloc(dataSize)); if (data == nullptr) { LOG_ERR("ZIP", "Failed to allocate memory for output buffer (%zu bytes)", dataSize); - if (!wasOpen) { - close(); - } return nullptr; } if (fileStat.method == ZIP_METHOD_STORED) { // no deflation, just read content const size_t dataRead = file.read(data, inflatedDataSize); - if (!wasOpen) { - close(); - } if (dataRead != inflatedDataSize) { LOG_ERR("ZIP", "Failed to read data"); @@ -446,16 +400,10 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo const auto deflatedData = static_cast(malloc(deflatedDataSize)); if (deflatedData == nullptr) { LOG_ERR("ZIP", "Failed to allocate memory for decompression buffer"); - if (!wasOpen) { - close(); - } return nullptr; } const size_t dataRead = file.read(deflatedData, deflatedDataSize); - if (!wasOpen) { - close(); - } if (dataRead != deflatedDataSize) { LOG_ERR("ZIP", "Failed to read data, expected %d got %d", deflatedDataSize, dataRead); @@ -482,9 +430,6 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo // Continue out of block with data set } else { LOG_ERR("ZIP", "Unsupported compression method"); - if (!wasOpen) { - close(); - } return nullptr; } @@ -494,20 +439,14 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo } bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t chunkSize) { - const bool wasOpen = isOpen(); - if (!wasOpen && !open()) { - return false; - } + const ScopedOpenClose zip{*this}; + if (!zip) return false; FileStatSlim fileStat = {}; - if (!loadFileStatSlim(filename, &fileStat)) { - return false; - } + if (!loadFileStatSlim(filename, &fileStat)) return false; const long fileOffset = getDataOffset(fileStat); - if (fileOffset < 0) { - return false; - } + if (fileOffset < 0) return false; file.seek(fileOffset); const auto deflatedDataSize = fileStat.compressedSize; @@ -518,9 +457,6 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch const auto buffer = static_cast(malloc(chunkSize)); if (!buffer) { LOG_ERR("ZIP", "Failed to allocate memory for buffer"); - if (!wasOpen) { - close(); - } return false; } @@ -530,9 +466,6 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch if (dataRead == 0) { LOG_ERR("ZIP", "Could not read more bytes"); free(buffer); - if (!wasOpen) { - close(); - } return false; } @@ -540,9 +473,6 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch remaining -= dataRead; } - if (!wasOpen) { - close(); - } free(buffer); return true; } @@ -551,9 +481,6 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch auto* fileReadBuffer = static_cast(malloc(chunkSize)); if (!fileReadBuffer) { LOG_ERR("ZIP", "Failed to allocate memory for zip file read buffer"); - if (!wasOpen) { - close(); - } return false; } @@ -561,9 +488,6 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch if (!outputBuffer) { LOG_ERR("ZIP", "Failed to allocate memory for output buffer"); free(fileReadBuffer); - if (!wasOpen) { - close(); - } return false; } @@ -577,9 +501,6 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch LOG_ERR("ZIP", "Failed to init inflate reader"); free(outputBuffer); free(fileReadBuffer); - if (!wasOpen) { - close(); - } return false; } ctx.reader.setReadCallback(zipReadCallback); @@ -623,18 +544,11 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch // InflateStatus::Ok: output buffer full, continue } - if (!wasOpen) { - close(); - } free(outputBuffer); free(fileReadBuffer); return success; // ctx.reader destructor frees the ring buffer } - if (!wasOpen) { - close(); - } - LOG_ERR("ZIP", "Unsupported compression method"); return false; } From 104f391a29d11d504fc4d990eb135fda72eadb04 Mon Sep 17 00:00:00 2001 From: Uri Tauber <142022451+Uri-Tauber@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:16:19 +0300 Subject: [PATCH 15/37] fix: two small memory leaks (#1628) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary * **What is the goal of this PR?** Fix two issues pointed out by `CodeRabbit` in #1433: 1. Free the output buffer in `readFileToMemory` on early error paths (prevents memory leaks). 2. Check `out.write()` return value in the STORED path (same as in the DEFLATED path). I can confirm both issues were real (not hallucinations). Both fixes are minimal — no behavior change on success. --- ### AI Usage Did you use AI tools to help write this code? _**< NO >**_ --- lib/ZipFile/ZipFile.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/ZipFile/ZipFile.cpp b/lib/ZipFile/ZipFile.cpp index f7342e5e6..2bb19147a 100644 --- a/lib/ZipFile/ZipFile.cpp +++ b/lib/ZipFile/ZipFile.cpp @@ -400,6 +400,7 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo const auto deflatedData = static_cast(malloc(deflatedDataSize)); if (deflatedData == nullptr) { LOG_ERR("ZIP", "Failed to allocate memory for decompression buffer"); + free(data); return nullptr; } @@ -430,6 +431,7 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo // Continue out of block with data set } else { LOG_ERR("ZIP", "Unsupported compression method"); + free(data); return nullptr; } @@ -469,7 +471,11 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch return false; } - out.write(buffer, dataRead); + if (out.write(buffer, dataRead) != dataRead) { + LOG_ERR("ZIP", "Failed to write all output bytes to stream"); + free(buffer); + return false; + } remaining -= dataRead; } From 5ba85290ab5a423b133dcb067e2d40c4671a285e Mon Sep 17 00:00:00 2001 From: Zach Nelson Date: Thu, 9 Apr 2026 16:35:43 -0500 Subject: [PATCH 16/37] refactor: Deduplicated BMP header writing in Xtc (#1439) ## Summary **What is the goal of this PR?** Replaced manual 1-bit BMP header logic in `Xtc::generateCoverBmp()` and `Xtc::generateThumbBmp()` with calls to the existing `createBmpHeader()` utility. Added a `BmpRowOrder` enum and param to support the top-down row order of XTC cover images. --- ### 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? _**PARTIALLY**_ --- lib/GfxRenderer/BitmapHelpers.cpp | 12 ++-- lib/GfxRenderer/BitmapHelpers.h | 4 +- lib/Xtc/Xtc.cpp | 98 ++++--------------------------- src/util/ScreenshotUtil.cpp | 2 +- 4 files changed, 21 insertions(+), 95 deletions(-) diff --git a/lib/GfxRenderer/BitmapHelpers.cpp b/lib/GfxRenderer/BitmapHelpers.cpp index e9dbb64d5..dca059ec9 100644 --- a/lib/GfxRenderer/BitmapHelpers.cpp +++ b/lib/GfxRenderer/BitmapHelpers.cpp @@ -108,7 +108,7 @@ uint8_t quantize1bit(int gray, int x, int y) { return (gray >= adjustedThreshold) ? 1 : 0; } -void createBmpHeader(BmpHeader* bmpHeader, int width, int height) { +void createBmpHeader(BmpHeader* bmpHeader, int width, int height, BmpRowOrder rowOrder) { if (!bmpHeader) return; // Zero out the memory to ensure no garbage data if called on uninitialized stack memory @@ -126,15 +126,15 @@ void createBmpHeader(BmpHeader* bmpHeader, int width, int height) { bmpHeader->infoHeader.biSize = sizeof(bmpHeader->infoHeader); bmpHeader->infoHeader.biWidth = width; - bmpHeader->infoHeader.biHeight = height; + bmpHeader->infoHeader.biHeight = (rowOrder == BmpRowOrder::TopDown) ? -height : height; bmpHeader->infoHeader.biPlanes = 1; bmpHeader->infoHeader.biBitCount = 1; bmpHeader->infoHeader.biCompression = 0; bmpHeader->infoHeader.biSizeImage = imageSize; - bmpHeader->infoHeader.biXPelsPerMeter = 0; - bmpHeader->infoHeader.biYPelsPerMeter = 0; - bmpHeader->infoHeader.biClrUsed = 0; - bmpHeader->infoHeader.biClrImportant = 0; + bmpHeader->infoHeader.biXPelsPerMeter = 2835; // 72 DPI + bmpHeader->infoHeader.biYPelsPerMeter = 2835; // 72 DPI + bmpHeader->infoHeader.biClrUsed = 2; + bmpHeader->infoHeader.biClrImportant = 2; // Color 0 (black) bmpHeader->colors[0].rgbBlue = 0; diff --git a/lib/GfxRenderer/BitmapHelpers.h b/lib/GfxRenderer/BitmapHelpers.h index 8f49124c8..d9d2d8554 100644 --- a/lib/GfxRenderer/BitmapHelpers.h +++ b/lib/GfxRenderer/BitmapHelpers.h @@ -11,8 +11,10 @@ uint8_t quantizeSimple(int gray); uint8_t quantize1bit(int gray, int x, int y); int adjustPixel(int gray); +enum class BmpRowOrder { BottomUp, TopDown }; + // Populates a 1-bit BMP header in the provided memory. -void createBmpHeader(BmpHeader* bmpHeader, int width, int height); +void createBmpHeader(BmpHeader* bmpHeader, int width, int height, BmpRowOrder rowOrder); // 1-bit Atkinson dithering - better quality than noise dithering for thumbnails // Error distribution pattern (same as 2-bit but quantizes to 2 levels): diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index 53d32cac3..71e5f28c2 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -7,6 +7,7 @@ #include "Xtc.h" +#include #include #include @@ -172,52 +173,12 @@ bool Xtc::generateCoverBmp() const { return false; } - // Write BMP header - // BMP file header (14 bytes) - const uint32_t rowSize = ((pageInfo.width + 31) / 32) * 4; // Row size aligned to 4 bytes - const uint32_t imageSize = rowSize * pageInfo.height; - const uint32_t fileSize = 14 + 40 + 8 + imageSize; // Header + DIB + palette + data + // Write 1-bit BMP header (top-down row order) + BmpHeader bmpHeader; + createBmpHeader(&bmpHeader, pageInfo.width, pageInfo.height, BmpRowOrder::TopDown); + coverBmp.write(reinterpret_cast(&bmpHeader), sizeof(bmpHeader)); - // File header - coverBmp.write('B'); - coverBmp.write('M'); - coverBmp.write(reinterpret_cast(&fileSize), 4); - uint32_t reserved = 0; - coverBmp.write(reinterpret_cast(&reserved), 4); - uint32_t dataOffset = 14 + 40 + 8; // 1-bit palette has 2 colors (8 bytes) - coverBmp.write(reinterpret_cast(&dataOffset), 4); - - // DIB header (BITMAPINFOHEADER - 40 bytes) - uint32_t dibHeaderSize = 40; - coverBmp.write(reinterpret_cast(&dibHeaderSize), 4); - int32_t width = pageInfo.width; - coverBmp.write(reinterpret_cast(&width), 4); - int32_t height = -static_cast(pageInfo.height); // Negative for top-down - coverBmp.write(reinterpret_cast(&height), 4); - uint16_t planes = 1; - coverBmp.write(reinterpret_cast(&planes), 2); - uint16_t bitsPerPixel = 1; // 1-bit monochrome - coverBmp.write(reinterpret_cast(&bitsPerPixel), 2); - uint32_t compression = 0; // BI_RGB (no compression) - coverBmp.write(reinterpret_cast(&compression), 4); - coverBmp.write(reinterpret_cast(&imageSize), 4); - int32_t ppmX = 2835; // 72 DPI - coverBmp.write(reinterpret_cast(&ppmX), 4); - int32_t ppmY = 2835; - coverBmp.write(reinterpret_cast(&ppmY), 4); - uint32_t colorsUsed = 2; - coverBmp.write(reinterpret_cast(&colorsUsed), 4); - uint32_t colorsImportant = 2; - coverBmp.write(reinterpret_cast(&colorsImportant), 4); - - // Color palette (2 colors for 1-bit) - // XTC 1-bit polarity: 0 = black, 1 = white (standard BMP palette order) - // Color 0: Black (text/foreground in XTC) - uint8_t black[4] = {0x00, 0x00, 0x00, 0x00}; - coverBmp.write(black, 4); - // Color 1: White (background in XTC) - uint8_t white[4] = {0xFF, 0xFF, 0xFF, 0x00}; - coverBmp.write(white, 4); + const uint32_t rowSize = ((pageInfo.width + 31) / 32) * 4; // Write bitmap data // BMP requires 4-byte row alignment @@ -400,49 +361,12 @@ bool Xtc::generateThumbBmp(int height) const { return false; } - // Write 1-bit BMP header for fast home screen rendering - const uint32_t rowSize = (thumbWidth + 31) / 32 * 4; // 1 bit per pixel, aligned to 4 bytes - const uint32_t imageSize = rowSize * thumbHeight; - const uint32_t fileSize = 14 + 40 + 8 + imageSize; // 8 bytes for 2-color palette + // Write 1-bit BMP header (top-down row order) + BmpHeader bmpHeader; + createBmpHeader(&bmpHeader, thumbWidth, thumbHeight, BmpRowOrder::TopDown); + thumbBmp.write(reinterpret_cast(&bmpHeader), sizeof(bmpHeader)); - // File header - thumbBmp.write('B'); - thumbBmp.write('M'); - thumbBmp.write(reinterpret_cast(&fileSize), 4); - uint32_t reserved = 0; - thumbBmp.write(reinterpret_cast(&reserved), 4); - uint32_t dataOffset = 14 + 40 + 8; // 1-bit palette has 2 colors (8 bytes) - thumbBmp.write(reinterpret_cast(&dataOffset), 4); - - // DIB header - uint32_t dibHeaderSize = 40; - thumbBmp.write(reinterpret_cast(&dibHeaderSize), 4); - int32_t widthVal = thumbWidth; - thumbBmp.write(reinterpret_cast(&widthVal), 4); - int32_t heightVal = -static_cast(thumbHeight); // Negative for top-down - thumbBmp.write(reinterpret_cast(&heightVal), 4); - uint16_t planes = 1; - thumbBmp.write(reinterpret_cast(&planes), 2); - uint16_t bitsPerPixel = 1; // 1-bit for black and white - thumbBmp.write(reinterpret_cast(&bitsPerPixel), 2); - uint32_t compression = 0; - thumbBmp.write(reinterpret_cast(&compression), 4); - thumbBmp.write(reinterpret_cast(&imageSize), 4); - int32_t ppmX = 2835; - thumbBmp.write(reinterpret_cast(&ppmX), 4); - int32_t ppmY = 2835; - thumbBmp.write(reinterpret_cast(&ppmY), 4); - uint32_t colorsUsed = 2; - thumbBmp.write(reinterpret_cast(&colorsUsed), 4); - uint32_t colorsImportant = 2; - thumbBmp.write(reinterpret_cast(&colorsImportant), 4); - - // Color palette (2 colors for 1-bit: black and white) - uint8_t palette[8] = { - 0x00, 0x00, 0x00, 0x00, // Color 0: Black - 0xFF, 0xFF, 0xFF, 0x00 // Color 1: White - }; - thumbBmp.write(palette, 8); + const uint32_t rowSize = (thumbWidth + 31) / 32 * 4; // Allocate row buffer for 1-bit output uint8_t* rowBuffer = static_cast(malloc(rowSize)); diff --git a/src/util/ScreenshotUtil.cpp b/src/util/ScreenshotUtil.cpp index 0f14f5060..1a564a61b 100644 --- a/src/util/ScreenshotUtil.cpp +++ b/src/util/ScreenshotUtil.cpp @@ -62,7 +62,7 @@ bool ScreenshotUtil::saveFramebufferAsBmp(const char* filename, const uint8_t* f BmpHeader header; - createBmpHeader(&header, phyWidth, phyHeight); + createBmpHeader(&header, phyWidth, phyHeight, BmpRowOrder::BottomUp); bool write_error = false; if (file.write(reinterpret_cast(&header), sizeof(header)) != sizeof(header)) { From 14ec53a33556b9084600b066fcc299ffd222783b Mon Sep 17 00:00:00 2001 From: thehijacker Date: Fri, 10 Apr 2026 18:00:32 +0200 Subject: [PATCH 17/37] feat: Added Slovenian translation (#1551) Adde ## Summary * **What is the goal of this PR?** Adding Slovenian translation * **What changes are included?** Just new slovenian.yaml file with translated strings ## Additional Context I kindly ask to include it in next release. --- ### 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? _**< NO >**_ --------- Co-authored-by: Andrej Kralj Co-authored-by: Uri Tauber <142022451+Uri-Tauber@users.noreply.github.com> --- lib/I18n/translations/slovenian.yaml | 292 +++++++++++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 lib/I18n/translations/slovenian.yaml diff --git a/lib/I18n/translations/slovenian.yaml b/lib/I18n/translations/slovenian.yaml new file mode 100644 index 000000000..195a5ac01 --- /dev/null +++ b/lib/I18n/translations/slovenian.yaml @@ -0,0 +1,292 @@ +_language_name: "Slovenščina" +_language_code: "SI" +_order: "21" + +STR_CROSSPOINT: "CrossPoint" +STR_BOOTING: "ZAGON" +STR_SLEEPING: "SPANJE" +STR_ENTERING_SLEEP: "Prehajanje v spanje" +STR_BROWSE_FILES: "Prebrskaj datoteke" +STR_FILE_TRANSFER: "Prenos datotek" +STR_SETTINGS_TITLE: "Nastavitve" +STR_CONTINUE_READING: "Nadaljuj z branjem" +STR_NO_OPEN_BOOK: "Ni odprte knjige" +STR_START_READING: "Začni brati spodaj" +STR_NO_FILES_FOUND: "Ni najdenih datotek" +STR_SELECT_CHAPTER: "Izberi poglavje" +STR_NO_CHAPTERS: "Ni poglavij" +STR_END_OF_BOOK: "Konec knjige" +STR_EMPTY_CHAPTER: "Prazno poglavje" +STR_INDEXING: "Indeksiranje" +STR_MEMORY_ERROR: "Napaka pomnilnika" +STR_PAGE_LOAD_ERROR: "Napaka pri nalaganju strani" +STR_EMPTY_FILE: "Prazna datoteka" +STR_OUT_OF_BOUNDS: "Izven meja" +STR_LOADING: "Nalaganje..." +STR_LOADING_POPUP: "Nalaganje" +STR_WIFI_NETWORKS: "WiFi omrežja" +STR_NO_NETWORKS: "Ni najdenih omrežij" +STR_NETWORKS_FOUND: "Najdenih omrežij: %zu" +STR_SCANNING: "Iskanje..." +STR_CONNECTING: "Povezovanje..." +STR_CONNECTED: "Povezano!" +STR_CONNECTION_FAILED: "Povezava ni uspela" +STR_FORGET_NETWORK: "Pozabi omrežje?" +STR_SAVE_PASSWORD: "Shranim geslo za naslednjič?" +STR_PRESS_OK_SCAN: "Pritisni OK za ponovno iskanje" +STR_JOIN_NETWORK: "Poveži se v omrežje" +STR_CREATE_HOTSPOT: "Ustvari dostopno točko" +STR_JOIN_DESC: "Poveži se v obstoječe WiFi omrežje" +STR_HOTSPOT_DESC: "Ustvari WiFi omrežje, v katerega se lahko povežejo drugi" +STR_STARTING_HOTSPOT: "Zaganjanje dostopne točke..." +STR_HOTSPOT_MODE: "Način dostopne točke" +STR_CONNECT_WIFI_HINT: "Poveži svojo napravo v to WiFi omrežje" +STR_OPEN_URL_HINT: "Odpri ta URL v svojem brskalniku" +STR_OR_HTTP_PREFIX: "ali http://" +STR_SCAN_QR_HINT: "ali skeniraj QR kodo s telefonom:" +STR_CALIBRE_WIRELESS: "Brezžični Calibre" +STR_CALIBRE_WEB_URL: "Calibre Web URL" +STR_NETWORK_LEGEND: "* = Šifrirano | + = Shranjeno" +STR_MAC_ADDRESS: "MAC naslov:" +STR_CHECKING_WIFI: "Preverjanje WiFi-ja..." +STR_ENTER_WIFI_PASSWORD: "Vnesi WiFi geslo" +STR_TO_PREFIX: "v " +STR_CALIBRE_RECEIVING: "Prejemanje: " +STR_CALIBRE_RECEIVED: "Prejeto: " +STR_CALIBRE_INSTRUCTION_1: "1) Namesti vtičnik CrossPoint Reader" +STR_CALIBRE_INSTRUCTION_2: "2) Bodi v istem WiFi omrežju" +STR_CALIBRE_INSTRUCTION_3: "3) V Calibre: \"Pošlji v napravo\"" +STR_CALIBRE_INSTRUCTION_4: "\"Med pošiljanjem pusti ta zaslon odprt\"" +STR_CAT_DISPLAY: "Zaslon" +STR_CAT_READER: "Bralnik" +STR_CAT_CONTROLS: "Kontrole" +STR_CAT_SYSTEM: "Sistem" +STR_SLEEP_SCREEN: "Zaslon za spanje" +STR_SLEEP_COVER_MODE: "Način naslovnice v spanju" +STR_HIDE_BATTERY: "Skrij % baterije" +STR_EXTRA_SPACING: "Dodaten razmik med odstavki" +STR_TEXT_AA: "Glajenje besedila (AA)" +STR_IMAGES: "Slike" +STR_IMAGES_DISPLAY: "Prikaži" +STR_IMAGES_PLACEHOLDER: "Oznaka mesta" +STR_IMAGES_SUPPRESS: "Zatdi" +STR_SHORT_PWR_BTN: "Kratek pritisk na gumb za vklop" +STR_ORIENTATION: "Orientacija branja" +STR_SIDE_BTN_LAYOUT: "Razpored stranskih gumbov" +STR_LONG_PRESS_SKIP: "Dolgi pritisk za preskok poglavja" +STR_FONT_FAMILY: "Pisava bralnika" +STR_FONT_SIZE: "Velikost pisave" +STR_LINE_SPACING: "Razmik med vrsticami" +STR_SCREEN_MARGIN: "Robovi zaslona" +STR_PARA_ALIGNMENT: "Poravnava odstavkov" +STR_HYPHENATION: "Deljenje besed" +STR_TIME_TO_SLEEP: "Čas do spanja" +STR_SHOW_HIDDEN_FILES: "Prikaži skrite datoteke" +STR_REFRESH_FREQ: "Pogostost osveževanja" +STR_KOREADER_SYNC: "KOReader sinhronizacija" +STR_CHECK_UPDATES: "Preveri posodobitve" +STR_LANGUAGE: "Jezik" +STR_CLEAR_READING_CACHE: "Počisti predpomnilnik branja" +STR_USERNAME: "Uporabniško ime" +STR_PASSWORD: "Geslo" +STR_SYNC_SERVER_URL: "URL strežnika za sinhronizacijo" +STR_DOCUMENT_MATCHING: "Ujemanje dokumentov" +STR_AUTHENTICATE: "Avtentikacija" +STR_KOREADER_USERNAME: "KOReader uporabnik" +STR_KOREADER_PASSWORD: "KOReader geslo" +STR_FILENAME: "Ime datoteke" +STR_BINARY: "Binarno" +STR_SET_CREDENTIALS_FIRST: "Najprej nastavi podatke za prijavo" +STR_WIFI_CONN_FAILED: "WiFi povezava ni uspela" +STR_AUTHENTICATING: "Preverjanje..." +STR_AUTH_SUCCESS: "Uspešna prijava!" +STR_KOREADER_AUTH: "KOReader avtentikacija" +STR_SYNC_READY: "KOReader sinhronizacija je pripravljena" +STR_AUTH_FAILED: "Prijava ni uspela" +STR_DONE: "Končano" +STR_CLEAR_CACHE_WARNING_1: "To bo izbrisalo vse predpomnjene podatke o knjigah." +STR_CLEAR_CACHE_WARNING_2: "Ves napredek pri branju bo izgubljen!" +STR_CLEAR_CACHE_WARNING_3: "Knjige bo treba ob ponovnem odpiranju" +STR_CLEAR_CACHE_WARNING_4: "ponovno indeksirati." +STR_CLEARING_CACHE: "Čiščenje predpomnilnika..." +STR_CACHE_CLEARED: "Predpomnilnik očiščen" +STR_ITEMS_REMOVED: "elementov odstranjenih" +STR_FAILED_LOWER: "ni uspelo" +STR_CLEAR_CACHE_FAILED: "Čiščenje predpomnilnika ni uspelo" +STR_CHECK_SERIAL_OUTPUT: "Za podrobnosti preveri serijski izhod" +STR_DARK: "Temno" +STR_LIGHT: "Svetlo" +STR_CUSTOM: "Po meri" +STR_COVER: "Naslovnica" +STR_NONE_OPT: "Brez" +STR_FIT: "Prilagodi" +STR_CROP: "Obreži" +STR_NEVER: "Nikoli" +STR_IN_READER: "V bralniku" +STR_ALWAYS: "Vedno" +STR_IGNORE: "Prezri" +STR_SLEEP: "Spanje" +STR_PAGE_TURN: "Obračanje strani" +STR_PORTRAIT: "Pokončno" +STR_LANDSCAPE_CW: "Ležeče (v smeri urinega kazalca)" +STR_INVERTED: "Obrnjeno" +STR_LANDSCAPE_CCW: "Ležeče (proti smeri urinega kazalca)" +STR_PREV_NEXT: "Nazaj/Naprej" +STR_NEXT_PREV: "Naprej/Nazaj" +STR_BOOKERLY: "Bookerly" +STR_NOTO_SANS: "Noto Sans" +STR_OPEN_DYSLEXIC: "Open Dyslexic" +STR_SMALL: "Majhno" +STR_MEDIUM: "Srednje" +STR_LARGE: "Veliko" +STR_X_LARGE: "Zelo veliko" +STR_TIGHT: "Tesno" +STR_NORMAL: "Normalno" +STR_WIDE: "Široko" +STR_JUSTIFY: "Obojestransko" +STR_ALIGN_LEFT: "Levo" +STR_CENTER: "Sredinsko" +STR_ALIGN_RIGHT: "Desno" +STR_MIN_1: "1 min" +STR_MIN_5: "5 min" +STR_MIN_10: "10 min" +STR_MIN_15: "15 min" +STR_MIN_30: "30 min" +STR_PAGES_1: "1 stran" +STR_PAGES_5: "5 strani" +STR_PAGES_10: "10 strani" +STR_PAGES_15: "15 strani" +STR_PAGES_30: "30 strani" +STR_UPDATE: "Posodobi" +STR_CHECKING_UPDATE: "Preverjanje posodobitev..." +STR_NEW_UPDATE: "Na voljo je nova posodobitev!" +STR_CURRENT_VERSION: "Trenutna različica: " +STR_NEW_VERSION: "Nova različica: " +STR_UPDATING: "Posodabljanje..." +STR_NO_UPDATE: "Ni novih posodobitev" +STR_UPDATE_FAILED: "Posodobitev ni uspela" +STR_UPDATE_COMPLETE: "Posodobitev končana" +STR_POWER_ON_HINT: "Pridrži gumb za vklop, da napravo znova vklopiš" +STR_NO_ENTRIES: "Ni najdenih vnosov" +STR_DOWNLOADING: "Prenašanje..." +STR_DOWNLOAD_FAILED: "Prenos ni uspel" +STR_ERROR_MSG: "Napaka:" +STR_UNNAMED: "Neimenovano" +STR_NO_SERVER_URL: "URL strežnika ni nastavljen" +STR_FETCH_FEED_FAILED: "Nalaganje vira ni uspelo" +STR_PARSE_FEED_FAILED: "Razčlenjevanje vira ni uspelo" +STR_NETWORK_PREFIX: "Omrežje: " +STR_IP_ADDRESS_PREFIX: "IP naslov: " +STR_ERROR_GENERAL_FAILURE: "Napaka: Splošna napaka" +STR_ERROR_NETWORK_NOT_FOUND: "Napaka: Omrežje ni najdeno" +STR_ERROR_CONNECTION_TIMEOUT: "Napaka: Časovna omejitev povezave" +STR_SD_CARD: "SD kartica" +STR_BACK: "« Nazaj" +STR_EXIT: "« Izhod" +STR_HOME: "« Domov" +STR_SELECT: "Izberi" +STR_SELECTED: "Izbrano" +STR_TOGGLE: "Preklopi" +STR_CONFIRM: "Potrdi" +STR_CANCEL: "Prekliči" +STR_CONNECT: "Poveži" +STR_OPEN: "Odpri" +STR_DOWNLOAD: "Prenesi" +STR_RETRY: "Poskusi znova" +STR_YES: "Da" +STR_NO: "Ne" +STR_SHOW: "Prikaži" +STR_HIDE: "Skrij" +STR_STATE_ON: "VKLOP" +STR_STATE_OFF: "IZKLOP" +STR_NOT_SET: "Ni nastavljeno" +STR_DIR_LEFT: "Levo" +STR_DIR_RIGHT: "Desno" +STR_DIR_UP: "Gor" +STR_DIR_DOWN: "Dol" +STR_OK_BUTTON: "V redu" +STR_SLEEP_COVER_FILTER: "Filter naslovnice v spanju" +STR_FILTER_CONTRAST: "Kontrast" +STR_CUSTOMISE_STATUS_BAR: "Prilagodi vrstico stanja" +STR_CHAPTER_PAGE_COUNT: "Število strani v poglavju" +STR_BOOK_PROGRESS_PERCENTAGE: "Odstotek napredka v knjigi" +STR_PROGRESS_BAR: "Vrstica napredka" +STR_PROGRESS_BAR_THICKNESS: "Debelina vrstice napredka" +STR_PROGRESS_BAR_THIN: "Tanko" +STR_PROGRESS_BAR_MEDIUM: "Srednje" +STR_PROGRESS_BAR_THICK: "Debelo" +STR_BOOK: "Knjiga" +STR_CHAPTER: "Poglavje" +STR_EXAMPLE_CHAPTER: "Poglavje 21" +STR_EXAMPLE_BOOK: "Naslov knjige" +STR_PREVIEW: "Predogled" +STR_TITLE: "Naslov" +STR_BATTERY: "Baterija" +STR_UI_THEME: "Tema uporabniškega vmesnika" +STR_THEME_CLASSIC: "Klasična" +STR_THEME_LYRA: "Lyra" +STR_THEME_LYRA_EXTENDED: "Lyra razširjena" +STR_SUNLIGHT_FADING_FIX: "Popravek bledenja na soncu" +STR_REMAP_FRONT_BUTTONS: "Prenastavi sprednje gumbe" +STR_OPDS_BROWSER: "OPDS brskalnik" +STR_COVER_CUSTOM: "Naslovnica + po meri" +STR_MENU_RECENT_BOOKS: "Zadnje knjige" +STR_NO_RECENT_BOOKS: "Ni zadnjih knjig" +STR_CALIBRE_DESC: "Uporabi brezžični prenos Calibre" +STR_FORGET_AND_REMOVE: "Pozabi omrežje in odstrani shranjeno geslo?" +STR_FORGET_BUTTON: "Pozabi" +STR_CALIBRE_STARTING: "Zaganjanje Calibre..." +STR_CALIBRE_SETUP: "Nastavitev" +STR_CALIBRE_STATUS: "Stanje" +STR_CLEAR_BUTTON: "Počisti" +STR_DEFAULT_VALUE: "Privzeto" +STR_REMAP_PROMPT: "Pritisni sprednji gumb za vsako vlogo" +STR_UNASSIGNED: "Nedodeljeno" +STR_ALREADY_ASSIGNED: "Že dodeljeno" +STR_REMAP_RESET_HINT: "Stranski gumb gor: Ponastavi na privzeto" +STR_REMAP_CANCEL_HINT: "Stranski gumb dol: Prekliči nastavljanje" +STR_HW_BACK_LABEL: "Nazaj (1. gumb)" +STR_HW_CONFIRM_LABEL: "Potrdi (2. gumb)" +STR_HW_LEFT_LABEL: "Levo (3. gumb)" +STR_HW_RIGHT_LABEL: "Desno (4. gumb)" +STR_GO_TO_PERCENT: "Pojdi na %" +STR_GO_HOME_BUTTON: "Pojdi domov" +STR_SYNC_PROGRESS: "Sinhroniziraj napredek" +STR_DELETE_CACHE: "Izbriši predpomnilnik knjige" +STR_DELETE: "Izbriši" +STR_DISPLAY_QR: "Prikaži stran kot QR" +STR_CHAPTER_PREFIX: "Poglavje: " +STR_PAGES_SEPARATOR: " strani | " +STR_BOOK_PREFIX: "Knjiga: " +STR_CALIBRE_URL_HINT: "Za Calibre dodaj /opds svojemu URL-ju" +STR_PERCENT_STEP_HINT: "Levo/desno: 1% Gor/dol: 10%" +STR_SYNCING_TIME: "Sinhronizacija časa..." +STR_CALC_HASH: "Izračunavanje podpisa dokumenta..." +STR_HASH_FAILED: "Izračun podpisa dokumenta ni uspel" +STR_FETCH_PROGRESS: "Pridobivanje napredka iz oblaka..." +STR_UPLOAD_PROGRESS: "Nalaganje napredka..." +STR_NO_CREDENTIALS_MSG: "Podatki za prijavo niso nastavljeni" +STR_KOREADER_SETUP_HINT: "Nastavi KOReader račun v nastavitvah" +STR_PROGRESS_FOUND: "Najden napredek!" +STR_REMOTE_LABEL: "Oddaljeno:" +STR_LOCAL_LABEL: "Lokalno:" +STR_PAGE_OVERALL_FORMAT: "Stran %d, %.2f%% skupno" +STR_PAGE_TOTAL_OVERALL_FORMAT: "Stran %d/%d, %.2f%% skupno" +STR_DEVICE_FROM_FORMAT: " Iz: %s" +STR_APPLY_REMOTE: "Uporabi oddaljen napredek" +STR_UPLOAD_LOCAL: "Naloži lokalni napredek" +STR_NO_REMOTE_MSG: "Oddaljen napredek ni bil najden" +STR_UPLOAD_PROMPT: "Naložim trenutno pozicijo?" +STR_UPLOAD_SUCCESS: "Napredek naložen!" +STR_SYNC_FAILED_MSG: "Sinhronizacija ni uspela" +STR_SECTION_PREFIX: "Razdelek " +STR_UPLOAD: "Naloži" +STR_BOOK_S_STYLE: "Slog knjige" +STR_EMBEDDED_STYLE: "Vgrajen slog" +STR_OPDS_SERVER_URL: "URL OPDS strežnika" +STR_FOOTNOTES: "Opombe" +STR_NO_FOOTNOTES: "Na tej strani ni opomb" +STR_LINK: "[povezava]" +STR_SCREENSHOT_BUTTON: "Naredi posnetek zaslona" +STR_AUTO_TURN_ENABLED: "Samodejno obračanje: " +STR_AUTO_TURN_PAGES_PER_MIN: "Samodejno obračanje (strani na minuto)" From 83cd96bc2fd90e5f5a8b0ceaed2022a16a017a1c Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Fri, 10 Apr 2026 15:13:59 -0500 Subject: [PATCH 18/37] fix: Wild pointer crash in JPEGDEC MCU_SKIP handling (#1627) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a PlatformIO pre-build script to patch JPEGDEC library. When decoding progressive JPEGs with AC coefficients, MCU_SKIP (-8) causes array index 0xFFFFF8, creating a wild pointer ~33MB past the sMCUs array. The patch redirects pMCU to sMCUs[0] when MCU_SKIP is active, preventing store-access faults while maintaining correct behavior for JPEG_SCALE_EIGHTH decoding. Devices with larger framebuffers (like the x3) (792×528 = 52,272 bytes vs 800×480 = 48,000 bytes) have less free heap, shifting the allocation and changing where the wild pointer lands. Commit 8628297 guarded the DC coefficient write (pMCU[0]) with if (iMCU >= 0), which prevents crashes for progressive JPEGs whose first scan is DC-only (iScanEnd == 0). However, if the first scan includes AC coefficients (iScanEnd > 0), the AC decode loop still writes through the wild pointer and crashes. --- platformio.ini | 1 + scripts/patch_jpegdec.py | 68 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 scripts/patch_jpegdec.py diff --git a/platformio.ini b/platformio.ini index c289d1133..75ee95672 100644 --- a/platformio.ini +++ b/platformio.ini @@ -51,6 +51,7 @@ extra_scripts = pre:scripts/build_html.py pre:scripts/gen_i18n.py pre:scripts/git_branch.py + pre:scripts/patch_jpegdec.py ; Libraries lib_deps = diff --git a/scripts/patch_jpegdec.py b/scripts/patch_jpegdec.py new file mode 100644 index 000000000..015b17616 --- /dev/null +++ b/scripts/patch_jpegdec.py @@ -0,0 +1,68 @@ +""" +PlatformIO pre-build script: patch JPEGDEC for MCU_SKIP wild pointer crash. + +Problem: + JPEGDecodeMCU_P computes pMCU = &sMCUs[iMCU & 0xffffff]. When iMCU is + MCU_SKIP (-8), the bitmask produces index 0xFFFFF8 (16 777 208), creating a + pointer ~33 MB past the 392-entry sMCUs array. If the progressive JPEG's + first scan includes AC coefficients (iScanEnd > 0), the AC decode loop writes + through this wild pointer and crashes with a store-access fault. + + Upstream commit 8628297 guarded the DC coefficient write (pMCU[0]) but not the + AC coefficient writes at indices 1-63. + +Fix: + Redirect pMCU to sMCUs[0] when MCU_SKIP is active. Writes to sMCUs[1..63] + are harmless: for JPEG_SCALE_EIGHTH only sMCUs[0] is read for output, and + the DC write at sMCUs[0] is already guarded by the existing `if (iMCU >= 0)` + check. + +Applied idempotently — safe to run on every build. +""" + +Import("env") +import os + + +def patch_jpegdec(env): + libdeps_dir = os.path.join(env["PROJECT_DIR"], ".pio", "libdeps") + if not os.path.isdir(libdeps_dir): + return + for env_dir in os.listdir(libdeps_dir): + jpeg_inl = os.path.join(libdeps_dir, env_dir, "JPEGDEC", "src", "jpeg.inl") + if os.path.isfile(jpeg_inl): + _apply_mcu_skip_pointer_fix(jpeg_inl) + + +def _apply_mcu_skip_pointer_fix(filepath): + MARKER = "// CrossPoint patch: safe pMCU for MCU_SKIP" + with open(filepath, "r") as f: + content = f.read() + + if MARKER in content: + return # already patched + + # The wild-pointer line in JPEGDecodeMCU_P: + OLD = " signed short *pMCU = &pJPEG->sMCUs[iMCU & 0xffffff];" + + NEW = ( + " " + MARKER + "\n" + " signed short *pMCU = (iMCU < 0) ? pJPEG->sMCUs\n" + " : &pJPEG->sMCUs[iMCU & 0xffffff];" + ) + + if OLD not in content: + print( + "WARNING: JPEGDEC MCU_SKIP pointer patch target not found in %s " + "— library may have been updated" % filepath + ) + return + + content = content.replace(OLD, NEW, 1) + with open(filepath, "w") as f: + f.write(content) + print("Patched JPEGDEC: safe pMCU for MCU_SKIP in JPEGDecodeMCU_P: %s" % filepath) + + +# Run immediately at script import time (before compilation). +patch_jpegdec(env) From 8d6b35b8e75989506cb762c821f23efdf9e45087 Mon Sep 17 00:00:00 2001 From: Jan Ivanov Date: Sat, 11 Apr 2026 20:20:46 +0300 Subject: [PATCH 19/37] fix: back navigation from BMPViewer (#1597) ## Summary This fixes navigating back from the BMP Viewer to the FileBrowser which was broken when moving to the new ActivityManager This is fixed by making FileBrowserActivity able to take a full file path on enter and splitting the basePath and fileName from it and navigating to the correct place. fixes: https://github.com/crosspoint-reader/crosspoint-reader/issues/1553 duplicates: https://github.com/crosspoint-reader/crosspoint-reader/pull/910 to some extend but mine has the file cursor at the correct file instead of the first one in the folder ## Additional Context * Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on). --- ### 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? _**PARTIALLY**_ --------- Co-authored-by: Jan Ivanov --- lib/FsHelpers/FsHelpers.cpp | 8 ++++++ lib/FsHelpers/FsHelpers.h | 2 ++ src/activities/home/FileBrowserActivity.cpp | 31 +++++++++++++++++++-- src/activities/home/FileBrowserActivity.h | 2 ++ src/activities/reader/ReaderActivity.cpp | 10 +------ src/activities/reader/ReaderActivity.h | 1 - src/activities/util/BmpViewerActivity.cpp | 2 +- 7 files changed, 43 insertions(+), 13 deletions(-) diff --git a/lib/FsHelpers/FsHelpers.cpp b/lib/FsHelpers/FsHelpers.cpp index 08ca44606..616b094b5 100644 --- a/lib/FsHelpers/FsHelpers.cpp +++ b/lib/FsHelpers/FsHelpers.cpp @@ -78,4 +78,12 @@ bool hasTxtExtension(std::string_view fileName) { return checkFileExtension(file bool hasMarkdownExtension(std::string_view fileName) { return checkFileExtension(fileName, ".md"); } +std::string extractFolderPath(const std::string& filePath) { + const auto lastSlash = filePath.find_last_of('/'); + if (lastSlash == std::string::npos || lastSlash == 0) { + return "/"; + } + return filePath.substr(0, lastSlash); +} + } // namespace FsHelpers diff --git a/lib/FsHelpers/FsHelpers.h b/lib/FsHelpers/FsHelpers.h index a21135127..f8af636a0 100644 --- a/lib/FsHelpers/FsHelpers.h +++ b/lib/FsHelpers/FsHelpers.h @@ -55,4 +55,6 @@ inline bool hasTxtExtension(const String& fileName) { // Check for .md extension (case-insensitive) bool hasMarkdownExtension(std::string_view fileName); +std::string extractFolderPath(const std::string& filePath); + } // namespace FsHelpers diff --git a/src/activities/home/FileBrowserActivity.cpp b/src/activities/home/FileBrowserActivity.cpp index 9ac714ebb..723a3e199 100644 --- a/src/activities/home/FileBrowserActivity.cpp +++ b/src/activities/home/FileBrowserActivity.cpp @@ -108,9 +108,28 @@ void FileBrowserActivity::loadFiles() { void FileBrowserActivity::onEnter() { Activity::onEnter(); - loadFiles(); selectorIndex = 0; + auto root = Storage.open(basepath.c_str()); + if (!root) { + basepath = "/"; + loadFiles(); + } else if (!root.isDirectory()) { + root.close(); + lockLongPressBack = mappedInput.isPressed(MappedInputManager::Button::Back); + + const std::string oldPath = basepath; + basepath = FsHelpers::extractFolderPath(basepath); + loadFiles(); + + const auto pos = oldPath.find_last_of('/'); + const std::string fileName = oldPath.substr(pos + 1); + selectorIndex = findEntry(fileName); + } else { + root.close(); + loadFiles(); + } + requestUpdate(); } @@ -129,11 +148,19 @@ void FileBrowserActivity::clearFileMetadata(const std::string& fullPath) { void FileBrowserActivity::loop() { // Long press BACK (1s+) goes to root folder + // but Long press BACK (1s+) from ReaderActivity sends us here with the MappedInput already set. + // So ignore it the first time. if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= GO_HOME_MS && - basepath != "/") { + basepath != "/" && !lockLongPressBack) { basepath = "/"; loadFiles(); selectorIndex = 0; + requestUpdate(); + return; + } + + if (lockLongPressBack && mappedInput.wasReleased(MappedInputManager::Button::Back)) { + lockLongPressBack = false; return; } diff --git a/src/activities/home/FileBrowserActivity.h b/src/activities/home/FileBrowserActivity.h index c4f359ddf..a8235088b 100644 --- a/src/activities/home/FileBrowserActivity.h +++ b/src/activities/home/FileBrowserActivity.h @@ -17,6 +17,8 @@ class FileBrowserActivity final : public Activity { size_t selectorIndex = 0; + bool lockLongPressBack = false; + // Files state std::string basepath = "/"; std::vector files; diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 2164a7f66..989e6d223 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -13,14 +13,6 @@ #include "activities/util/BmpViewerActivity.h" #include "activities/util/FullScreenMessageActivity.h" -std::string ReaderActivity::extractFolderPath(const std::string& filePath) { - const auto lastSlash = filePath.find_last_of('/'); - if (lastSlash == std::string::npos || lastSlash == 0) { - return "/"; - } - return filePath.substr(0, lastSlash); -} - bool ReaderActivity::isXtcFile(const std::string& path) { return FsHelpers::hasXtcExtension(path); } bool ReaderActivity::isTxtFile(const std::string& path) { @@ -77,7 +69,7 @@ std::unique_ptr ReaderActivity::loadTxt(const std::string& path) { void ReaderActivity::goToLibrary(const std::string& fromBookPath) { // If coming from a book, start in that book's folder; otherwise start from root - auto initialPath = fromBookPath.empty() ? "/" : extractFolderPath(fromBookPath); + auto initialPath = fromBookPath.empty() ? "/" : FsHelpers::extractFolderPath(fromBookPath); activityManager.goToFileBrowser(std::move(initialPath)); } diff --git a/src/activities/reader/ReaderActivity.h b/src/activities/reader/ReaderActivity.h index 6a3756db2..f5c61a393 100644 --- a/src/activities/reader/ReaderActivity.h +++ b/src/activities/reader/ReaderActivity.h @@ -18,7 +18,6 @@ class ReaderActivity final : public Activity { static bool isTxtFile(const std::string& path); static bool isBmpFile(const std::string& path); - static std::string extractFolderPath(const std::string& filePath); void goToLibrary(const std::string& fromBookPath = ""); void onGoToEpubReader(std::unique_ptr epub); void onGoToXtcReader(std::unique_ptr xtc); diff --git a/src/activities/util/BmpViewerActivity.cpp b/src/activities/util/BmpViewerActivity.cpp index 51fd12b10..62a02ce67 100644 --- a/src/activities/util/BmpViewerActivity.cpp +++ b/src/activities/util/BmpViewerActivity.cpp @@ -94,7 +94,7 @@ void BmpViewerActivity::loop() { Activity::loop(); if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { - onGoHome(); + activityManager.goToFileBrowser(filePath); return; } } \ No newline at end of file From 5c12f2f01ea744e5e453d5237e4e90244614202a Mon Sep 17 00:00:00 2001 From: Mraulio Date: Sat, 11 Apr 2026 19:28:00 +0200 Subject: [PATCH 20/37] fix: avoid skipping chapter after screenshot (#1625) ## Summary * **What is the goal of this PR?** Fixes skipping chapter when it's enabled in Settings and you take a Screenshot. * **What changes are included?** A simple return if Power and Down were released before the skipChapter. ## Additional Context * There is an Issue related #1595. * Checked that it kept the Power button funcionality for Next page and Suspend. --- ### 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? _**NO**_ --- src/activities/reader/EpubReaderActivity.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 65dbe17c8..f6d58bfc4 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -195,6 +195,11 @@ void EpubReaderActivity::loop() { const bool skipChapter = SETTINGS.longPressChapterSkip && mappedInput.getHeldTime() > skipChapterMs; + // Don't skip chapter after screenshot + if (gpio.wasReleased(HalGPIO::BTN_POWER) && gpio.wasReleased(HalGPIO::BTN_DOWN)) { + return; + } + if (skipChapter) { lastPageTurnTime = millis(); // We don't want to delete the section mid-render, so grab the semaphore From fa2a3d25393414a75620d22abc1105dd5703240e Mon Sep 17 00:00:00 2001 From: rxmmah <115156210+rxmmah@users.noreply.github.com> Date: Sun, 12 Apr 2026 18:24:27 +0200 Subject: [PATCH 21/37] feat: add OPDS search support & next/prev page navigation (#1462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary **What is the goal of this PR?** Adds OPDS search support, allowing users to search a catalog directly from the book browser when the server exposes an OpenSearch template. **What changes are included?** - `OpdsParser`: parses the OpenSearch template URL from feed-level `` elements and exposes it via `getSearchTemplate()` - `OpdsBookBrowserActivity`: fetches and stores the search template after each feed load; shows a Search hint on the Left button when a template is available; launches the existing `KeyboardEntryActivity` for query input; URL-encodes the query and fetches the result feed - Absolute search result URLs are handled correctly in `fetchFeed` (skips prepending the server base URL) - A `consumeConfirm` guard prevents the Confirm release that submits the keyboard from immediately triggering a book download on the first browsing frame after search results load ## Additional Context - Search is silently unavailable if the server does not advertise an OpenSearch template — no UI change in that case - Tested against a Calibre-Web OPDS endpoint which exposes `` - The inline URL encoder in `performSearch` was necessary as `StringUtils` has no such utility; worth considering extracting to `StringUtils` in a follow-up - No new dependencies introduced --- ### 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? _**NO**_ --------- Co-authored-by: kira Co-authored-by: Justin Mitchell --- lib/I18n/translations/belarusian.yaml | 2 +- lib/I18n/translations/catalan.yaml | 2 +- lib/I18n/translations/czech.yaml | 2 +- lib/I18n/translations/danish.yaml | 2 +- lib/I18n/translations/dutch.yaml | 2 +- lib/I18n/translations/english.yaml | 5 +- lib/I18n/translations/finnish.yaml | 2 +- lib/I18n/translations/french.yaml | 2 +- lib/I18n/translations/german.yaml | 2 +- lib/I18n/translations/hungarian.yaml | 2 +- lib/I18n/translations/italian.yaml | 2 +- lib/I18n/translations/kazakh.yaml | 2 +- lib/I18n/translations/lithuanian.yaml | 2 +- lib/I18n/translations/polish.yaml | 2 +- lib/I18n/translations/portuguese.yaml | 2 +- lib/I18n/translations/romanian.yaml | 2 +- lib/I18n/translations/russian.yaml | 2 +- lib/I18n/translations/spanish.yaml | 2 +- lib/I18n/translations/swedish.yaml | 2 +- lib/I18n/translations/turkish.yaml | 2 +- lib/I18n/translations/ukrainian.yaml | 2 +- lib/OpdsParser/OpdsParser.cpp | 155 ++++------- lib/OpdsParser/OpdsParser.h | 6 + .../browser/OpdsBookBrowserActivity.cpp | 258 ++++++++---------- .../browser/OpdsBookBrowserActivity.h | 21 +- 25 files changed, 208 insertions(+), 277 deletions(-) diff --git a/lib/I18n/translations/belarusian.yaml b/lib/I18n/translations/belarusian.yaml index e17f3bad2..cf1d2de74 100644 --- a/lib/I18n/translations/belarusian.yaml +++ b/lib/I18n/translations/belarusian.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Адкрыйце гэты адрас у браўзеры" STR_OR_HTTP_PREFIX: "або http://" STR_SCAN_QR_HINT: "або адсканіруйце QR-код:" STR_CALIBRE_WIRELESS: "Calibre па Wi-Fi" -STR_CALIBRE_WEB_URL: "Вэб-адрас Calibre" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Абаронена | + = Захавана" STR_MAC_ADDRESS: "MAC-адрас:" STR_CHECKING_WIFI: "Праверка Wi-Fi..." diff --git a/lib/I18n/translations/catalan.yaml b/lib/I18n/translations/catalan.yaml index a585019f1..614821a21 100644 --- a/lib/I18n/translations/catalan.yaml +++ b/lib/I18n/translations/catalan.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Obriu aquest URL al navegador" STR_OR_HTTP_PREFIX: "o http://" STR_SCAN_QR_HINT: "o escanegeu el codi QR amb el telèfon:" STR_CALIBRE_WIRELESS: "Calibre sense fils" -STR_CALIBRE_WEB_URL: "URL web del Calibre" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Encriptat | + = Desat" STR_MAC_ADDRESS: "Adreça MAC:" STR_CHECKING_WIFI: "S'està comprovant el WiFi..." diff --git a/lib/I18n/translations/czech.yaml b/lib/I18n/translations/czech.yaml index 2add9e737..06261a26c 100644 --- a/lib/I18n/translations/czech.yaml +++ b/lib/I18n/translations/czech.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Otevřete tuto URL ve svém prohlížeči" STR_OR_HTTP_PREFIX: "nebo http://" STR_SCAN_QR_HINT: "nebo naskenujte QR kód telefonem:" STR_CALIBRE_WIRELESS: "Calibre Wireless" -STR_CALIBRE_WEB_URL: "URL webu Calibre" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Šifrováno | + = Uloženo" STR_MAC_ADDRESS: "MAC adresa:" STR_CHECKING_WIFI: "Kontrola WiFi..." diff --git a/lib/I18n/translations/danish.yaml b/lib/I18n/translations/danish.yaml index 3e4704c91..807d98312 100644 --- a/lib/I18n/translations/danish.yaml +++ b/lib/I18n/translations/danish.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Åbn denne URL i din browser" STR_OR_HTTP_PREFIX: "eller http://" STR_SCAN_QR_HINT: "eller scan QR-kode med din telefon:" STR_CALIBRE_WIRELESS: "Calibre Wireless" -STR_CALIBRE_WEB_URL: "Calibre Web URL" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Krypteret | + = Gemt" STR_MAC_ADDRESS: "MAC-adresse:" STR_CHECKING_WIFI: "Tjekker WiFi..." diff --git a/lib/I18n/translations/dutch.yaml b/lib/I18n/translations/dutch.yaml index 73e44d61a..38b88d77c 100644 --- a/lib/I18n/translations/dutch.yaml +++ b/lib/I18n/translations/dutch.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Open deze URL in je browser" STR_OR_HTTP_PREFIX: "of http://" STR_SCAN_QR_HINT: "of scan de QR-code met je telefoon:" STR_CALIBRE_WIRELESS: "Calibre Wireless" -STR_CALIBRE_WEB_URL: "Calibre Web URL" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Beveiligd | + = Opgeslagen" STR_MAC_ADDRESS: "MAC-adres:" STR_CHECKING_WIFI: "Wifi controleren..." diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index df91c1a1c..b741a85d0 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Open this URL in your browser" STR_OR_HTTP_PREFIX: "or http://" STR_SCAN_QR_HINT: "or scan QR code with your phone:" STR_CALIBRE_WIRELESS: "Calibre Wireless" -STR_CALIBRE_WEB_URL: "Calibre Web URL" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Encrypted | + = Saved" STR_MAC_ADDRESS: "MAC address:" STR_CHECKING_WIFI: "Checking WiFi..." @@ -175,6 +175,8 @@ STR_UNNAMED: "Unnamed" STR_NO_SERVER_URL: "No server URL configured" STR_FETCH_FEED_FAILED: "Failed to fetch feed" STR_PARSE_FEED_FAILED: "Failed to parse feed" +STR_NEXT_PAGE: "Next Page »" +STR_PREV_PAGE: "« Previous Page" STR_NETWORK_PREFIX: "Network: " STR_IP_ADDRESS_PREFIX: "IP Address: " STR_ERROR_GENERAL_FAILURE: "Error: General failure" @@ -229,6 +231,7 @@ STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_SUNLIGHT_FADING_FIX: "Sunlight Fading Fix" STR_REMAP_FRONT_BUTTONS: "Remap Front Buttons" STR_OPDS_BROWSER: "OPDS Browser" +STR_SEARCH: "Search" STR_COVER_CUSTOM: "Cover + Custom" STR_MENU_RECENT_BOOKS: "Recent Books" STR_NO_RECENT_BOOKS: "No recent books" diff --git a/lib/I18n/translations/finnish.yaml b/lib/I18n/translations/finnish.yaml index 35e581dd5..e584d7a26 100644 --- a/lib/I18n/translations/finnish.yaml +++ b/lib/I18n/translations/finnish.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Avaa tämä osoite selaimessasi" STR_OR_HTTP_PREFIX: "tai http://" STR_SCAN_QR_HINT: "tai skannaa QR-koodi puhelimellasi:" STR_CALIBRE_WIRELESS: "Calibre langaton" -STR_CALIBRE_WEB_URL: "Calibre-verkko-osoite" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Salattu | + = Tallennettu" STR_MAC_ADDRESS: "MAC-osoite:" STR_CHECKING_WIFI: "Tarkistetaan WiFi..." diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml index f91e46f42..b908fa84d 100644 --- a/lib/I18n/translations/french.yaml +++ b/lib/I18n/translations/french.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Ouvrez cette URL dans un navigateur" STR_OR_HTTP_PREFIX: "ou http://" STR_SCAN_QR_HINT: "ou scannez le QR code :" STR_CALIBRE_WIRELESS: "Connexion Calibre sans fil" -STR_CALIBRE_WEB_URL: "URL Web Calibre" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Sécurisé | + = Sauvegardé" STR_MAC_ADDRESS: "Adresse MAC :" STR_CHECKING_WIFI: "Vérification du WiFi…" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index b53ff4944..881a2b012 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Diese URL im Browser öffnen" STR_OR_HTTP_PREFIX: "oder http://" STR_SCAN_QR_HINT: "oder QR-Code mit dem Handy scannen:" STR_CALIBRE_WIRELESS: "Calibre Wireless" -STR_CALIBRE_WEB_URL: "Calibre-Web-URL" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Verschlüsselt | + = Gespeichert" STR_MAC_ADDRESS: "MAC-Adresse:" STR_CHECKING_WIFI: "WLAN prüfen…" diff --git a/lib/I18n/translations/hungarian.yaml b/lib/I18n/translations/hungarian.yaml index e28524ce3..38c1bc12b 100644 --- a/lib/I18n/translations/hungarian.yaml +++ b/lib/I18n/translations/hungarian.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Nyisd meg ezt az URL-t a böngésződben" STR_OR_HTTP_PREFIX: "vagy http://" STR_SCAN_QR_HINT: "vagy olvasd be a QR-kódot a telefonoddal:" STR_CALIBRE_WIRELESS: "Calibre Wireless" -STR_CALIBRE_WEB_URL: "Calibre Web URL" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Titkosított | + = Mentett" STR_MAC_ADDRESS: "MAC-cím:" STR_CHECKING_WIFI: "WiFi ellenőrzése..." diff --git a/lib/I18n/translations/italian.yaml b/lib/I18n/translations/italian.yaml index b18bea1f2..f7679ccf6 100644 --- a/lib/I18n/translations/italian.yaml +++ b/lib/I18n/translations/italian.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Apri questo URL nel tuo browser" STR_OR_HTTP_PREFIX: "o http://" STR_SCAN_QR_HINT: "o scansiona il codice QR con il tuo telefono:" STR_CALIBRE_WIRELESS: "Calibre Wireless" -STR_CALIBRE_WEB_URL: "URL Web Calibre" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Criptata | + = Salvata" STR_MAC_ADDRESS: "Indirizzo MAC:" STR_CHECKING_WIFI: "Controllo WiFi in corso..." diff --git a/lib/I18n/translations/kazakh.yaml b/lib/I18n/translations/kazakh.yaml index 4b30e9328..66c07712b 100644 --- a/lib/I18n/translations/kazakh.yaml +++ b/lib/I18n/translations/kazakh.yaml @@ -44,7 +44,7 @@ STR_OPEN_URL_HINT: "Браузерде осы URL мекенжайын ашың STR_OR_HTTP_PREFIX: "немесе http://" STR_SCAN_QR_HINT: "немесе телефонмен QR кодын сканерлеңіз:" STR_CALIBRE_WIRELESS: "Calibre сымсыз" -STR_CALIBRE_WEB_URL: "Calibre Web URL" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Шифрланған | + = Сақталған" STR_MAC_ADDRESS: "MAC мекенжайы:" STR_CHECKING_WIFI: "WiFi тексерілуде..." diff --git a/lib/I18n/translations/lithuanian.yaml b/lib/I18n/translations/lithuanian.yaml index 91eb7c6ec..67e424abc 100644 --- a/lib/I18n/translations/lithuanian.yaml +++ b/lib/I18n/translations/lithuanian.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Atidarykite šį adresą naršyklėje" STR_OR_HTTP_PREFIX: "arba http://" STR_SCAN_QR_HINT: "arba nuskaitykite QR kodą:" STR_CALIBRE_WIRELESS: "Calibre belaidis" -STR_CALIBRE_WEB_URL: "Calibre Web URL" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Užšifruota | + = Išsaugota" STR_MAC_ADDRESS: "MAC adresas:" STR_CHECKING_WIFI: "Tikrinamas WiFi..." diff --git a/lib/I18n/translations/polish.yaml b/lib/I18n/translations/polish.yaml index c5d9fed1f..a4020fbb4 100644 --- a/lib/I18n/translations/polish.yaml +++ b/lib/I18n/translations/polish.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Otwórz ten URL w przeglądarce" STR_OR_HTTP_PREFIX: "albo http://" STR_SCAN_QR_HINT: "albo zeskanuj kod QR telefonem:" STR_CALIBRE_WIRELESS: "Bezprzewodowe połączenie z Calibre" -STR_CALIBRE_WEB_URL: "Calibre Web URL" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Zaszyfrowane | + = Zapisane" STR_MAC_ADDRESS: "Adres MAC:" STR_CHECKING_WIFI: "Sprawdzanie WiFi..." diff --git a/lib/I18n/translations/portuguese.yaml b/lib/I18n/translations/portuguese.yaml index 962863ad9..989806493 100644 --- a/lib/I18n/translations/portuguese.yaml +++ b/lib/I18n/translations/portuguese.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Abra este URL seu navegador" STR_OR_HTTP_PREFIX: "ou http://" STR_SCAN_QR_HINT: "ou escaneie o QR code com seu celular:" STR_CALIBRE_WIRELESS: "Calibre sem fio" -STR_CALIBRE_WEB_URL: "URL do Calibre Web" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Criptografada | + = Salva" STR_MAC_ADDRESS: "Endereço MAC:" STR_CHECKING_WIFI: "Verificando Wi‑Fi..." diff --git a/lib/I18n/translations/romanian.yaml b/lib/I18n/translations/romanian.yaml index 27ec6e4a1..b41caa60f 100644 --- a/lib/I18n/translations/romanian.yaml +++ b/lib/I18n/translations/romanian.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Deschideţi acest URL în browserul dvs." STR_OR_HTTP_PREFIX: "sau http://" STR_SCAN_QR_HINT: "sau scanaţi codul QR cu telefonul dvs.:" STR_CALIBRE_WIRELESS: "Calibre Wireless" -STR_CALIBRE_WEB_URL: "Calibre URL" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Criptat | + = Salvat" STR_MAC_ADDRESS: "Adresă MAC:" STR_CHECKING_WIFI: "Verificare WiFi..." diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index b1354ab67..708a0b4ba 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Откройте этот адрес в браузере" STR_OR_HTTP_PREFIX: "или http://" STR_SCAN_QR_HINT: "или отсканируйте QR-код:" STR_CALIBRE_WIRELESS: "Calibre по Wi-Fi" -STR_CALIBRE_WEB_URL: "Web-адрес Calibre" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Защищена | + = Сохранена" STR_MAC_ADDRESS: "MAC-адрес:" STR_CHECKING_WIFI: "Проверка Wi-Fi..." diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index d4b2ad149..be4ab7a7e 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Abra esta dirección en su navegador" STR_OR_HTTP_PREFIX: "o http://" STR_SCAN_QR_HINT: "o escanee el código QR con su móvil:" STR_CALIBRE_WIRELESS: "Calibre inalámbrico" -STR_CALIBRE_WEB_URL: "URL del sitio web de Calibre" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* (Cifrado) | + (Guardado)" STR_MAC_ADDRESS: "MAC Address:" STR_CHECKING_WIFI: "Verificando Wi-Fi..." diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index c5e577e3a..34c013249 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Öppna denna adress i din browser" STR_OR_HTTP_PREFIX: "eller http://" STR_SCAN_QR_HINT: "eller skanna QR-kod med din telefon:" STR_CALIBRE_WIRELESS: "Calibre Trådlöst" -STR_CALIBRE_WEB_URL: "Calibre webbadress" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Krypterad | + = Sparad" STR_MAC_ADDRESS: "MAC-adress:" STR_CHECKING_WIFI: "Kontrollerar trådlöst nätverk…" diff --git a/lib/I18n/translations/turkish.yaml b/lib/I18n/translations/turkish.yaml index 0c6aa1a88..667c90494 100644 --- a/lib/I18n/translations/turkish.yaml +++ b/lib/I18n/translations/turkish.yaml @@ -44,7 +44,7 @@ STR_OPEN_URL_HINT: "Tarayıcınızda bu adresi açın" STR_OR_HTTP_PREFIX: "veya http://" STR_SCAN_QR_HINT: "veya telefonunuzla QR kodu tarayın:" STR_CALIBRE_WIRELESS: "Calibre Kablosuz" -STR_CALIBRE_WEB_URL: "Calibre Web Adresi" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Şifreli | + = Kayıtlı" STR_MAC_ADDRESS: "MAC adresi:" STR_CHECKING_WIFI: "WiFi kontrol ediliyor..." diff --git a/lib/I18n/translations/ukrainian.yaml b/lib/I18n/translations/ukrainian.yaml index c6a03112a..d76cad9db 100644 --- a/lib/I18n/translations/ukrainian.yaml +++ b/lib/I18n/translations/ukrainian.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Відкрийте цю URL-адресу у вашому б STR_OR_HTTP_PREFIX: "або http://" STR_SCAN_QR_HINT: "або відскануйте QR-код телефоном:" STR_CALIBRE_WIRELESS: "Calibre бездротовий" -STR_CALIBRE_WEB_URL: "URL Calibre Web" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Зашифровано | + = Збережено" STR_MAC_ADDRESS: "MAC адреса:" STR_CHECKING_WIFI: "Перевірка WiFi..." diff --git a/lib/OpdsParser/OpdsParser.cpp b/lib/OpdsParser/OpdsParser.cpp index f4ce69602..84feef74f 100644 --- a/lib/OpdsParser/OpdsParser.cpp +++ b/lib/OpdsParser/OpdsParser.cpp @@ -25,15 +25,12 @@ OpdsParser::~OpdsParser() { size_t OpdsParser::write(uint8_t c) { return write(&c, 1); } size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) { - if (errorOccured) { - return length; - } + if (errorOccured) return length; XML_SetUserData(parser, this); XML_SetElementHandler(parser, startElement, endElement); XML_SetCharacterDataHandler(parser, characterData); - // Parse in chunks to avoid large buffer allocations const char* currentPos = reinterpret_cast(xmlData); size_t remaining = length; constexpr size_t chunkSize = 1024; @@ -42,9 +39,7 @@ size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) { void* const buf = XML_GetBuffer(parser, chunkSize); if (!buf) { errorOccured = true; - LOG_DBG("OPDS", "Couldn't allocate memory for buffer"); XML_ParserFree(parser); - parser = nullptr; return length; } @@ -53,13 +48,9 @@ size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) { if (XML_ParseBuffer(parser, static_cast(toRead), 0) == XML_STATUS_ERROR) { errorOccured = true; - LOG_DBG("OPDS", "Parse error at line %lu: %s", XML_GetCurrentLineNumber(parser), - XML_ErrorString(XML_GetErrorCode(parser))); XML_ParserFree(parser); - parser = nullptr; return length; } - currentPos += toRead; remaining -= toRead; } @@ -78,30 +69,25 @@ bool OpdsParser::error() const { return errorOccured; } void OpdsParser::clear() { entries.clear(); + searchTemplate.clear(); + nextPageUrl.clear(); + prevPageUrl.clear(); currentEntry = OpdsEntry{}; currentText.clear(); - inEntry = false; - inTitle = false; - inAuthor = false; - inAuthorName = false; - inId = false; + inEntry = inTitle = inAuthor = inAuthorName = inId = false; } std::vector OpdsParser::getBooks() const { std::vector books; for (const auto& entry : entries) { - if (entry.type == OpdsEntryType::BOOK) { - books.push_back(entry); - } + if (entry.type == OpdsEntryType::BOOK) books.push_back(entry); } return books; } const char* OpdsParser::findAttribute(const XML_Char** atts, const char* name) { for (int i = 0; atts[i]; i += 2) { - if (strcmp(atts[i], name) == 0) { - return atts[i + 1]; - } + if (strcmp(atts[i], name) == 0) return atts[i + 1]; } return nullptr; } @@ -109,7 +95,38 @@ const char* OpdsParser::findAttribute(const XML_Char** atts, const char* name) { void XMLCALL OpdsParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) { auto* self = static_cast(userData); - // Check for entry element (with or without namespace prefix) + if (strcmp(name, "link") == 0 || strstr(name, ":link") != nullptr) { + const char* href = findAttribute(atts, "href"); + if (href) { + const char* rel = findAttribute(atts, "rel"); + const char* type = findAttribute(atts, "type"); + + if (rel && strcmp(rel, "search") == 0) { + std::string sHref(href); + if (sHref.find("{searchTerms}") != std::string::npos) { + self->searchTemplate = sHref; + } + } else if (rel && strcmp(rel, "next") == 0 && !self->inEntry) { + self->nextPageUrl = href; + } else if (rel && strcmp(rel, "previous") == 0 && !self->inEntry) { + self->prevPageUrl = href; + } + + if (self->inEntry) { + if (rel && type && strstr(rel, "opds-spec.org/acquisition") != nullptr && + strcmp(type, "application/epub+zip") == 0) { + self->currentEntry.type = OpdsEntryType::BOOK; + self->currentEntry.href = href; + } else if (type && strstr(type, "application/atom+xml") != nullptr) { + if (self->currentEntry.type != OpdsEntryType::BOOK) { + self->currentEntry.type = OpdsEntryType::NAVIGATION; + self->currentEntry.href = href; + } + } + } + } + } + if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) { self->inEntry = true; self->currentEntry = OpdsEntry{}; @@ -118,112 +135,46 @@ void XMLCALL OpdsParser::startElement(void* userData, const XML_Char* name, cons if (!self->inEntry) return; - // Check for title element if (strcmp(name, "title") == 0 || strstr(name, ":title") != nullptr) { self->inTitle = true; self->currentText.clear(); - return; - } - - // Check for author element - if (strcmp(name, "author") == 0 || strstr(name, ":author") != nullptr) { + } else if (strcmp(name, "author") == 0 || strstr(name, ":author") != nullptr) { self->inAuthor = true; - return; - } - - // Check for author name element - if (self->inAuthor && (strcmp(name, "name") == 0 || strstr(name, ":name") != nullptr)) { + } else if (self->inAuthor && (strcmp(name, "name") == 0 || strstr(name, ":name") != nullptr)) { self->inAuthorName = true; self->currentText.clear(); - return; - } - - // Check for id element - if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) { + } else if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) { self->inId = true; self->currentText.clear(); - return; - } - - // Check for link element - if (strcmp(name, "link") == 0 || strstr(name, ":link") != nullptr) { - const char* rel = findAttribute(atts, "rel"); - const char* type = findAttribute(atts, "type"); - const char* href = findAttribute(atts, "href"); - - if (href) { - // Check for acquisition link with epub type (this is a downloadable book) - if (rel && type && strstr(rel, "opds-spec.org/acquisition") != nullptr && - strcmp(type, "application/epub+zip") == 0) { - self->currentEntry.type = OpdsEntryType::BOOK; - self->currentEntry.href = href; - } - // Check for navigation link (subsection or no rel specified with atom+xml type) - else if (type && strstr(type, "application/atom+xml") != nullptr) { - // Only set navigation link if we don't already have an epub link - if (self->currentEntry.type != OpdsEntryType::BOOK) { - self->currentEntry.type = OpdsEntryType::NAVIGATION; - self->currentEntry.href = href; - } - } - } } } void XMLCALL OpdsParser::endElement(void* userData, const XML_Char* name) { auto* self = static_cast(userData); - // Check for entry end if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) { - // Only add entry if it has required fields (title and href) if (!self->currentEntry.title.empty() && !self->currentEntry.href.empty()) { self->entries.push_back(self->currentEntry); } self->inEntry = false; - self->currentEntry = OpdsEntry{}; - return; - } - - if (!self->inEntry) return; - - // Check for title end - if (strcmp(name, "title") == 0 || strstr(name, ":title") != nullptr) { - if (self->inTitle) { - self->currentEntry.title = self->currentText; - } - self->inTitle = false; - return; - } - - // Check for author end - if (strcmp(name, "author") == 0 || strstr(name, ":author") != nullptr) { - self->inAuthor = false; - return; - } - - // Check for author name end - if (self->inAuthor && (strcmp(name, "name") == 0 || strstr(name, ":name") != nullptr)) { - if (self->inAuthorName) { + } else if (self->inEntry) { + if (strcmp(name, "title") == 0 || strstr(name, ":title") != nullptr) { + if (self->inTitle) self->currentEntry.title = self->currentText; + self->inTitle = false; + } else if (strcmp(name, "author") == 0 || strstr(name, ":author") != nullptr) { + self->inAuthor = false; + } else if (self->inAuthorName && (strcmp(name, "name") == 0 || strstr(name, ":name") != nullptr)) { self->currentEntry.author = self->currentText; + self->inAuthorName = false; + } else if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) { + if (self->inId) self->currentEntry.id = self->currentText; + self->inId = false; } - self->inAuthorName = false; - return; - } - - // Check for id end - if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) { - if (self->inId) { - self->currentEntry.id = self->currentText; - } - self->inId = false; - return; } } void XMLCALL OpdsParser::characterData(void* userData, const XML_Char* s, const int len) { auto* self = static_cast(userData); - - // Only accumulate text when in a text element if (self->inTitle || self->inAuthorName || self->inId) { self->currentText.append(s, len); } diff --git a/lib/OpdsParser/OpdsParser.h b/lib/OpdsParser/OpdsParser.h index 570ac4cce..9c93b89ac 100644 --- a/lib/OpdsParser/OpdsParser.h +++ b/lib/OpdsParser/OpdsParser.h @@ -49,6 +49,9 @@ class OpdsParser final : public Print { ~OpdsParser(); // Disable copy + const std::string& getSearchTemplate() const { return searchTemplate; } + const std::string& getNextPageUrl() const { return nextPageUrl; } + const std::string& getPrevPageUrl() const { return prevPageUrl; } OpdsParser(const OpdsParser&) = delete; OpdsParser& operator=(const OpdsParser&) = delete; @@ -85,6 +88,9 @@ class OpdsParser final : public Print { static void XMLCALL endElement(void* userData, const XML_Char* name); static void XMLCALL characterData(void* userData, const XML_Char* s, int len); + std::string searchTemplate; + std::string nextPageUrl; + std::string prevPageUrl; // Helper to find attribute value static const char* findAttribute(const XML_Char** atts, const char* name); diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index f6a58385d..e4c6f9fd9 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -10,6 +10,7 @@ #include "CrossPointSettings.h" #include "MappedInputManager.h" #include "activities/network/WifiSelectionActivity.h" +#include "activities/util/KeyboardEntryActivity.h" #include "components/UITheme.h" #include "fontIds.h" #include "network/HttpDownloader.h" @@ -18,7 +19,7 @@ namespace { constexpr int PAGE_ITEMS = 23; -} // namespace +} void OpdsBookBrowserActivity::onEnter() { Activity::onEnter(); @@ -26,47 +27,47 @@ void OpdsBookBrowserActivity::onEnter() { state = BrowserState::CHECK_WIFI; entries.clear(); navigationHistory.clear(); - currentPath = ""; // Root path - user provides full URL in settings + searchTemplate = ""; + currentPath = ""; selectorIndex = 0; + consumeConfirm = false; + consumeBack = false; errorMessage.clear(); statusMessage = tr(STR_CHECKING_WIFI); requestUpdate(); - // Check WiFi and connect if needed, then fetch feed checkAndConnectWifi(); } void OpdsBookBrowserActivity::onExit() { Activity::onExit(); - - // Turn off WiFi when exiting WiFi.mode(WIFI_OFF); - entries.clear(); navigationHistory.clear(); } void OpdsBookBrowserActivity::loop() { - // Handle WiFi selection subactivity - if (state == BrowserState::WIFI_SELECTION) { - // Should already handled by the WifiSelectionActivity + if (state == BrowserState::WIFI_SELECTION || state == BrowserState::SEARCH_INPUT) { + return; + } + + if (consumeConfirm && mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + consumeConfirm = false; + return; + } + if (consumeBack && mappedInput.wasReleased(MappedInputManager::Button::Back)) { + consumeBack = false; return; } - // Handle error state - Confirm retries, Back goes back or home if (state == BrowserState::ERROR) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { - // Check if WiFi is still connected if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) { - // WiFi connected - just retry fetching the feed - LOG_DBG("OPDS", "Retry: WiFi connected, retrying fetch"); state = BrowserState::LOADING; statusMessage = tr(STR_LOADING); requestUpdate(); fetchFeed(currentPath); } else { - // WiFi not connected - launch WiFi selection - LOG_DBG("OPDS", "Retry: WiFi not connected, launching selection"); launchWifiSelection(); } } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { @@ -75,59 +76,40 @@ void OpdsBookBrowserActivity::loop() { return; } - // Handle WiFi check state - only Back works - if (state == BrowserState::CHECK_WIFI) { + if (state == BrowserState::CHECK_WIFI || state == BrowserState::LOADING) { if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { - onGoHome(); + state == BrowserState::CHECK_WIFI ? onGoHome() : navigateBack(); } return; } - // Handle loading state - only Back works - if (state == BrowserState::LOADING) { - if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { - navigateBack(); - } - return; - } + if (state == BrowserState::DOWNLOADING) return; - // Handle downloading state - no input allowed - if (state == BrowserState::DOWNLOADING) { - return; - } - - // Handle browsing state if (state == BrowserState::BROWSING) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (!entries.empty()) { const auto& entry = entries[selectorIndex]; - if (entry.type == OpdsEntryType::BOOK) { - downloadBook(entry); - } else { - navigateToEntry(entry); - } + entry.type == OpdsEntryType::BOOK ? downloadBook(entry) : navigateToEntry(entry); } } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { navigateBack(); + } else if (mappedInput.wasReleased(MappedInputManager::Button::Left)) { + if (!searchTemplate.empty() && selectorIndex == 0) launchSearch(); } - // Handle navigation if (!entries.empty()) { buttonNavigator.onNextRelease([this] { selectorIndex = ButtonNavigator::nextIndex(selectorIndex, entries.size()); requestUpdate(); }); - buttonNavigator.onPreviousRelease([this] { selectorIndex = ButtonNavigator::previousIndex(selectorIndex, entries.size()); requestUpdate(); }); - buttonNavigator.onNextContinuous([this] { selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, entries.size(), PAGE_ITEMS); requestUpdate(); }); - buttonNavigator.onPreviousContinuous([this] { selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, entries.size(), PAGE_ITEMS); requestUpdate(); @@ -138,21 +120,12 @@ void OpdsBookBrowserActivity::loop() { void OpdsBookBrowserActivity::render(RenderLock&&) { renderer.clearScreen(); - const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); renderer.drawCenteredText(UI_12_FONT_ID, 15, tr(STR_OPDS_BROWSER), true, EpdFontFamily::BOLD); - if (state == BrowserState::CHECK_WIFI) { - renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str()); - const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); - GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - renderer.displayBuffer(); - return; - } - - if (state == BrowserState::LOADING) { + if (state == BrowserState::CHECK_WIFI || state == BrowserState::LOADING) { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str()); const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); @@ -171,76 +144,50 @@ void OpdsBookBrowserActivity::render(RenderLock&&) { if (state == BrowserState::DOWNLOADING) { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 40, tr(STR_DOWNLOADING)); - const auto maxWidth = pageWidth - 40; - // Trim long titles to keep them within the screen bounds. - auto title = renderer.truncatedText(UI_10_FONT_ID, statusMessage.c_str(), maxWidth); + auto title = renderer.truncatedText(UI_10_FONT_ID, statusMessage.c_str(), pageWidth - 40); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 10, title.c_str()); if (downloadTotal > 0) { - const int barWidth = pageWidth - 100; - constexpr int barHeight = 20; - constexpr int barX = 50; - const int barY = pageHeight / 2 + 20; - GUI.drawProgressBar(renderer, Rect{barX, barY, barWidth, barHeight}, downloadProgress, downloadTotal); + GUI.drawProgressBar(renderer, Rect{50, pageHeight / 2 + 20, pageWidth - 100, 20}, downloadProgress, + downloadTotal); } renderer.displayBuffer(); return; } - // Browsing state - // Show appropriate button hint based on selected entry type - const char* confirmLabel = tr(STR_OPEN); - if (!entries.empty() && entries[selectorIndex].type == OpdsEntryType::BOOK) { - confirmLabel = tr(STR_DOWNLOAD); - } - const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + const char* confirmLabel = + (!entries.empty() && entries[selectorIndex].type == OpdsEntryType::BOOK) ? tr(STR_DOWNLOAD) : tr(STR_OPEN); + const char* searchLabel = (!searchTemplate.empty() && selectorIndex == 0) ? tr(STR_SEARCH) : tr(STR_DIR_UP); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirmLabel, searchLabel, tr(STR_DIR_DOWN)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); if (entries.empty()) { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, tr(STR_NO_ENTRIES)); - renderer.displayBuffer(); - return; - } + } else { + const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS; + renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 - 2, pageWidth - 1, 30); - const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS; - renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 - 2, pageWidth - 1, 30); - - for (size_t i = pageStartIndex; i < entries.size() && i < static_cast(pageStartIndex + PAGE_ITEMS); i++) { - const auto& entry = entries[i]; - - // Format display text with type indicator - std::string displayText; - if (entry.type == OpdsEntryType::NAVIGATION) { - displayText = "> " + entry.title; // Folder/navigation indicator - } else { - // Book: "Title - Author" or just "Title" - displayText = entry.title; - if (!entry.author.empty()) { - displayText += " - " + entry.author; - } + for (size_t i = pageStartIndex; i < entries.size() && i < static_cast(pageStartIndex + PAGE_ITEMS); i++) { + const auto& entry = entries[i]; + std::string displayText = (entry.type == OpdsEntryType::NAVIGATION) ? "> " + entry.title : entry.title; + if (entry.type == OpdsEntryType::BOOK && !entry.author.empty()) displayText += " - " + entry.author; + auto item = renderer.truncatedText(UI_10_FONT_ID, displayText.c_str(), pageWidth - 40); + renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, item.c_str(), + i != static_cast(selectorIndex)); } - - auto item = renderer.truncatedText(UI_10_FONT_ID, displayText.c_str(), renderer.getScreenWidth() - 40); - renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, item.c_str(), - i != static_cast(selectorIndex)); } - renderer.displayBuffer(); } void OpdsBookBrowserActivity::fetchFeed(const std::string& path) { - const char* serverUrl = SETTINGS.opdsServerUrl; - if (strlen(serverUrl) == 0) { + if (strlen(SETTINGS.opdsServerUrl) == 0) { state = BrowserState::ERROR; errorMessage = tr(STR_NO_SERVER_URL); requestUpdate(); return; } - std::string url = UrlUtils::buildUrl(serverUrl, path); - LOG_DBG("OPDS", "Fetching: %s", url.c_str()); - + std::string url = (path.find("http") == 0) ? path : UrlUtils::buildUrl(SETTINGS.opdsServerUrl, path); OpdsParser parser; - { OpdsParserStream stream{parser}; if (!HttpDownloader::fetchUrl(url, stream)) { @@ -258,50 +205,46 @@ void OpdsBookBrowserActivity::fetchFeed(const std::string& path) { return; } + searchTemplate = parser.getSearchTemplate(); + const auto& nextUrl = parser.getNextPageUrl(); + const auto& prevUrl = parser.getPrevPageUrl(); entries = std::move(parser).getEntries(); - LOG_DBG("OPDS", "Found %d entries", entries.size()); - selectorIndex = 0; - if (entries.empty()) { - state = BrowserState::ERROR; - errorMessage = tr(STR_NO_ENTRIES); - requestUpdate(); - return; + if (!prevUrl.empty()) { + entries.insert(entries.begin(), OpdsEntry{OpdsEntryType::NAVIGATION, tr(STR_PREV_PAGE), "", prevUrl, ""}); + } + if (!nextUrl.empty()) { + entries.push_back(OpdsEntry{OpdsEntryType::NAVIGATION, tr(STR_NEXT_PAGE), "", nextUrl, ""}); } - state = BrowserState::BROWSING; + selectorIndex = 0; + state = entries.empty() ? BrowserState::ERROR : BrowserState::BROWSING; + if (entries.empty()) errorMessage = tr(STR_NO_ENTRIES); requestUpdate(); } void OpdsBookBrowserActivity::navigateToEntry(const OpdsEntry& entry) { - // Push current path to history before navigating navigationHistory.push_back(currentPath); currentPath = entry.href; - state = BrowserState::LOADING; statusMessage = tr(STR_LOADING); entries.clear(); selectorIndex = 0; - requestUpdate(true); // Force update to show loading state immediately before fetch - + requestUpdate(true); fetchFeed(currentPath); } void OpdsBookBrowserActivity::navigateBack() { if (navigationHistory.empty()) { - // At root, go home onGoHome(); } else { - // Go back to previous catalog currentPath = navigationHistory.back(); navigationHistory.pop_back(); - state = BrowserState::LOADING; statusMessage = tr(STR_LOADING); entries.clear(); selectorIndex = 0; requestUpdate(); - fetchFeed(currentPath); } } @@ -309,48 +252,84 @@ void OpdsBookBrowserActivity::navigateBack() { void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { state = BrowserState::DOWNLOADING; statusMessage = book.title; - downloadProgress = 0; - downloadTotal = 0; + downloadProgress = downloadTotal = 0; requestUpdate(true); - // Build full download URL - std::string downloadUrl = UrlUtils::buildUrl(SETTINGS.opdsServerUrl, book.href); - - // Create sanitized filename: "Title - Author.epub" or just "Title.epub" if no author - std::string baseName = book.title; - if (!book.author.empty()) { - baseName += " - " + book.author; - } - std::string filename = "/" + StringUtils::sanitizeFilename(baseName) + ".epub"; - - LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str()); + std::string downloadUrl = + (book.href.find("http") == 0) ? book.href : UrlUtils::buildUrl(SETTINGS.opdsServerUrl, book.href); + std::string filename = + "/" + StringUtils::sanitizeFilename(book.title + (book.author.empty() ? "" : " - " + book.author)) + ".epub"; const auto result = HttpDownloader::downloadToFile(downloadUrl, filename, [this](const size_t downloaded, const size_t total) { downloadProgress = downloaded; downloadTotal = total; - requestUpdate(true); // Force update to refresh progress bar + requestUpdate(true); }); if (result == HttpDownloader::OK) { - LOG_DBG("OPDS", "Download complete: %s", filename.c_str()); - - // Invalidate any existing cache for this file to prevent stale metadata issues - Epub epub(filename, "/.crosspoint"); - epub.clearCache(); - LOG_DBG("OPDS", "Cleared cache for: %s", filename.c_str()); - + Epub(filename, "/.crosspoint").clearCache(); state = BrowserState::BROWSING; - requestUpdate(); } else { state = BrowserState::ERROR; errorMessage = tr(STR_DOWNLOAD_FAILED); - requestUpdate(); } + requestUpdate(); +} + +void OpdsBookBrowserActivity::launchSearch() { + consumeConfirm = true; + state = BrowserState::SEARCH_INPUT; + requestUpdate(); + + auto keyboard = std::make_unique(renderer, mappedInput, tr(STR_SEARCH)); + startActivityForResult(std::move(keyboard), [this](const ActivityResult& result) { + state = BrowserState::BROWSING; + if (!result.isCancelled) { + performSearch(std::get(result.data).text); + } else { + requestUpdate(); + } + }); +} + +void OpdsBookBrowserActivity::performSearch(const std::string& query) { + if (query.empty() || searchTemplate.empty()) { + state = BrowserState::BROWSING; + requestUpdate(); + return; + } + + auto urlEncode = [](const std::string& s) { + std::string out; + out.reserve(s.size() * 3); + for (unsigned char c : s) { + if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') + out += static_cast(c); + else { + char buf[4]; + snprintf(buf, sizeof(buf), "%%%02X", c); + out += buf; + } + } + return out; + }; + + std::string url = searchTemplate; + const std::string placeholder = "{searchTerms}"; + const size_t pos = url.find(placeholder); + if (pos != std::string::npos) url.replace(pos, placeholder.length(), urlEncode(query)); + + navigationHistory.push_back(currentPath); // <-- add this + currentPath = url; // <-- add this + + state = BrowserState::LOADING; + statusMessage = tr(STR_LOADING); + requestUpdate(true); + fetchFeed(url); } void OpdsBookBrowserActivity::checkAndConnectWifi() { - // Already connected? Verify connection is valid by checking IP if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) { state = BrowserState::LOADING; statusMessage = tr(STR_LOADING); @@ -358,12 +337,11 @@ void OpdsBookBrowserActivity::checkAndConnectWifi() { fetchFeed(currentPath); return; } - - // Not connected - launch WiFi selection screen directly launchWifiSelection(); } void OpdsBookBrowserActivity::launchWifiSelection() { + consumeBack = consumeConfirm = true; state = BrowserState::WIFI_SELECTION; requestUpdate(); @@ -373,15 +351,11 @@ void OpdsBookBrowserActivity::launchWifiSelection() { void OpdsBookBrowserActivity::onWifiSelectionComplete(const bool connected) { if (connected) { - LOG_DBG("OPDS", "WiFi connected via selection, fetching feed"); state = BrowserState::LOADING; statusMessage = tr(STR_LOADING); - requestUpdate(true); // Force update to show loading state immediately before fetch + requestUpdate(true); fetchFeed(currentPath); } else { - LOG_DBG("OPDS", "WiFi selection cancelled/failed"); - // Force disconnect to ensure clean state for next retry - // This prevents stale connection status from interfering WiFi.disconnect(); WiFi.mode(WIFI_OFF); state = BrowserState::ERROR; diff --git a/src/activities/browser/OpdsBookBrowserActivity.h b/src/activities/browser/OpdsBookBrowserActivity.h index fa716cbd2..8b55343a3 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.h +++ b/src/activities/browser/OpdsBookBrowserActivity.h @@ -11,21 +11,13 @@ /** * Activity for browsing and downloading books from an OPDS server. * Supports navigation through catalog hierarchy and downloading EPUBs. - * When WiFi connection fails, launches WiFi selection to let user connect. */ class OpdsBookBrowserActivity final : public Activity { public: - enum class BrowserState { - CHECK_WIFI, // Checking WiFi connection - WIFI_SELECTION, // WiFi selection subactivity is active - LOADING, // Fetching OPDS feed - BROWSING, // Displaying entries (navigation or books) - DOWNLOADING, // Downloading selected EPUB - ERROR // Error state with message - }; + enum class BrowserState { CHECK_WIFI, WIFI_SELECTION, LOADING, BROWSING, DOWNLOADING, ERROR, SEARCH_INPUT }; explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) - : Activity("OpdsBookBrowser", renderer, mappedInput) {} + : Activity("OpdsBookBrowser", renderer, mappedInput), buttonNavigator() {} void onEnter() override; void onExit() override; @@ -36,8 +28,11 @@ class OpdsBookBrowserActivity final : public Activity { ButtonNavigator buttonNavigator; BrowserState state = BrowserState::LOADING; std::vector entries; - std::vector navigationHistory; // Stack of previous feed paths for back navigation - std::string currentPath; // Current feed path being displayed + std::vector navigationHistory; + std::string currentPath; + std::string searchTemplate; + bool consumeConfirm = false; + bool consumeBack = false; // Added missing member int selectorIndex = 0; std::string errorMessage; std::string statusMessage; @@ -51,5 +46,7 @@ class OpdsBookBrowserActivity final : public Activity { void navigateToEntry(const OpdsEntry& entry); void navigateBack(); void downloadBook(const OpdsEntry& book); + void launchSearch(); + void performSearch(const std::string& query); bool preventAutoSleep() override { return true; } }; From 9c11f3e4a26762fc13c2ebadd131ca91abf5d646 Mon Sep 17 00:00:00 2001 From: bdeshi Date: Mon, 13 Apr 2026 01:31:26 +0600 Subject: [PATCH 22/37] feat: enable manual screen refresh on power button short press (#1626) ## Summary Adds an option to allow manual screen refresh on power button short press. ## Additional Context there's an option to refresh the screen after a set number of pages. but sometimes a manual refresh is needed to clear up stale ink pixels. this works everywhere both in and out of reading mode. resolves #550 --- ### 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? _**PARTIALLY**_, to understand the project structure. --- lib/I18n/translations/english.yaml | 1 + src/CrossPointSettings.h | 2 +- src/SettingsList.h | 4 ++-- src/main.cpp | 10 +++++++++- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index b741a85d0..a948ea5a8 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -127,6 +127,7 @@ STR_ALWAYS: "Always" STR_IGNORE: "Ignore" STR_SLEEP: "Sleep" STR_PAGE_TURN: "Page Turn" +STR_FORCE_REFRESH: "Refresh Screen" STR_PORTRAIT: "Portrait" STR_LANDSCAPE_CW: "Landscape CW" STR_INVERTED: "Inverted" diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 9a0e298b0..43dbcba6b 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -126,7 +126,7 @@ class CrossPointSettings { }; // Short power button press actions - enum SHORT_PWRBTN { IGNORE = 0, SLEEP = 1, PAGE_TURN = 2, SHORT_PWRBTN_COUNT }; + enum SHORT_PWRBTN { IGNORE = 0, SLEEP = 1, PAGE_TURN = 2, FORCE_REFRESH = 3, SHORT_PWRBTN_COUNT }; // Hide battery percentage enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2, HIDE_BATTERY_PERCENTAGE_COUNT }; diff --git a/src/SettingsList.h b/src/SettingsList.h index cdbee372c..50225c467 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -71,8 +71,8 @@ inline const std::vector& getSettingsList() { SettingInfo::Toggle(StrId::STR_LONG_PRESS_SKIP, &CrossPointSettings::longPressChapterSkip, "longPressChapterSkip", StrId::STR_CAT_CONTROLS), SettingInfo::Enum(StrId::STR_SHORT_PWR_BTN, &CrossPointSettings::shortPwrBtn, - {StrId::STR_IGNORE, StrId::STR_SLEEP, StrId::STR_PAGE_TURN}, "shortPwrBtn", - StrId::STR_CAT_CONTROLS), + {StrId::STR_IGNORE, StrId::STR_SLEEP, StrId::STR_PAGE_TURN, StrId::STR_FORCE_REFRESH}, + "shortPwrBtn", StrId::STR_CAT_CONTROLS), // --- System --- SettingInfo::Enum(StrId::STR_TIME_TO_SLEEP, &CrossPointSettings::sleepTimeout, diff --git a/src/main.cpp b/src/main.cpp index 5ba58cbcb..e77e8ba6f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -379,6 +379,14 @@ void loop() { return; } + // Refresh screen when power button is short-pressed with FORCE_REFRESH setting. + if (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::FORCE_REFRESH && + mappedInputManager.wasReleased(MappedInputManager::Button::Power)) { + LOG_DBG("MAIN", "Manual screen refresh triggered"); + RenderLock lock; + renderer.displayBuffer(HalDisplay::HALF_REFRESH); + } + // Refresh the battery icon when USB is plugged or unplugged. // Placed after sleep guards so we never queue a render that won't be processed. if (gpio.wasUsbStateChanged()) { @@ -413,4 +421,4 @@ void loop() { delay(10); } } -} \ No newline at end of file +} From 9bc5111c77ae1da20b5ab67328cf1a000c018534 Mon Sep 17 00:00:00 2001 From: CSCMe Date: Mon, 13 Apr 2026 01:41:28 +0200 Subject: [PATCH 23/37] fix: increase loadable epub size (#1638) ## Summary * **What is the goal of this PR?** Slightly increase the OOM limit when loading epubs * **What changes are included?** Switched vectors for parsing epubs to deques, allowing use of more memory ## Additional Context * Increases loadable epub size from 2000+ chapter/ToC entries loadable to 5000+ chapter/ToC entries * #1574, but without the complicated stuff --- ### 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? _**NO**_ ### Testing | Commit | Book | Time | |----------|------|-------| | 83cd96bc2fd90e5f5a8b0ceaed2022a16a017a1c | 1000.epub | ~30 sec | | 83cd96bc2fd90e5f5a8b0ceaed2022a16a017a1c | 5000.epub | crash | | PR | 1000.epub | ~29 sec | | PR | 5000.epub | ~2 min 20 sec | => No actual loading time regressions [tested_epubs.zip](https://github.com/user-attachments/files/26645243/tested_epubs.zip) --- lib/Epub/Epub/BookMetadataCache.cpp | 16 ++++++++-------- lib/Epub/Epub/BookMetadataCache.h | 4 ++-- lib/Epub/Epub/parsers/ContentOpfParser.h | 3 ++- lib/ZipFile/ZipFile.cpp | 2 +- lib/ZipFile/ZipFile.h | 4 ++-- 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/lib/Epub/Epub/BookMetadataCache.cpp b/lib/Epub/Epub/BookMetadataCache.cpp index 3cdee0b0e..ffc456cad 100644 --- a/lib/Epub/Epub/BookMetadataCache.cpp +++ b/lib/Epub/Epub/BookMetadataCache.cpp @@ -4,7 +4,7 @@ #include #include -#include +#include #include "FsHelpers.h" @@ -50,7 +50,7 @@ bool BookMetadataCache::beginTocPass() { if (spineCount >= LARGE_SPINE_THRESHOLD) { spineHrefIndex.clear(); - spineHrefIndex.reserve(spineCount); + spineHrefIndex.resize(spineCount); spineFile.seek(0); for (int i = 0; i < spineCount; i++) { auto entry = readSpineEntry(spineFile); @@ -58,7 +58,7 @@ bool BookMetadataCache::beginTocPass() { idx.hrefHash = fnvHash64(entry.href); idx.hrefLen = static_cast(entry.href.size()); idx.spineIndex = static_cast(i); - spineHrefIndex.push_back(idx); + spineHrefIndex[i] = idx; } std::sort(spineHrefIndex.begin(), spineHrefIndex.end(), [](const SpineHrefIndexEntry& a, const SpineHrefIndexEntry& b) { @@ -153,7 +153,7 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta // Loop through spines from spine file matching up TOC indexes, calculating cumulative size and writing to book.bin // Build spineIndex->tocIndex mapping in one pass (O(n) instead of O(n*m)) - std::vector spineToTocIndex(spineCount, -1); + std::deque spineToTocIndex(spineCount, -1); tocFile.seek(0); for (int j = 0; j < tocCount; j++) { auto tocEntry = readTocEntry(tocFile); @@ -181,14 +181,14 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta // This is O(n*log(m)) instead of O(n*m) while avoiding memory exhaustion. // See: https://github.com/crosspoint-reader/crosspoint-reader/issues/134 - std::vector spineSizes; + std::deque spineSizes; bool useBatchSizes = false; if (spineCount >= LARGE_SPINE_THRESHOLD) { LOG_DBG("BMC", "Using batch size lookup for %d spine items", spineCount); - std::vector targets; - targets.reserve(spineCount); + std::deque targets; + targets.resize(spineCount); spineFile.seek(0); for (int i = 0; i < spineCount; i++) { @@ -199,7 +199,7 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta t.hash = ZipFile::fnvHash64(path.c_str(), path.size()); t.len = static_cast(path.size()); t.index = static_cast(i); - targets.push_back(t); + targets[i] = t; } std::sort(targets.begin(), targets.end(), [](const ZipFile::SizeTarget& a, const ZipFile::SizeTarget& b) { diff --git a/lib/Epub/Epub/BookMetadataCache.h b/lib/Epub/Epub/BookMetadataCache.h index 9439b37fe..7f45090a9 100644 --- a/lib/Epub/Epub/BookMetadataCache.h +++ b/lib/Epub/Epub/BookMetadataCache.h @@ -3,8 +3,8 @@ #include #include +#include #include -#include class BookMetadataCache { public: @@ -61,7 +61,7 @@ class BookMetadataCache { uint16_t hrefLen; // length for collision reduction int16_t spineIndex; }; - std::vector spineHrefIndex; + std::deque spineHrefIndex; bool useSpineHrefIndex = false; static constexpr uint16_t LARGE_SPINE_THRESHOLD = 400; diff --git a/lib/Epub/Epub/parsers/ContentOpfParser.h b/lib/Epub/Epub/parsers/ContentOpfParser.h index 89fb3379b..485b3a857 100644 --- a/lib/Epub/Epub/parsers/ContentOpfParser.h +++ b/lib/Epub/Epub/parsers/ContentOpfParser.h @@ -2,6 +2,7 @@ #include #include +#include #include #include "Epub.h" @@ -37,7 +38,7 @@ class ContentOpfParser final : public Print { uint16_t idLen; // length for collision reduction uint32_t fileOffset; // offset in .items.bin }; - std::vector itemIndex; + std::deque itemIndex; bool useItemIndex = false; static constexpr uint16_t LARGE_SPINE_THRESHOLD = 400; diff --git a/lib/ZipFile/ZipFile.cpp b/lib/ZipFile/ZipFile.cpp index 2bb19147a..bdf5c0e25 100644 --- a/lib/ZipFile/ZipFile.cpp +++ b/lib/ZipFile/ZipFile.cpp @@ -295,7 +295,7 @@ bool ZipFile::getInflatedFileSize(const char* filename, size_t* size) { return true; } -int ZipFile::fillUncompressedSizes(std::vector& targets, std::vector& sizes) { +int ZipFile::fillUncompressedSizes(std::deque& targets, std::deque& sizes) { if (targets.empty()) { return 0; } diff --git a/lib/ZipFile/ZipFile.h b/lib/ZipFile/ZipFile.h index bc97559dd..60c97a4cf 100644 --- a/lib/ZipFile/ZipFile.h +++ b/lib/ZipFile/ZipFile.h @@ -1,9 +1,9 @@ #pragma once #include +#include #include #include -#include class ZipFile { public: @@ -64,7 +64,7 @@ class ZipFile { // Batch lookup: scan ZIP central dir once and fill sizes for matching targets. // targets must be sorted by (hash, len). sizes[target.index] receives uncompressedSize. // Returns number of targets matched. - int fillUncompressedSizes(std::vector& targets, std::vector& sizes); + int fillUncompressedSizes(std::deque& targets, std::deque& sizes); // Due to the memory required to run each of these, it is recommended to not preopen the zip file for multiple // These functions will open and close the zip as needed uint8_t* readFileToMemory(const char* filename, size_t* size = nullptr, bool trailingNullByte = false); From 405ce0c3c881d3bb4fea14211a56983592b41923 Mon Sep 17 00:00:00 2001 From: Ian Chasse <86565956+iandchasse@users.noreply.github.com> Date: Sun, 12 Apr 2026 21:03:23 -0400 Subject: [PATCH 24/37] feat: Rework "Cover + Custom" sleep screens to show covers only when currently reading (#1256) ## Summary The cover/custom setting was a bit misleading to me. Usually after you start reading a book you never see your customs again unless a cover fails to render for some reason. With this, you can easily show your custom images by just sleeping from the menus instead of the reader ## Additional Context * Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on). --- ### 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? _**< NO >**_ --- src/activities/boot_sleep/SleepActivity.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index d6a566df3..f89e7c3b5 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -24,8 +24,13 @@ void SleepActivity::onEnter() { case (CrossPointSettings::SLEEP_SCREEN_MODE::CUSTOM): return renderCustomSleepScreen(); case (CrossPointSettings::SLEEP_SCREEN_MODE::COVER): - case (CrossPointSettings::SLEEP_SCREEN_MODE::COVER_CUSTOM): return renderCoverSleepScreen(); + case (CrossPointSettings::SLEEP_SCREEN_MODE::COVER_CUSTOM): + if (APP_STATE.lastSleepFromReader) { + return renderCoverSleepScreen(); + } else { + return renderCustomSleepScreen(); + } default: return renderDefaultSleepScreen(); } From 05f8e6e12d05eb16c6e13cc2e1aac17895f6e059 Mon Sep 17 00:00:00 2001 From: Diana <5275194+DianaNites@users.noreply.github.com> Date: Mon, 13 Apr 2026 00:59:07 -0500 Subject: [PATCH 25/37] fix: webserver /delete API backward compatibility (#1475) ## Summary * **What is the goal of this PR?** (e.g., Implements the new feature for file uploading.) This fixes #682 by restoring API compatibility for external users, eg deleting on-device books in bulk via calibre using the crosspoint plugin works now. * **What changes are included?** The `/delete` webserver API now accepts the old `path` argument and maps it to a JSON array element. It is made an error to provide both arguments. ## Additional Context * Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on). I have compiled, flashed, and tested this on master at commit 0245972132b54563976525a5841a0e9c49e8e498. Before this change, calibre cannot delete books, either individually or in bulk. After this change, it can do so successfully. --- ### 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? _**< NO >**_ --- src/network/CrossPointWebServer.cpp | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index dba8faa2d..a745783d6 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -959,16 +959,31 @@ void CrossPointWebServer::handleMove() const { } void CrossPointWebServer::handleDelete() const { - // Check if 'paths' argument is provided - if (!server->hasArg("paths")) { - server->send(400, "text/plain", "Missing paths"); + // To ensure backwards compatibility, plain `path` is mapped + // to a single element JSON array. + bool hasPathArg = server->hasArg("path"); + bool hasPathsArg = server->hasArg("paths"); + // Check 'paths' or `path` argument is provided + if (!(hasPathArg || hasPathsArg)) { + server->send(400, "text/plain", "Missing `path` or `paths` argument"); + return; + } + if (hasPathArg && hasPathsArg) { + server->send(400, "text/plain", "Provide either 'path' or 'paths', not both"); return; } // Parse paths - String pathsArg = server->arg("paths"); + String pathsArg; JsonDocument doc; - DeserializationError error = deserializeJson(doc, pathsArg); + DeserializationError error = DeserializationError(DeserializationError::Code::Ok); + if (hasPathsArg) { + pathsArg = server->arg("paths"); + error = deserializeJson(doc, pathsArg); + } else { + pathsArg = server->arg("path"); + doc.add(pathsArg); + } if (error) { server->send(400, "text/plain", "Invalid paths format"); return; From cc23aaa9c238469395fb1c890d1164cb164727a0 Mon Sep 17 00:00:00 2001 From: Russell Neches Date: Tue, 14 Apr 2026 03:50:31 +0900 Subject: [PATCH 26/37] docs: Update README with firmware flashing instructions (#1654) Added instructions for flashing firmware using esptool. ## Summary Instructions for flashing a specific firmware from the terminal using `esptool.py`. ## Additional Context Not everyone has Chrome or Chromium installed. An alternative to the web-based firmware utility shouldn't be hard to find. --- ### AI Usage Did you use AI tools to help write this code? _**PARTIALLY**_ : `dmesg` command analog for MacOS --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index b9093ea59..c27ff1e2e 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,24 @@ back to the other partition using the "Swap boot partition" button here https:// To revert back to the official firmware, you can flash the latest official firmware from https://xteink.dve.al/, or swap back to the other partition using the "Swap boot partition" button here https://xteink.dve.al/debug. +### Command line (specific firmware version) + +1. Install [`esptool`](https://github.com/espressif/esptool) : +```bash +pip install esptool +``` +2. Download the `firmware.bin` file from the release of your choice via the [releases page](https://github.com/crosspoint-reader/crosspoint-reader/releases) +3. Connect your Xteink X4 to your computer via USB-C. +4. Note the device location. On Linux, run `dmesg` after connecting. On MacOS, run : +```bash +log stream --predicate 'subsystem == "com.apple.iokit"' --info +``` +5. Flash the firmware : +```bash +esptool.py --chip esp32c3 --port /dev/ttyACM0 --baud 921600 write_flash 0x10000 /path/to/firmware.bin +``` +Change `/dev/ttyACM0` to the device for your system. + ### Manual See [Development](#development) below. From 4e9c7a787f8f0d827bb07adedaf8c7f707e611f6 Mon Sep 17 00:00:00 2001 From: zgredex <112968378+zgredex@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:12:49 +0200 Subject: [PATCH 27/37] feat: show full path bar in file browser (#1411) ![obraz](https://github.com/user-attachments/assets/f848a381-7c45-4724-912b-56168791bcf3) ## Summary Adds a full path display at the bottom of the file browser with a separator line matching the header style. Path uses the small font, left-truncates to always show the deepest folder when path is too long. Toggleable via Settings > System > Show Full Path (default on). ### 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? _**< PARTIALLY >**_ --------- Co-authored-by: Patryk Radtke --- src/activities/home/FileBrowserActivity.cpp | 37 +++++++++++++++++++-- src/components/UITheme.cpp | 4 +-- src/components/UITheme.h | 2 +- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/activities/home/FileBrowserActivity.cpp b/src/activities/home/FileBrowserActivity.cpp index 723a3e199..6b16bf8ba 100644 --- a/src/activities/home/FileBrowserActivity.cpp +++ b/src/activities/home/FileBrowserActivity.cpp @@ -164,7 +164,8 @@ void FileBrowserActivity::loop() { return; } - const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false); + const int pathReserved = renderer.getLineHeight(SMALL_FONT_ID) + UITheme::getInstance().getMetrics().verticalSpacing; + const int pageItems = UITheme::getNumberOfItemsPerPage(renderer, true, false, true, false, pathReserved); if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (files.empty()) return; @@ -294,8 +295,11 @@ void FileBrowserActivity::render(RenderLock&&) { std::string folderName = (basepath == "/") ? tr(STR_SD_CARD) : basepath.substr(basepath.rfind('/') + 1); GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, folderName.c_str()); + const int pathLineHeight = renderer.getLineHeight(SMALL_FONT_ID); + const int pathReserved = pathLineHeight + metrics.verticalSpacing; const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; - const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing; + const int contentHeight = + pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing - pathReserved; if (files.empty()) { renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, contentTop + 20, tr(STR_NO_FILES_FOUND)); } else { @@ -306,6 +310,33 @@ void FileBrowserActivity::render(RenderLock&&) { [this](int index) { return getFileExtension(files[index]); }, false); } + // Full path display + { + const int pathY = pageHeight - metrics.buttonHintsHeight - metrics.verticalSpacing - pathLineHeight; + const int separatorY = pathY - metrics.verticalSpacing / 2; + renderer.drawLine(0, separatorY, pageWidth - 1, separatorY, 3, true); + const int pathMaxWidth = pageWidth - metrics.contentSidePadding * 2; + // Left-truncate so the deepest directory is always visible + const char* pathStr = basepath.c_str(); + const char* pathDisplay = pathStr; + char leftTruncBuf[256]; + if (renderer.getTextWidth(SMALL_FONT_ID, pathStr) > pathMaxWidth) { + const char ellipsis[] = "\xe2\x80\xa6"; // UTF-8 ellipsis (…) + const int ellipsisWidth = renderer.getTextWidth(SMALL_FONT_ID, ellipsis); + const int available = pathMaxWidth - ellipsisWidth; + // Walk forward from the start until the suffix fits, skipping UTF-8 continuation bytes + const char* p = pathStr; + while (*p) { + if (renderer.getTextWidth(SMALL_FONT_ID, p) <= available) break; + ++p; + while (*p && (static_cast(*p) & 0xC0) == 0x80) ++p; + } + snprintf(leftTruncBuf, sizeof(leftTruncBuf), "%s%s", ellipsis, p); + pathDisplay = leftTruncBuf; + } + renderer.drawText(SMALL_FONT_ID, metrics.contentSidePadding, pathY, pathDisplay); + } + // Help text const auto labels = mappedInput.mapLabels(basepath == "/" ? tr(STR_HOME) : tr(STR_BACK), files.empty() ? "" : tr(STR_OPEN), @@ -319,4 +350,4 @@ size_t FileBrowserActivity::findEntry(const std::string& name) const { for (size_t i = 0; i < files.size(); i++) if (files[i] == name) return i; return 0; -} \ No newline at end of file +} diff --git a/src/components/UITheme.cpp b/src/components/UITheme.cpp index 57e494840..425981a21 100644 --- a/src/components/UITheme.cpp +++ b/src/components/UITheme.cpp @@ -49,7 +49,7 @@ void UITheme::setTheme(CrossPointSettings::UI_THEME type) { } int UITheme::getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader, bool hasTabBar, bool hasButtonHints, - bool hasSubtitle) { + bool hasSubtitle, int extraReservedHeight) { const ThemeMetrics& metrics = UITheme::getInstance().getMetrics(); int reservedHeight = metrics.topPadding; if (hasHeader) { @@ -61,7 +61,7 @@ int UITheme::getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader if (hasButtonHints) { reservedHeight += metrics.verticalSpacing + metrics.buttonHintsHeight; } - const int availableHeight = renderer.getScreenHeight() - reservedHeight; + const int availableHeight = renderer.getScreenHeight() - reservedHeight - extraReservedHeight; int rowHeight = hasSubtitle ? metrics.listWithSubtitleRowHeight : metrics.listRowHeight; return availableHeight / rowHeight; } diff --git a/src/components/UITheme.h b/src/components/UITheme.h index daa1ec452..c4c37235c 100644 --- a/src/components/UITheme.h +++ b/src/components/UITheme.h @@ -19,7 +19,7 @@ class UITheme { void reload(); void setTheme(CrossPointSettings::UI_THEME type); static int getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader, bool hasTabBar, bool hasButtonHints, - bool hasSubtitle); + bool hasSubtitle, int extraReservedHeight = 0); static std::string getCoverThumbPath(std::string coverBmpPath, int coverHeight); static UIIcon getFileIcon(const std::string& filename); static int getStatusBarHeight(); From 243ae8b40858b67ec291e8c349e10e2ac0749423 Mon Sep 17 00:00:00 2001 From: Xuan-Son Nguyen Date: Mon, 13 Apr 2026 22:23:45 +0200 Subject: [PATCH 28/37] feat: show crash reason on boot (#1453) ## Summary If the system reboots from crash, display the reason and tell user to include `crash_report.txt` file. To test this, simply add an `assert(false)` somewhere inside the code. ![screenshot-74453.bmp](https://github.com/user-attachments/files/26160373/screenshot-74453.bmp) --- ### 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? **NO** --- lib/I18n/translations/english.yaml | 4 ++ src/activities/ActivityManager.cpp | 3 ++ src/activities/ActivityManager.h | 1 + src/activities/home/CrashActivity.cpp | 61 +++++++++++++++++++++++++++ src/activities/home/CrashActivity.h | 13 ++++++ src/main.cpp | 12 +++--- 6 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 src/activities/home/CrashActivity.cpp create mode 100644 src/activities/home/CrashActivity.h diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index a948ea5a8..af66e074c 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -294,3 +294,7 @@ STR_LINK: "[link]" STR_SCREENSHOT_BUTTON: "Take screenshot" STR_AUTO_TURN_ENABLED: "Auto Turn Enabled: " STR_AUTO_TURN_PAGES_PER_MIN: "Auto Turn (Pages Per Minute)" +STR_CRASH_TITLE: "System Crash" +STR_CRASH_DESCRIPTION: "A detailed report was saved to crash_report.txt. Please include this file in your bug report." +STR_CRASH_REASON: "Crash reason:" +STR_CRASH_NO_REASON: "(No reason was recorded)" diff --git a/src/activities/ActivityManager.cpp b/src/activities/ActivityManager.cpp index 3cf115b13..bd15abbaf 100644 --- a/src/activities/ActivityManager.cpp +++ b/src/activities/ActivityManager.cpp @@ -5,6 +5,7 @@ #include "boot_sleep/BootActivity.h" #include "boot_sleep/SleepActivity.h" #include "browser/OpdsBookBrowserActivity.h" +#include "home/CrashActivity.h" #include "home/FileBrowserActivity.h" #include "home/HomeActivity.h" #include "home/RecentBooksActivity.h" @@ -196,6 +197,8 @@ void ActivityManager::goToFullScreenMessage(std::string message, EpdFontFamily:: replaceActivity(std::make_unique(renderer, mappedInput, std::move(message), style)); } +void ActivityManager::goToCrashReport() { replaceActivity(std::make_unique(renderer, mappedInput)); } + void ActivityManager::goHome() { replaceActivity(std::make_unique(renderer, mappedInput)); } void ActivityManager::pushActivity(std::unique_ptr&& activity) { diff --git a/src/activities/ActivityManager.h b/src/activities/ActivityManager.h index bc24c42e3..bc975e919 100644 --- a/src/activities/ActivityManager.h +++ b/src/activities/ActivityManager.h @@ -86,6 +86,7 @@ class ActivityManager { void goToSleep(); void goToBoot(); void goToFullScreenMessage(std::string message, EpdFontFamily::Style style = EpdFontFamily::REGULAR); + void goToCrashReport(); void goHome(); // This will move current activity to stack instead of deleting it diff --git a/src/activities/home/CrashActivity.cpp b/src/activities/home/CrashActivity.cpp new file mode 100644 index 000000000..4f5a392b9 --- /dev/null +++ b/src/activities/home/CrashActivity.cpp @@ -0,0 +1,61 @@ +#include "CrashActivity.h" + +#include +#include +#include + +#include "components/UITheme.h" +#include "fontIds.h" + +void CrashActivity::onEnter() { + Activity::onEnter(); + + panicMessage = HalSystem::getPanicInfo(false); + if (panicMessage.empty()) { + panicMessage = tr(STR_CRASH_NO_REASON); + } + HalSystem::clearPanic(); + + requestUpdateAndWait(); +} + +void CrashActivity::loop() { + if (mappedInput.isPressed(MappedInputManager::Button::Back)) { + finish(); + } +} + +void CrashActivity::render(RenderLock&&) { + renderer.clearScreen(); + + const auto& metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); + const auto contentWidth = pageWidth - 2 * metrics.contentSidePadding; + const auto x = metrics.contentSidePadding; + const auto lineHeight = renderer.getLineHeight(UI_10_FONT_ID); + + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_CRASH_TITLE)); + + int y = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + + auto descLines = renderer.wrappedText(UI_10_FONT_ID, tr(STR_CRASH_DESCRIPTION), contentWidth, 10); + for (const auto& line : descLines) { + renderer.drawText(UI_10_FONT_ID, x, y, line.c_str()); + y += lineHeight; + } + + y += metrics.verticalSpacing * 2; + renderer.drawText(UI_10_FONT_ID, x, y, tr(STR_CRASH_REASON)); + y += lineHeight + metrics.verticalSpacing; + + auto panicLines = renderer.wrappedText(UI_10_FONT_ID, panicMessage.c_str(), contentWidth, 5); + for (const auto& line : panicLines) { + renderer.drawText(UI_10_FONT_ID, x, y, line.c_str()); + y += lineHeight; + } + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} diff --git a/src/activities/home/CrashActivity.h b/src/activities/home/CrashActivity.h new file mode 100644 index 000000000..9b4641ed1 --- /dev/null +++ b/src/activities/home/CrashActivity.h @@ -0,0 +1,13 @@ +#pragma once +#include "../Activity.h" + +class CrashActivity final : public Activity { + std::string panicMessage; + + public: + explicit CrashActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) + : Activity("Crash", renderer, mappedInput) {} + void onEnter() override; + void loop() override; + void render(RenderLock&&) override; +}; diff --git a/src/main.cpp b/src/main.cpp index e77e8ba6f..e51a24333 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -253,7 +253,6 @@ void setup() { } HalSystem::checkPanic(); - HalSystem::clearPanic(); // TODO: move this to an activity when we have one to display the panic info SETTINGS.loadFromFile(); I18N.loadSettings(); @@ -290,10 +289,13 @@ void setup() { APP_STATE.loadFromFile(); RECENT_BOOKS.loadFromFile(); - // Boot to home screen if no book is open, last sleep was not from reader, back button is held, or reader activity - // crashed (indicated by readerActivityLoadCount > 0) - if (APP_STATE.openEpubPath.empty() || !APP_STATE.lastSleepFromReader || - mappedInputManager.isPressed(MappedInputManager::Button::Back) || APP_STATE.readerActivityLoadCount > 0) { + if (HalSystem::isRebootFromPanic()) { + // If we rebooted from a panic, go to crash report screen to show the panic info + activityManager.goToCrashReport(); + } else if (APP_STATE.openEpubPath.empty() || !APP_STATE.lastSleepFromReader || + mappedInputManager.isPressed(MappedInputManager::Button::Back) || APP_STATE.readerActivityLoadCount > 0) { + // Boot to home screen if no book is open, last sleep was not from reader, back button is held, or reader activity + // crashed (indicated by readerActivityLoadCount > 0) activityManager.goHome(); } else { // Clear app state to avoid getting into a boot loop if the epub doesn't load From 075ad7d0219081de4306f94f45179773b6c31bed Mon Sep 17 00:00:00 2001 From: Zach Nelson Date: Tue, 14 Apr 2026 14:35:50 -0500 Subject: [PATCH 29/37] fix: Use font metrics for combining mark positioning (#1310) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary **What is the goal of this PR?** (e.g., Implements the new feature for file uploading.) Combining diacritical marks (U+0300–U+036F) were positioned using a heuristic that centered them at the midpoint of the base glyph's **advance width**. This worked acceptably for Bookerly but produced visibly off-center marks for Noto Sans due to a fundamental difference in how the two fonts design their combining mark metrics. Builds on the work of #1037. ## The problem The two built-in body fonts encode combining mark `left` offsets with very different conventions: | Mark | Bookerly `left` | Noto Sans `left` | |---|---|---| | U+0301 (acute) | -2 | -10 | | U+0300 (grave) | -5 | -15 | | U+0302 (circumflex) | -5 | -5 | | U+0323 (dot below) | -2 | -11 | Noto Sans uses large negative `left` values because its marks are designed for placement at the post-advance cursor position, with `left` pulling the bitmap back over the base glyph. Bookerly uses small offsets because its marks sit closer to the glyph origin. The old `advance/2` centering split the difference poorly — it happened to land close to correct for Bookerly but placed Noto Sans marks roughly 6px left of center on a typical lowercase letter. There was also a bug in the vertical gap heuristic. It unconditionally computed a `raiseBy` value to prevent above-baseline marks from colliding with tall base glyphs, but it applied the same logic to **below-baseline** marks like cedilla (U+0327), dot below (U+0323), and ogonek (U+0328). For those marks, the math produced a large positive raise (e.g., 24px for dot-below on 'a'), launching them above the x-height instead of keeping them below the baseline. ## The fix **Horizontal positioning**: Instead of centering at `advance/2`, align the mark bitmap's visual midpoint directly over the base glyph bitmap's visual midpoint. This uses the base glyph's actual `left` and `width` rather than its advance width, producing correct results regardless of how the font encodes its mark offsets. **Vertical positioning**: The raise heuristic now checks `markTop - markHeight > 0` and skips below-baseline marks entirely, leaving them at their font-designed position. **Consolidation**: The shared math is extracted into two `constexpr` helpers (`combiningMark::centerOver` and `combiningMark::raiseAboveBase`) in `EpdFontData.h`, eliminating the previously triplicated inline calculations across `drawText`, `drawTextRotated90CW`, and `getTextBounds`. The `MIN_COMBINING_GAP_PX` constant is also centralized as `combiningMark::MIN_GAP_PX`. | Before | After | | -- | -- | | | | | | | --- ### 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 to analyze differences between Noto Sans and Bookerly font metrics**_ --------- Co-authored-by: Uri Tauber <142022451+Uri-Tauber@users.noreply.github.com> --- lib/EpdFont/EpdFont.cpp | 34 +++++++++++++----------- lib/EpdFont/EpdFontData.h | 31 ++++++++++++++++++++++ lib/GfxRenderer/GfxRenderer.cpp | 47 +++++++++++++-------------------- 3 files changed, 69 insertions(+), 43 deletions(-) diff --git a/lib/EpdFont/EpdFont.cpp b/lib/EpdFont/EpdFont.cpp index bfe746eff..fbcc32990 100644 --- a/lib/EpdFont/EpdFont.cpp +++ b/lib/EpdFont/EpdFont.cpp @@ -16,10 +16,10 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star } int lastBaseX = startX; - int lastBaseAdvanceFP = 0; // 12.4 fixed-point + int lastBaseLeft = 0; + int lastBaseWidth = 0; int lastBaseTop = 0; int32_t prevAdvanceFP = 0; // 12.4 fixed-point: prev glyph's advance + next kern for snap - constexpr int MIN_COMBINING_GAP_PX = 1; uint32_t cp; uint32_t prevCp = 0; while ((cp = utf8NextCodepoint(reinterpret_cast(&string)))) { @@ -31,26 +31,29 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star const EpdGlyph* glyph = getGlyph(cp); if (!glyph) { - lastBaseX += fp4::toPixel(prevAdvanceFP); // flush pending advance before resetting - prevCp = 0; - prevAdvanceFP = 0; + // Keep cursor movement stable when a base glyph is missing, but don't attach subsequent + // combining marks to stale base metrics. + if (!isCombining) { + lastBaseX += fp4::toPixel(prevAdvanceFP); // flush pending advance before resetting + prevCp = 0; + prevAdvanceFP = 0; + lastBaseLeft = 0; + lastBaseWidth = 0; + lastBaseTop = 0; + } continue; } - int raiseBy = 0; - if (isCombining) { - const int currentGap = glyph->top - glyph->height - lastBaseTop; - if (currentGap < MIN_COMBINING_GAP_PX) { - raiseBy = MIN_COMBINING_GAP_PX - currentGap; - } - } + const int raiseBy = isCombining ? combiningMark::raiseAboveBase(glyph->top, glyph->height, lastBaseTop) : 0; if (!isCombining && prevCp != 0) { const auto kernFP = getKerning(prevCp, cp); // 4.4 fixed-point kern lastBaseX += fp4::toPixel(prevAdvanceFP + kernFP); } - const int glyphBaseX = isCombining ? (lastBaseX + fp4::toPixel(lastBaseAdvanceFP / 2)) : lastBaseX; + const int glyphBaseX = + isCombining ? combiningMark::centerOver(lastBaseX, lastBaseLeft, lastBaseWidth, glyph->left, glyph->width) + : lastBaseX; const int glyphBaseY = startY - raiseBy; *minX = std::min(*minX, glyphBaseX + glyph->left); @@ -59,9 +62,10 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star *maxY = std::max(*maxY, glyphBaseY + glyph->top); if (!isCombining) { - lastBaseAdvanceFP = glyph->advanceX; // 12.4 fixed-point + lastBaseLeft = glyph->left; + lastBaseWidth = glyph->width; lastBaseTop = glyph->top; - prevAdvanceFP = lastBaseAdvanceFP; + prevAdvanceFP = glyph->advanceX; // 12.4 fixed-point prevCp = cp; } } diff --git a/lib/EpdFont/EpdFontData.h b/lib/EpdFont/EpdFontData.h index 0b70e57fd..380c5733d 100644 --- a/lib/EpdFont/EpdFontData.h +++ b/lib/EpdFont/EpdFontData.h @@ -30,6 +30,37 @@ constexpr int toPixel(int32_t fp) { return static_cast((fp + HALF) >> FRAC_ constexpr float toFloat(int32_t fp) { return fp / static_cast(1 << FRAC_BITS); } } // namespace fp4 +/// Helpers for positioning Unicode combining marks (U+0300 ff.) over a +/// preceding base glyph without GPOS anchor tables. +namespace combiningMark { + +constexpr int MIN_GAP_PX = 1; + +/// Compute the cursor-X at which to render a combining mark so its bitmap +/// is visually centered over the base glyph's bitmap. +constexpr int centerOver(int baseCursorPos, int baseLeft, int baseWidth, int markLeft, int markWidth) { + return baseCursorPos + baseLeft + baseWidth / 2 - markWidth / 2 - markLeft; +} + +/// Rotated-90CW variant of centerOver. In the rotated coordinate system +/// renderCharImpl uses (cursorY - left) instead of (cursorX + left), so +/// every left/width term inverts sign. +constexpr int centerOverRotated90CW(int baseCursorPos, int baseLeft, int baseWidth, int markLeft, int markWidth) { + return baseCursorPos - baseLeft - baseWidth / 2 + markWidth / 2 + markLeft; +} + +/// For combining marks that sit entirely above the baseline, compute how many +/// pixels to raise the mark so there is at least MIN_GAP_PX between its bottom +/// edge and the top of the base glyph. Returns 0 for marks that extend to or +/// below the baseline (e.g. cedilla, dot-below, ogonek). +constexpr int raiseAboveBase(int markTop, int markHeight, int baseTop) { + if (markTop - markHeight <= 0) return 0; + const int gap = markTop - markHeight - baseTop; + return (gap < MIN_GAP_PX) ? (MIN_GAP_PX - gap) : 0; +} + +} // namespace combiningMark + /// Fixed-point conventions used by EpdGlyph and EpdFontData: /// advanceX: 12.4 unsigned fixed-point in uint16_t (use fp4::toPixel) /// kernMatrix: 4.4 signed fixed-point in int8_t (use fp4::toPixel) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index c1a4b869f..2d685e5fe 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -216,7 +216,8 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha const EpdFontFamily::Style style) const { const int yPos = y + getFontAscenderSize(fontId); int lastBaseX = x; - int lastBaseAdvanceFP = 0; // 12.4 fixed-point + int lastBaseLeft = 0; + int lastBaseWidth = 0; int lastBaseTop = 0; int32_t prevAdvanceFP = 0; // 12.4 fixed-point: prev glyph's advance + next kern for snap @@ -236,24 +237,17 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha return; } const auto& font = fontIt->second; - constexpr int MIN_COMBINING_GAP_PX = 1; uint32_t cp; uint32_t prevCp = 0; while ((cp = utf8NextCodepoint(reinterpret_cast(&text)))) { if (utf8IsCombiningMark(cp)) { const EpdGlyph* combiningGlyph = font.getGlyph(cp, style); - int raiseBy = 0; - if (combiningGlyph) { - const int currentGap = combiningGlyph->top - combiningGlyph->height - lastBaseTop; - if (currentGap < MIN_COMBINING_GAP_PX) { - raiseBy = MIN_COMBINING_GAP_PX - currentGap; - } - } - - const int combiningX = lastBaseX + fp4::toPixel(lastBaseAdvanceFP / 2); - const int combiningY = yPos - raiseBy; - renderCharImpl(*this, renderMode, font, cp, combiningX, combiningY, black, style); + if (!combiningGlyph) continue; + const int raiseBy = combiningMark::raiseAboveBase(combiningGlyph->top, combiningGlyph->height, lastBaseTop); + const int combiningX = combiningMark::centerOver(lastBaseX, lastBaseLeft, lastBaseWidth, combiningGlyph->left, + combiningGlyph->width); + renderCharImpl(*this, renderMode, font, cp, combiningX, yPos - raiseBy, black, style); continue; } @@ -269,9 +263,10 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha const EpdGlyph* glyph = font.getGlyph(cp, style); - lastBaseAdvanceFP = glyph ? glyph->advanceX : 0; + lastBaseLeft = glyph ? glyph->left : 0; + lastBaseWidth = glyph ? glyph->width : 0; lastBaseTop = glyph ? glyph->top : 0; - prevAdvanceFP = lastBaseAdvanceFP; + prevAdvanceFP = glyph ? glyph->advanceX : 0; // 12.4 fixed-point renderCharImpl(*this, renderMode, font, cp, lastBaseX, yPos, black, style); prevCp = cp; @@ -1079,26 +1074,21 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y const auto& font = fontIt->second; int lastBaseY = y; - int lastBaseAdvanceFP = 0; // 12.4 fixed-point + int lastBaseLeft = 0; + int lastBaseWidth = 0; int lastBaseTop = 0; int32_t prevAdvanceFP = 0; // 12.4 fixed-point: prev glyph's advance + next kern for snap - constexpr int MIN_COMBINING_GAP_PX = 1; uint32_t cp; uint32_t prevCp = 0; while ((cp = utf8NextCodepoint(reinterpret_cast(&text)))) { if (utf8IsCombiningMark(cp)) { const EpdGlyph* combiningGlyph = font.getGlyph(cp, style); - int raiseBy = 0; - if (combiningGlyph) { - const int currentGap = combiningGlyph->top - combiningGlyph->height - lastBaseTop; - if (currentGap < MIN_COMBINING_GAP_PX) { - raiseBy = MIN_COMBINING_GAP_PX - currentGap; - } - } - + if (!combiningGlyph) continue; + const int raiseBy = combiningMark::raiseAboveBase(combiningGlyph->top, combiningGlyph->height, lastBaseTop); const int combiningX = x - raiseBy; - const int combiningY = lastBaseY - fp4::toPixel(lastBaseAdvanceFP / 2); + const int combiningY = combiningMark::centerOverRotated90CW(lastBaseY, lastBaseLeft, lastBaseWidth, + combiningGlyph->left, combiningGlyph->width); renderCharImpl(*this, renderMode, font, cp, combiningX, combiningY, black, style); continue; } @@ -1114,9 +1104,10 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y const EpdGlyph* glyph = font.getGlyph(cp, style); - lastBaseAdvanceFP = glyph ? glyph->advanceX : 0; // 12.4 fixed-point + lastBaseLeft = glyph ? glyph->left : 0; + lastBaseWidth = glyph ? glyph->width : 0; lastBaseTop = glyph ? glyph->top : 0; - prevAdvanceFP = lastBaseAdvanceFP; + prevAdvanceFP = glyph ? glyph->advanceX : 0; // 12.4 fixed-point renderCharImpl(*this, renderMode, font, cp, x, lastBaseY, black, style); prevCp = cp; From 23aad213fc254c078cc4725dca0367857367b519 Mon Sep 17 00:00:00 2001 From: Zach Nelson Date: Tue, 14 Apr 2026 16:41:05 -0500 Subject: [PATCH 30/37] refactor: Removed redundant FsFile close() calls (#1434) ## Summary **What is the goal of this PR?** (e.g., Implements the new feature for file uploading.) `DESTRUCTOR_CLOSES_FILE=1` is set in platformio.ini, which makes SdFat's FsBaseFile destructor call close() automatically when a file goes out of scope. Three categories of file close calls remain untouched: 1. Close before Storage.remove() on the same path: ScreenshotUtil.cpp closes the file before deleting it on write error. The remove might fail if the file is still open. 2. Close before reopening the same variable: Epub.cpp writes a temp NCX/nav file, closes it, then reopens it for reading. The RecentBooksStore.cpp close before saveToFile() is the same pattern, it rewrites the same file. 3. Close on member variables: BookMetadataCache.cpp (bookFile, spineFile, tocFile), Section.cpp (file), XtcParser.cpp (m_file), ZipFile.cpp (file). These persist beyond any single function scope, so the destructor timing doesn't match the intended close point. --- ### 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? _**PARTIALLY**_ --- lib/Epub/Epub.cpp | 24 ++++++++++++------- lib/Epub/Epub/BookMetadataCache.cpp | 8 +++++++ lib/Epub/Epub/Section.cpp | 11 ++++++--- lib/Epub/Epub/blocks/ImageBlock.cpp | 5 ---- lib/Epub/Epub/css/CssParser.cpp | 17 +------------ .../Epub/parsers/ChapterHtmlSlimParser.cpp | 4 ---- lib/I18n/I18n.cpp | 3 --- lib/KOReaderSync/KOReaderCredentialStore.cpp | 2 -- lib/KOReaderSync/KOReaderDocumentId.cpp | 2 -- lib/Txt/Txt.cpp | 9 ------- lib/Xtc/Xtc.cpp | 6 ----- lib/Xtc/Xtc/XtcParser.cpp | 6 +++++ lib/ZipFile/ZipFile.cpp | 1 + src/CrossPointSettings.cpp | 2 -- src/CrossPointState.cpp | 2 -- src/RecentBooksStore.cpp | 3 +-- src/WifiCredentialStore.cpp | 2 -- src/activities/boot_sleep/SleepActivity.cpp | 15 ------------ src/activities/home/FileBrowserActivity.cpp | 6 ----- src/activities/reader/EpubReaderActivity.cpp | 2 -- src/activities/reader/TxtReaderActivity.cpp | 12 ---------- src/components/themes/BaseTheme.cpp | 2 -- src/network/CrossPointWebServer.cpp | 3 +++ src/util/ScreenshotUtil.cpp | 3 +++ 24 files changed, 46 insertions(+), 104 deletions(-) diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index cb0b18017..a5befe5b3 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -155,6 +155,7 @@ bool Epub::parseTocNcxFile() const { return false; } readItemContentsToStream(tocNcxItem, tempNcxFile, 1024); + // Explicitly close() file before reopening for reading tempNcxFile.close(); if (!Storage.openFileForRead("EBP", tmpNcxPath, tempNcxFile)) { return false; @@ -165,14 +166,12 @@ bool Epub::parseTocNcxFile() const { if (!ncxParser.setup()) { LOG_ERR("EBP", "Could not setup toc ncx parser"); - tempNcxFile.close(); return false; } const auto ncxBuffer = static_cast(malloc(1024)); if (!ncxBuffer) { LOG_ERR("EBP", "Could not allocate memory for toc ncx parser"); - tempNcxFile.close(); return false; } @@ -184,12 +183,12 @@ bool Epub::parseTocNcxFile() const { if (processedSize != readSize) { LOG_ERR("EBP", "Could not process all toc ncx data"); free(ncxBuffer); - tempNcxFile.close(); return false; } } free(ncxBuffer); + // Explicitly close() file before calling Storage.remove() tempNcxFile.close(); Storage.remove(tmpNcxPath.c_str()); @@ -212,6 +211,7 @@ bool Epub::parseTocNavFile() const { return false; } readItemContentsToStream(tocNavItem, tempNavFile, 1024); + // Explicitly close() file before reopening for reading tempNavFile.close(); if (!Storage.openFileForRead("EBP", tmpNavPath, tempNavFile)) { return false; @@ -241,12 +241,12 @@ bool Epub::parseTocNavFile() const { if (processedSize != readSize) { LOG_ERR("EBP", "Could not process all toc nav data"); free(navBuffer); - tempNavFile.close(); return false; } } free(navBuffer); + // Explicitly close() file before calling Storage.remove() tempNavFile.close(); Storage.remove(tmpNavPath.c_str()); @@ -304,10 +304,12 @@ void Epub::parseCssFiles() const { } if (!readItemContentsToStream(cssPath, tempCssFile, 1024)) { LOG_ERR("EBP", "Could not read CSS file: %s", cssPath.c_str()); + // Explicitly close() file before calling Storage.remove() tempCssFile.close(); Storage.remove(tmpCssPath.c_str()); continue; } + // Explicitly close() file before reopening for reading tempCssFile.close(); // Parse the CSS file @@ -317,6 +319,7 @@ void Epub::parseCssFiles() const { continue; } cssParser->loadFromStream(tempCssFile); + // Explicitly close() file before calling Storage.remove() tempCssFile.close(); Storage.remove(tmpCssPath.c_str()); } @@ -547,6 +550,7 @@ bool Epub::generateCoverBmp(bool cropped) const { return false; } readItemContentsToStream(coverImageHref, coverJpg, 1024); + // Explicitly close() file before reopening for reading coverJpg.close(); if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) { @@ -555,10 +559,10 @@ bool Epub::generateCoverBmp(bool cropped) const { FsFile coverBmp; if (!Storage.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) { - coverJpg.close(); return false; } const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp, cropped); + // Explicitly close() files before calling Storage.remove() coverJpg.close(); coverBmp.close(); Storage.remove(coverJpgTempPath.c_str()); @@ -580,6 +584,7 @@ bool Epub::generateCoverBmp(bool cropped) const { return false; } readItemContentsToStream(coverImageHref, coverPng, 1024); + // Explicitly close() file before reopening for reading coverPng.close(); if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) { @@ -588,10 +593,10 @@ bool Epub::generateCoverBmp(bool cropped) const { FsFile coverBmp; if (!Storage.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) { - coverPng.close(); return false; } const bool success = PngToBmpConverter::pngFileToBmpStream(coverPng, coverBmp, cropped); + // Explicitly close() files before calling Storage.remove() coverPng.close(); coverBmp.close(); Storage.remove(coverPngTempPath.c_str()); @@ -634,6 +639,7 @@ bool Epub::generateThumbBmp(int height) const { return false; } readItemContentsToStream(coverImageHref, coverJpg, 1024); + // Explicitly close() file before reopening for reading coverJpg.close(); if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) { @@ -642,7 +648,6 @@ bool Epub::generateThumbBmp(int height) const { FsFile thumbBmp; if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) { - coverJpg.close(); return false; } // Use smaller target size for Continue Reading card (half of screen: 240x400) @@ -651,6 +656,7 @@ bool Epub::generateThumbBmp(int height) const { int THUMB_TARGET_HEIGHT = height; const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH, THUMB_TARGET_HEIGHT); + // Explicitly close() files before calling Storage.remove() coverJpg.close(); thumbBmp.close(); Storage.remove(coverJpgTempPath.c_str()); @@ -670,6 +676,7 @@ bool Epub::generateThumbBmp(int height) const { return false; } readItemContentsToStream(coverImageHref, coverPng, 1024); + // Explicitly close() file before reopening for reading coverPng.close(); if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) { @@ -678,13 +685,13 @@ bool Epub::generateThumbBmp(int height) const { FsFile thumbBmp; if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) { - coverPng.close(); return false; } int THUMB_TARGET_WIDTH = height * 0.6; int THUMB_TARGET_HEIGHT = height; const bool success = PngToBmpConverter::pngFileTo1BitBmpStreamWithSize(coverPng, thumbBmp, THUMB_TARGET_WIDTH, THUMB_TARGET_HEIGHT); + // Explicitly close() files before calling Storage.remove() coverPng.close(); thumbBmp.close(); Storage.remove(coverPngTempPath.c_str()); @@ -702,7 +709,6 @@ bool Epub::generateThumbBmp(int height) const { // Write an empty bmp file to avoid generation attempts in the future FsFile thumbBmp; Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp); - thumbBmp.close(); return false; } diff --git a/lib/Epub/Epub/BookMetadataCache.cpp b/lib/Epub/Epub/BookMetadataCache.cpp index ffc456cad..8985776b0 100644 --- a/lib/Epub/Epub/BookMetadataCache.cpp +++ b/lib/Epub/Epub/BookMetadataCache.cpp @@ -33,6 +33,7 @@ bool BookMetadataCache::beginContentOpfPass() { } bool BookMetadataCache::endContentOpfPass() { + // Explicit close() required: member variable persists beyond function scope spineFile.close(); return true; } @@ -44,6 +45,7 @@ bool BookMetadataCache::beginTocPass() { return false; } if (!Storage.openFileForWrite("BMC", cachePath + tmpTocBinFile, tocFile)) { + // Explicit close() required: member variable persists beyond function scope spineFile.close(); return false; } @@ -75,6 +77,7 @@ bool BookMetadataCache::beginTocPass() { } bool BookMetadataCache::endTocPass() { + // Explicit close() required: member variables persist beyond function scope tocFile.close(); spineFile.close(); @@ -103,11 +106,13 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta } if (!Storage.openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) { + // Explicit close() required: member variable persists beyond function scope bookFile.close(); return false; } if (!Storage.openFileForRead("BMC", cachePath + tmpTocBinFile, tocFile)) { + // Explicit close() required: member variables persist beyond function scope bookFile.close(); spineFile.close(); return false; @@ -168,6 +173,7 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta // Pre-open zip file to speed up size calculations if (!zip.open()) { LOG_ERR("BMC", "Could not open EPUB zip for size calculations"); + // Explicit close() required: member variables persist beyond function scope bookFile.close(); spineFile.close(); tocFile.close(); @@ -265,6 +271,7 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta writeTocEntry(bookFile, tocEntry); } + // Explicit close() required: member variables persist beyond function scope bookFile.close(); spineFile.close(); tocFile.close(); @@ -373,6 +380,7 @@ bool BookMetadataCache::load() { serialization::readPod(bookFile, version); if (version != BOOK_CACHE_VERSION) { LOG_DBG("BMC", "Cache version mismatch: expected %d, got %d", BOOK_CACHE_VERSION, version); + // Explicit close() required: member variable persists beyond function scope bookFile.close(); return false; } diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 7c0b3c12b..da74ce648 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -74,6 +74,7 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con uint8_t version; serialization::readPod(file, version); if (version != SECTION_FILE_VERSION) { + // Explicit close() required: member variable persists beyond function scope file.close(); LOG_ERR("SCT", "Deserialization failed: Unknown version %u", version); clearCache(); @@ -103,6 +104,7 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con viewportWidth != fileViewportWidth || viewportHeight != fileViewportHeight || hyphenationEnabled != fileHyphenationEnabled || embeddedStyle != fileEmbeddedStyle || imageRendering != fileImageRendering) { + // Explicit close() required: member variable persists beyond function scope file.close(); LOG_ERR("SCT", "Deserialization failed: Parameters do not match"); clearCache(); @@ -111,6 +113,7 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con } serialization::readPod(file, pageCount); + // Explicit close() required: member variable persists beyond function scope file.close(); LOG_DBG("SCT", "Deserialization succeeded: %d pages", pageCount); return true; @@ -165,6 +168,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c } success = epub->readItemContentsToStream(localPath, tmpHtml, 1024); fileSize = tmpHtml.size(); + // Explicitly close() file before calling Storage.remove() tmpHtml.close(); // If streaming failed, remove the incomplete file immediately @@ -214,6 +218,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c Storage.remove(tmpHtmlPath.c_str()); if (!success) { LOG_ERR("SCT", "Failed to parse XML and build pages"); + // Explicitly close() file before calling Storage.remove() file.close(); Storage.remove(filePath.c_str()); if (cssParser) { @@ -235,6 +240,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c if (hasFailedLutRecords) { LOG_ERR("SCT", "Failed to write LUT due to invalid page positions"); + // Explicitly close() file before calling Storage.remove() file.close(); Storage.remove(filePath.c_str()); return false; @@ -254,6 +260,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c serialization::writePod(file, pageCount); serialization::writePod(file, lutOffset); serialization::writePod(file, anchorMapOffset); + // Explicit close() required: member variable persists beyond function scope file.close(); if (cssParser) { cssParser->clear(); @@ -275,6 +282,7 @@ std::unique_ptr Section::loadPageFromSectionFile() { file.seek(pagePos); auto page = Page::deserialize(file); + // Explicit close() required: member variable persists beyond function scope file.close(); return page; } @@ -290,7 +298,6 @@ std::optional Section::getPageForAnchor(const std::string& anchor) con uint32_t anchorMapOffset; serialization::readPod(f, anchorMapOffset); if (anchorMapOffset == 0 || anchorMapOffset >= fileSize) { - f.close(); return std::nullopt; } @@ -303,11 +310,9 @@ std::optional Section::getPageForAnchor(const std::string& anchor) con serialization::readString(f, key); serialization::readPod(f, page); if (key == anchor) { - f.close(); return page; } } - f.close(); return std::nullopt; } diff --git a/lib/Epub/Epub/blocks/ImageBlock.cpp b/lib/Epub/Epub/blocks/ImageBlock.cpp index 9a958d11c..1b71817a8 100644 --- a/lib/Epub/Epub/blocks/ImageBlock.cpp +++ b/lib/Epub/Epub/blocks/ImageBlock.cpp @@ -37,7 +37,6 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, uint16_t cachedWidth, cachedHeight; if (cacheFile.read(&cachedWidth, 2) != 2 || cacheFile.read(&cachedHeight, 2) != 2) { - cacheFile.close(); return false; } @@ -47,7 +46,6 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, if (widthDiff > 1 || heightDiff > 1) { LOG_ERR("IMG", "Cache dimension mismatch: %dx%d vs %dx%d", cachedWidth, cachedHeight, expectedWidth, expectedHeight); - cacheFile.close(); return false; } @@ -62,7 +60,6 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, uint8_t* rowBuffer = (uint8_t*)malloc(bytesPerRow); if (!rowBuffer) { LOG_ERR("IMG", "Failed to allocate row buffer"); - cacheFile.close(); return false; } @@ -73,7 +70,6 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, if (cacheFile.read(rowBuffer, bytesPerRow) != bytesPerRow) { LOG_ERR("IMG", "Cache read error at row %d", row); free(rowBuffer); - cacheFile.close(); return false; } @@ -89,7 +85,6 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, } free(rowBuffer); - cacheFile.close(); LOG_DBG("IMG", "Cache render complete"); return true; } diff --git a/lib/Epub/Epub/css/CssParser.cpp b/lib/Epub/Epub/css/CssParser.cpp index d2e679c36..8f7237e5c 100644 --- a/lib/Epub/Epub/css/CssParser.cpp +++ b/lib/Epub/Epub/css/CssParser.cpp @@ -743,7 +743,6 @@ bool CssParser::saveToCache() const { } LOG_DBG("CSS", "Saved %u rules to cache", ruleCount); - file.close(); return true; } @@ -765,6 +764,7 @@ bool CssParser::loadFromCache() { if (file.read(&version, 1) != 1 || version != CssParser::CSS_CACHE_VERSION) { LOG_DBG("CSS", "Cache version mismatch (got %u, expected %u), removing stale cache for rebuild", version, CssParser::CSS_CACHE_VERSION); + // Explicitly close() file before calling Storage.remove() file.close(); Storage.remove((cachePath + rulesCache).c_str()); return false; @@ -773,14 +773,12 @@ bool CssParser::loadFromCache() { // Read rule count uint16_t ruleCount = 0; if (file.read(&ruleCount, sizeof(ruleCount)) != sizeof(ruleCount)) { - file.close(); return false; } if (ruleCount > MAX_RULES) { LOG_DBG("CSS", "Invalid cache rule count (%u > %zu)", ruleCount, MAX_RULES); rulesBySelector_.clear(); - file.close(); return false; } @@ -799,19 +797,16 @@ bool CssParser::loadFromCache() { uint16_t selectorLen = 0; if (!hasRemainingBytes(sizeof(selectorLen))) { rulesBySelector_.clear(); - file.close(); return false; } if (file.read(&selectorLen, sizeof(selectorLen)) != sizeof(selectorLen)) { rulesBySelector_.clear(); - file.close(); return false; } if (selectorLen == 0 || selectorLen > MAX_SELECTOR_LENGTH || !hasRemainingBytes(selectorLen)) { LOG_DBG("CSS", "Invalid selector length in cache: %u", selectorLen); rulesBySelector_.clear(); - file.close(); return false; } @@ -819,14 +814,12 @@ bool CssParser::loadFromCache() { selector.resize(selectorLen); if (file.read(&selector[0], selectorLen) != selectorLen) { rulesBySelector_.clear(); - file.close(); return false; } if (!hasRemainingBytes(CSS_FIXED_STYLE_BYTES)) { LOG_DBG("CSS", "Truncated CSS cache while reading style payload"); rulesBySelector_.clear(); - file.close(); return false; } @@ -836,28 +829,24 @@ bool CssParser::loadFromCache() { if (file.read(&enumVal, 1) != 1) { rulesBySelector_.clear(); - file.close(); return false; } style.textAlign = static_cast(enumVal); if (file.read(&enumVal, 1) != 1) { rulesBySelector_.clear(); - file.close(); return false; } style.fontStyle = static_cast(enumVal); if (file.read(&enumVal, 1) != 1) { rulesBySelector_.clear(); - file.close(); return false; } style.fontWeight = static_cast(enumVal); if (file.read(&enumVal, 1) != 1) { rulesBySelector_.clear(); - file.close(); return false; } style.textDecoration = static_cast(enumVal); @@ -880,7 +869,6 @@ bool CssParser::loadFromCache() { !readLength(style.paddingBottom) || !readLength(style.paddingLeft) || !readLength(style.paddingRight) || !readLength(style.imageHeight) || !readLength(style.imageWidth)) { rulesBySelector_.clear(); - file.close(); return false; } @@ -888,7 +876,6 @@ bool CssParser::loadFromCache() { uint8_t displayVal; if (file.read(&displayVal, 1) != 1) { rulesBySelector_.clear(); - file.close(); return false; } style.display = static_cast(displayVal); @@ -897,7 +884,6 @@ bool CssParser::loadFromCache() { uint16_t definedBits = 0; if (file.read(&definedBits, sizeof(definedBits)) != sizeof(definedBits)) { rulesBySelector_.clear(); - file.close(); return false; } style.defined.textAlign = (definedBits & 1 << 0) != 0; @@ -921,6 +907,5 @@ bool CssParser::loadFromCache() { } LOG_DBG("CSS", "Loaded %u rules from cache", ruleCount); - file.close(); return true; } diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 368a4c60d..957a6b0a3 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -1018,7 +1018,6 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); - file.close(); return false; } @@ -1030,7 +1029,6 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); - file.close(); return false; } @@ -1043,7 +1041,6 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); - file.close(); return false; } } while (!done); @@ -1053,7 +1050,6 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); - file.close(); // Process last page if there is still text if (currentTextBlock) { diff --git a/lib/I18n/I18n.cpp b/lib/I18n/I18n.cpp index 55cf8fc49..545e14360 100644 --- a/lib/I18n/I18n.cpp +++ b/lib/I18n/I18n.cpp @@ -71,7 +71,6 @@ void I18n::loadSettings() { serialization::readPod(file, version); if (version != SETTINGS_VERSION) { Serial.printf("[I18N] Settings version mismatch\n"); - file.close(); return; } @@ -81,8 +80,6 @@ void I18n::loadSettings() { _language = static_cast(lang); Serial.printf("[I18N] Loaded language: %d\n", static_cast(_language)); } - - file.close(); } // Generate character set for a specific language diff --git a/lib/KOReaderSync/KOReaderCredentialStore.cpp b/lib/KOReaderSync/KOReaderCredentialStore.cpp index 574f2b8e9..ce7132df2 100644 --- a/lib/KOReaderSync/KOReaderCredentialStore.cpp +++ b/lib/KOReaderSync/KOReaderCredentialStore.cpp @@ -82,7 +82,6 @@ bool KOReaderCredentialStore::loadFromBinaryFile() { serialization::readPod(file, version); if (version != KOREADER_FILE_VERSION) { LOG_DBG("KRS", "Unknown file version: %u", version); - file.close(); return false; } @@ -113,7 +112,6 @@ bool KOReaderCredentialStore::loadFromBinaryFile() { matchMethod = DocumentMatchMethod::FILENAME; } - file.close(); LOG_DBG("KRS", "Loaded KOReader credentials from binary for user: %s", username.c_str()); return true; } diff --git a/lib/KOReaderSync/KOReaderDocumentId.cpp b/lib/KOReaderSync/KOReaderDocumentId.cpp index efb18d1b7..887214191 100644 --- a/lib/KOReaderSync/KOReaderDocumentId.cpp +++ b/lib/KOReaderSync/KOReaderDocumentId.cpp @@ -84,8 +84,6 @@ std::string KOReaderDocumentId::calculate(const std::string& filePath) { } } - file.close(); - // Calculate final hash md5.calculate(); std::string result = md5.toString().c_str(); diff --git a/lib/Txt/Txt.cpp b/lib/Txt/Txt.cpp index 83ef123cc..0209923a8 100644 --- a/lib/Txt/Txt.cpp +++ b/lib/Txt/Txt.cpp @@ -120,7 +120,6 @@ bool Txt::generateCoverBmp() const { return false; } if (!Storage.openFileForWrite("TXT", getCoverBmpPath(), dst)) { - src.close(); return false; } uint8_t buffer[1024]; @@ -128,8 +127,6 @@ bool Txt::generateCoverBmp() const { size_t bytesRead = src.read(buffer, sizeof(buffer)); dst.write(buffer, bytesRead); } - src.close(); - dst.close(); LOG_DBG("TXT", "Copied BMP cover to cache"); return true; } else if (FsHelpers::hasJpgExtension(coverImagePath)) { @@ -140,12 +137,9 @@ bool Txt::generateCoverBmp() const { return false; } if (!Storage.openFileForWrite("TXT", getCoverBmpPath(), coverBmp)) { - coverJpg.close(); return false; } const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp); - coverJpg.close(); - coverBmp.close(); if (!success) { LOG_ERR("TXT", "Failed to generate BMP from JPG cover image"); @@ -172,12 +166,9 @@ bool Txt::readContent(uint8_t* buffer, size_t offset, size_t length) const { } if (!file.seek(offset)) { - file.close(); return false; } size_t bytesRead = file.read(buffer, length); - file.close(); - return bytesRead > 0; } diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index 71e5f28c2..893e8169c 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -199,7 +199,6 @@ bool Xtc::generateCoverBmp() const { uint8_t* rowBuffer = static_cast(malloc(dstRowSize)); if (!rowBuffer) { free(pageBuffer); - coverBmp.close(); return false; } @@ -255,7 +254,6 @@ bool Xtc::generateCoverBmp() const { } } - coverBmp.close(); free(pageBuffer); LOG_DBG("XTC", "Generated cover BMP: %s", getCoverBmpPath().c_str()); @@ -316,9 +314,7 @@ bool Xtc::generateThumbBmp(int height) const { size_t bytesRead = src.read(buffer, sizeof(buffer)); dst.write(buffer, bytesRead); } - dst.close(); } - src.close(); } LOG_DBG("XTC", "Copied cover to thumb (no scaling needed)"); return Storage.exists(getThumbBmpPath(height).c_str()); @@ -372,7 +368,6 @@ bool Xtc::generateThumbBmp(int height) const { uint8_t* rowBuffer = static_cast(malloc(rowSize)); if (!rowBuffer) { free(pageBuffer); - thumbBmp.close(); return false; } @@ -479,7 +474,6 @@ bool Xtc::generateThumbBmp(int height) const { } free(rowBuffer); - thumbBmp.close(); free(pageBuffer); LOG_DBG("XTC", "Generated thumb BMP (%dx%d): %s", thumbWidth, thumbHeight, getThumbBmpPath(height).c_str()); diff --git a/lib/Xtc/Xtc/XtcParser.cpp b/lib/Xtc/Xtc/XtcParser.cpp index 12e8a61db..183afea92 100644 --- a/lib/Xtc/Xtc/XtcParser.cpp +++ b/lib/Xtc/Xtc/XtcParser.cpp @@ -43,6 +43,7 @@ XtcError XtcParser::open(const char* filepath) { m_lastError = readHeader(); if (m_lastError != XtcError::OK) { LOG_DBG("XTC", "Failed to read header: %s", errorToString(m_lastError)); + // Explicit close() required: member variable persists beyond function scope m_file.close(); return m_lastError; } @@ -52,12 +53,14 @@ XtcError XtcParser::open(const char* filepath) { m_lastError = readTitle(); if (m_lastError != XtcError::OK) { LOG_DBG("XTC", "Failed to read title: %s", errorToString(m_lastError)); + // Explicit close() required: member variable persists beyond function scope m_file.close(); return m_lastError; } m_lastError = readAuthor(); if (m_lastError != XtcError::OK) { LOG_DBG("XTC", "Failed to read author: %s", errorToString(m_lastError)); + // Explicit close() required: member variable persists beyond function scope m_file.close(); return m_lastError; } @@ -67,6 +70,7 @@ XtcError XtcParser::open(const char* filepath) { m_lastError = readPageTable(); if (m_lastError != XtcError::OK) { LOG_DBG("XTC", "Failed to read page table: %s", errorToString(m_lastError)); + // Explicit close() required: member variable persists beyond function scope m_file.close(); return m_lastError; } @@ -75,6 +79,7 @@ XtcError XtcParser::open(const char* filepath) { m_lastError = readChapters(); if (m_lastError != XtcError::OK) { LOG_DBG("XTC", "Failed to read chapters: %s", errorToString(m_lastError)); + // Explicit close() required: member variable persists beyond function scope m_file.close(); return m_lastError; } @@ -86,6 +91,7 @@ XtcError XtcParser::open(const char* filepath) { void XtcParser::close() { if (m_isOpen) { + // Explicit close() required: member variable persists beyond function scope m_file.close(); m_isOpen = false; } diff --git a/lib/ZipFile/ZipFile.cpp b/lib/ZipFile/ZipFile.cpp index bdf5c0e25..fe59dfaa0 100644 --- a/lib/ZipFile/ZipFile.cpp +++ b/lib/ZipFile/ZipFile.cpp @@ -278,6 +278,7 @@ bool ZipFile::open() { bool ZipFile::close() { if (file) { + // Explicit close() required: member variable persists beyond function scope file.close(); } lastCentralDirPos = 0; diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index aca87bdc5..4ba71cfd7 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -126,7 +126,6 @@ bool CrossPointSettings::loadFromBinaryFile() { serialization::readPod(inputFile, version); if (version != SETTINGS_FILE_VERSION) { LOG_ERR("CPS", "Deserialization failed: Unknown version %u", version); - inputFile.close(); return false; } @@ -220,7 +219,6 @@ bool CrossPointSettings::loadFromBinaryFile() { applyLegacyFrontButtonLayout(*this); } - inputFile.close(); LOG_DBG("CPS", "Settings loaded from binary file"); return true; } diff --git a/src/CrossPointState.cpp b/src/CrossPointState.cpp index af8bdda59..7d084da6e 100644 --- a/src/CrossPointState.cpp +++ b/src/CrossPointState.cpp @@ -55,7 +55,6 @@ bool CrossPointState::loadFromBinaryFile() { serialization::readPod(inputFile, version); if (version > STATE_FILE_VERSION) { LOG_ERR("CPS", "Deserialization failed: Unknown version %u", version); - inputFile.close(); return false; } @@ -76,6 +75,5 @@ bool CrossPointState::loadFromBinaryFile() { lastSleepFromReader = false; } - inputFile.close(); return true; } diff --git a/src/RecentBooksStore.cpp b/src/RecentBooksStore.cpp index f5a2c0483..b34f8523b 100644 --- a/src/RecentBooksStore.cpp +++ b/src/RecentBooksStore.cpp @@ -163,6 +163,7 @@ bool RecentBooksStore::loadFromBinaryFile() { } if (omitted > 0) { + // Explicitly close() file before saveToFile() rewrites the same file inputFile.close(); saveToFile(); LOG_DBG("RBS", "Omitted %u recent book(s) with missing title", omitted); @@ -170,11 +171,9 @@ bool RecentBooksStore::loadFromBinaryFile() { } } else { LOG_ERR("RBS", "Deserialization failed: Unknown version %u", version); - inputFile.close(); return false; } - inputFile.close(); LOG_DBG("RBS", "Recent books loaded from binary file (%d entries)", static_cast(recentBooks.size())); return true; } diff --git a/src/WifiCredentialStore.cpp b/src/WifiCredentialStore.cpp index a95be3142..82102898d 100644 --- a/src/WifiCredentialStore.cpp +++ b/src/WifiCredentialStore.cpp @@ -76,7 +76,6 @@ bool WifiCredentialStore::loadFromBinaryFile() { serialization::readPod(file, version); if (version > WIFI_FILE_VERSION) { LOG_DBG("WCS", "Unknown file version: %u", version); - file.close(); return false; } @@ -98,7 +97,6 @@ bool WifiCredentialStore::loadFromBinaryFile() { credentials.push_back(cred); } - file.close(); // LOG_DBG("WCS", "Loaded %zu WiFi credentials from binary file", credentials.size()); return true; } diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index f89e7c3b5..64fa11d91 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -43,7 +43,6 @@ void SleepActivity::renderCustomSleepScreen() const { if (dir && dir.isDirectory()) { sleepDir = "/.sleep"; } else { - if (dir) dir.close(); dir = Storage.open("/sleep"); if (dir && dir.isDirectory()) { sleepDir = "/sleep"; @@ -56,29 +55,24 @@ void SleepActivity::renderCustomSleepScreen() const { // collect all valid BMP files for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) { if (file.isDirectory()) { - file.close(); continue; } file.getName(name, sizeof(name)); auto filename = std::string(name); if (filename[0] == '.') { - file.close(); continue; } if (!FsHelpers::hasBmpExtension(filename)) { LOG_DBG("SLP", "Skipping non-.bmp file name: %s", name); - file.close(); continue; } Bitmap bitmap(file); if (bitmap.parseHeaders() != BmpReaderError::Ok) { LOG_DBG("SLP", "Skipping invalid BMP file: %s", name); - file.close(); continue; } files.emplace_back(filename); - file.close(); } const auto numFiles = files.size(); if (numFiles > 0) { @@ -98,16 +92,11 @@ void SleepActivity::renderCustomSleepScreen() const { Bitmap bitmap(file, true); if (bitmap.parseHeaders() == BmpReaderError::Ok) { renderBitmapSleepScreen(bitmap); - file.close(); - dir.close(); return; } - file.close(); } } } - if (dir) dir.close(); - // Look for sleep.bmp on the root of the sd card to determine if we should // render a custom sleep screen instead of the default. FsFile file; @@ -116,10 +105,8 @@ void SleepActivity::renderCustomSleepScreen() const { if (bitmap.parseHeaders() == BmpReaderError::Ok) { LOG_DBG("SLP", "Loading: /sleep.bmp"); renderBitmapSleepScreen(bitmap); - file.close(); return; } - file.close(); } renderDefaultSleepScreen(); @@ -286,10 +273,8 @@ void SleepActivity::renderCoverSleepScreen() const { if (bitmap.parseHeaders() == BmpReaderError::Ok) { LOG_DBG("SLP", "Rendering sleep cover: %s", coverBmpPath.c_str()); renderBitmapSleepScreen(bitmap); - file.close(); return; } - file.close(); } return (this->*renderNoCoverSleepScreen)(); diff --git a/src/activities/home/FileBrowserActivity.cpp b/src/activities/home/FileBrowserActivity.cpp index 6b16bf8ba..1a4b45513 100644 --- a/src/activities/home/FileBrowserActivity.cpp +++ b/src/activities/home/FileBrowserActivity.cpp @@ -75,7 +75,6 @@ void FileBrowserActivity::loadFiles() { auto root = Storage.open(basepath.c_str()); if (!root || !root.isDirectory()) { - if (root) root.close(); return; } @@ -85,7 +84,6 @@ void FileBrowserActivity::loadFiles() { for (auto file = root.openNextFile(); file; file = root.openNextFile()) { file.getName(name, sizeof(name)); if ((!SETTINGS.showHiddenFiles && name[0] == '.') || strcmp(name, "System Volume Information") == 0) { - file.close(); continue; } @@ -99,9 +97,7 @@ void FileBrowserActivity::loadFiles() { files.emplace_back(filename); } } - file.close(); } - root.close(); sortFileList(files); } @@ -115,7 +111,6 @@ void FileBrowserActivity::onEnter() { basepath = "/"; loadFiles(); } else if (!root.isDirectory()) { - root.close(); lockLongPressBack = mappedInput.isPressed(MappedInputManager::Button::Back); const std::string oldPath = basepath; @@ -126,7 +121,6 @@ void FileBrowserActivity::onEnter() { const std::string fileName = oldPath.substr(pos + 1); selectorIndex = findEntry(fileName); } else { - root.close(); loadFiles(); } diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index f6d58bfc4..21e6f82e2 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -69,7 +69,6 @@ void EpubReaderActivity::onEnter() { if (dataSize == 6) { cachedChapterTotalPageCount = data[4] + (data[5] << 8); } - f.close(); } // We may want a better condition to detect if we are opening for the first time. // This will trigger if the book is re-opened at Chapter 0. @@ -696,7 +695,6 @@ void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageC data[4] = pageCount & 0xFF; data[5] = (pageCount >> 8) & 0xFF; f.write(data, 6); - f.close(); LOG_DBG("ERS", "Progress saved: Chapter %d, Page %d", spineIndex, currentPage); } else { LOG_ERR("ERS", "Could not save progress!"); diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 6bc3ba5ad..fba7bc0bc 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -405,7 +405,6 @@ void TxtReaderActivity::saveProgress() const { data[2] = 0; data[3] = 0; f.write(data, 4); - f.close(); } } @@ -423,7 +422,6 @@ void TxtReaderActivity::loadProgress() { } LOG_DBG("TRS", "Loaded progress: page %d/%d", currentPage, totalPages); } - f.close(); } } @@ -452,7 +450,6 @@ bool TxtReaderActivity::loadPageIndexCache() { serialization::readPod(f, magic); if (magic != CACHE_MAGIC) { LOG_DBG("TRS", "Cache magic mismatch, rebuilding"); - f.close(); return false; } @@ -460,7 +457,6 @@ bool TxtReaderActivity::loadPageIndexCache() { serialization::readPod(f, version); if (version != CACHE_VERSION) { LOG_DBG("TRS", "Cache version mismatch (%d != %d), rebuilding", version, CACHE_VERSION); - f.close(); return false; } @@ -468,7 +464,6 @@ bool TxtReaderActivity::loadPageIndexCache() { serialization::readPod(f, fileSize); if (fileSize != txt->getFileSize()) { LOG_DBG("TRS", "Cache file size mismatch, rebuilding"); - f.close(); return false; } @@ -476,7 +471,6 @@ bool TxtReaderActivity::loadPageIndexCache() { serialization::readPod(f, cachedWidth); if (cachedWidth != viewportWidth) { LOG_DBG("TRS", "Cache viewport width mismatch, rebuilding"); - f.close(); return false; } @@ -484,7 +478,6 @@ bool TxtReaderActivity::loadPageIndexCache() { serialization::readPod(f, cachedLines); if (cachedLines != linesPerPage) { LOG_DBG("TRS", "Cache lines per page mismatch, rebuilding"); - f.close(); return false; } @@ -492,7 +485,6 @@ bool TxtReaderActivity::loadPageIndexCache() { serialization::readPod(f, fontId); if (fontId != cachedFontId) { LOG_DBG("TRS", "Cache font ID mismatch (%d != %d), rebuilding", fontId, cachedFontId); - f.close(); return false; } @@ -500,7 +492,6 @@ bool TxtReaderActivity::loadPageIndexCache() { serialization::readPod(f, margin); if (margin != cachedScreenMargin) { LOG_DBG("TRS", "Cache screen margin mismatch, rebuilding"); - f.close(); return false; } @@ -508,7 +499,6 @@ bool TxtReaderActivity::loadPageIndexCache() { serialization::readPod(f, alignment); if (alignment != cachedParagraphAlignment) { LOG_DBG("TRS", "Cache paragraph alignment mismatch, rebuilding"); - f.close(); return false; } @@ -525,7 +515,6 @@ bool TxtReaderActivity::loadPageIndexCache() { pageOffsets.push_back(offset); } - f.close(); totalPages = pageOffsets.size(); LOG_DBG("TRS", "Loaded page index cache: %d pages", totalPages); return true; @@ -555,6 +544,5 @@ void TxtReaderActivity::savePageIndexCache() const { serialization::writePod(f, static_cast(offset)); } - f.close(); LOG_DBG("TRS", "Saved page index cache: %d pages", totalPages); } diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index 055b37a7a..6f7f84e40 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -422,7 +422,6 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std: bookWidth = rect.width / 2; // Fallback } } - file.close(); } } @@ -476,7 +475,6 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std: renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); } } - file.close(); } } diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index a745783d6..d18eebd13 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -193,6 +193,7 @@ void CrossPointWebServer::begin() { } void CrossPointWebServer::abortWsUpload(const char* tag) { + // Explicit close() required: file-scope global persists beyond function scope wsUploadFile.close(); String filePath = wsUploadPath; if (!filePath.endsWith("/")) filePath += "/"; @@ -1342,6 +1343,7 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* // Zero-byte upload: complete immediately without waiting for BIN frames if (wsUploadSize == 0) { + // Explicit close() required: file-scope global persists beyond function scope wsUploadFile.close(); wsLastCompleteName = wsUploadFileName; wsLastCompleteSize = 0; @@ -1397,6 +1399,7 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* // Check if upload complete if (wsUploadReceived >= wsUploadSize) { + // Explicit close() required: file-scope global persists beyond function scope wsUploadFile.close(); wsUploadInProgress = false; wsUploadClientNum = 255; diff --git a/src/util/ScreenshotUtil.cpp b/src/util/ScreenshotUtil.cpp index 1a564a61b..a152488d7 100644 --- a/src/util/ScreenshotUtil.cpp +++ b/src/util/ScreenshotUtil.cpp @@ -70,6 +70,7 @@ bool ScreenshotUtil::saveFramebufferAsBmp(const char* filename, const uint8_t* f } if (write_error) { + // Explicitly close() file before calling Storage.remove() file.close(); Storage.remove(filename); return false; @@ -80,6 +81,7 @@ bool ScreenshotUtil::saveFramebufferAsBmp(const char* filename, const uint8_t* f constexpr size_t kMaxRowSize = 68; if (rowSizePadded > kMaxRowSize) { LOG_ERR("SCR", "Row size %u exceeds buffer capacity", rowSizePadded); + // Explicitly close() file before calling Storage.remove() file.close(); Storage.remove(filename); return false; @@ -106,6 +108,7 @@ bool ScreenshotUtil::saveFramebufferAsBmp(const char* filename, const uint8_t* f memset(rowBuffer, 0, rowSizePadded); // Clear the buffer for the next row } + // Explicitly close() file before calling Storage.remove() file.close(); if (write_error) { From 1bd7a1de67f7e3c96242ecef8ee6bcac61278531 Mon Sep 17 00:00:00 2001 From: Zach Nelson Date: Tue, 14 Apr 2026 17:05:06 -0500 Subject: [PATCH 31/37] refactor: Deduplicate battery drawing code and fix Lyra charging indicator (#1437) ## Summary **What is the goal of this PR?** Following up on #1427: - Extracted shared battery drawing logic, including lightning bolt, to reduce duplication. - In Lyra with segmented battery the lightning bolt was hard to see, so when charging Lyra now uses a solid battery fill. --- ### 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? _**PARTIALLY**_ --- src/components/themes/BaseTheme.cpp | 60 ++++++++++++++---------- src/components/themes/BaseTheme.h | 5 ++ src/components/themes/lyra/LyraTheme.cpp | 57 ++++++++-------------- 3 files changed, 59 insertions(+), 63 deletions(-) diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index 6f7f84e40..0d6dcd336 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -16,24 +16,14 @@ // Internal constants namespace { -constexpr int batteryPercentSpacing = 4; constexpr int homeMenuMargin = 20; constexpr int homeMarginTop = 30; constexpr int subtitleY = 738; // Helper: draw battery icon at given position void drawBatteryIcon(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight, uint16_t percentage) { - // Top line - renderer.drawLine(x + 1, y, x + battWidth - 3, y); - // Bottom line - renderer.drawLine(x + 1, y + rectHeight - 1, x + battWidth - 3, y + rectHeight - 1); - // Left line - renderer.drawLine(x, y + 1, x, y + rectHeight - 2); - // Battery end - renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rectHeight - 2); - renderer.drawPixel(x + battWidth - 1, y + 3); - renderer.drawPixel(x + battWidth - 1, y + rectHeight - 4); - renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rectHeight - 5); + // Draw battery outline (shared code) + BaseTheme::drawBatteryOutline(renderer, x, y, battWidth, rectHeight); const bool charging = gpio.isUsbConnected(); @@ -58,20 +48,37 @@ void drawBatteryIcon(const GfxRenderer& renderer, int x, int y, int battWidth, i // Draw lightning bolt when charging (white/inverted on black fill for visibility) if (charging) { - const int boltX = x + 4; - const int boltY = y + 2; - renderer.drawLine(boltX + 4, boltY + 0, boltX + 5, boltY + 0, false); - renderer.drawLine(boltX + 3, boltY + 1, boltX + 4, boltY + 1, false); - renderer.drawLine(boltX + 2, boltY + 2, boltX + 5, boltY + 2, false); - renderer.drawLine(boltX + 3, boltY + 3, boltX + 4, boltY + 3, false); - renderer.drawLine(boltX + 2, boltY + 4, boltX + 3, boltY + 4, false); - renderer.drawLine(boltX + 1, boltY + 5, boltX + 4, boltY + 5, false); - renderer.drawLine(boltX + 2, boltY + 6, boltX + 3, boltY + 6, false); - renderer.drawLine(boltX + 1, boltY + 7, boltX + 2, boltY + 7, false); + BaseTheme::drawBatteryLightningBolt(renderer, x + 4, y + 2); } } } // namespace +void BaseTheme::drawBatteryOutline(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight) { + // Top line + renderer.drawLine(x + 1, y, x + battWidth - 3, y); + // Bottom line + renderer.drawLine(x + 1, y + rectHeight - 1, x + battWidth - 3, y + rectHeight - 1); + // Left line + renderer.drawLine(x, y + 1, x, y + rectHeight - 2); + // Battery end + renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rectHeight - 2); + renderer.drawPixel(x + battWidth - 1, y + 3); + renderer.drawPixel(x + battWidth - 1, y + rectHeight - 4); + renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rectHeight - 5); +} + +void BaseTheme::drawBatteryLightningBolt(const GfxRenderer& renderer, int boltX, int boltY) { + // Draw lightning bolt (white/inverted on black fill for visibility) + renderer.drawLine(boltX + 4, boltY + 0, boltX + 5, boltY + 0, false); + renderer.drawLine(boltX + 3, boltY + 1, boltX + 4, boltY + 1, false); + renderer.drawLine(boltX + 2, boltY + 2, boltX + 5, boltY + 2, false); + renderer.drawLine(boltX + 3, boltY + 3, boltX + 4, boltY + 3, false); + renderer.drawLine(boltX + 2, boltY + 4, boltX + 3, boltY + 4, false); + renderer.drawLine(boltX + 1, boltY + 5, boltX + 4, boltY + 5, false); + renderer.drawLine(boltX + 2, boltY + 6, boltX + 3, boltY + 6, false); + renderer.drawLine(boltX + 1, boltY + 7, boltX + 2, boltY + 7, false); +} + void BaseTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const { // Left aligned: icon on left, percentage on right (reader mode) const uint16_t percentage = powerManager.getBatteryPercentage(); @@ -79,8 +86,8 @@ void BaseTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bo if (showPercentage) { const auto percentageText = std::to_string(percentage) + "%"; - renderer.drawText(SMALL_FONT_ID, rect.x + batteryPercentSpacing + BaseMetrics::values.batteryWidth, rect.y, - percentageText.c_str()); + renderer.drawText(SMALL_FONT_ID, rect.x + BaseTheme::batteryPercentSpacing + BaseMetrics::values.batteryWidth, + rect.y, percentageText.c_str()); } drawBatteryIcon(renderer, rect.x, y, BaseMetrics::values.batteryWidth, rect.height, percentage); @@ -97,9 +104,10 @@ void BaseTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const b const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()); // Clear the area where we're going to draw the text to prevent ghosting const auto textHeight = renderer.getTextHeight(SMALL_FONT_ID); - renderer.fillRect(rect.x - textWidth - batteryPercentSpacing, rect.y, textWidth, textHeight, false); + renderer.fillRect(rect.x - textWidth - BaseTheme::batteryPercentSpacing, rect.y, textWidth, textHeight, false); // Draw text to the left of the icon - renderer.drawText(SMALL_FONT_ID, rect.x - textWidth - batteryPercentSpacing, rect.y, percentageText.c_str()); + renderer.drawText(SMALL_FONT_ID, rect.x - textWidth - BaseTheme::batteryPercentSpacing, rect.y, + percentageText.c_str()); } // Icon is already at correct position from rect.x diff --git a/src/components/themes/BaseTheme.h b/src/components/themes/BaseTheme.h index 6878f5558..5476c085e 100644 --- a/src/components/themes/BaseTheme.h +++ b/src/components/themes/BaseTheme.h @@ -142,4 +142,9 @@ class BaseTheme { virtual void drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth) const; virtual void drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label, const bool isSelected) const; virtual bool showsFileIcons() const { return false; } + + // Shared constants and helpers for battery drawing (used by all themes) + static constexpr int batteryPercentSpacing = 4; + static void drawBatteryOutline(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight); + static void drawBatteryLightningBolt(const GfxRenderer& renderer, int boltX, int boltY); }; diff --git a/src/components/themes/lyra/LyraTheme.cpp b/src/components/themes/lyra/LyraTheme.cpp index 0eee84a29..e2451f684 100644 --- a/src/components/themes/lyra/LyraTheme.cpp +++ b/src/components/themes/lyra/LyraTheme.cpp @@ -30,7 +30,6 @@ // Internal constants namespace { -constexpr int batteryPercentSpacing = 4; constexpr int hPaddingInSelection = 8; constexpr int cornerRadius = 6; constexpr int topHintButtonY = 345; @@ -45,42 +44,25 @@ int coverWidth = 0; void drawLyraBatteryIcon(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight, uint16_t percentage) { - // Top line - renderer.drawLine(x + 1, y, x + battWidth - 3, y); - // Bottom line - renderer.drawLine(x + 1, y + rectHeight - 1, x + battWidth - 3, y + rectHeight - 1); - // Left line - renderer.drawLine(x, y + 1, x, y + rectHeight - 2); - // Battery end - renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rectHeight - 2); - renderer.drawPixel(x + battWidth - 1, y + 3); - renderer.drawPixel(x + battWidth - 1, y + rectHeight - 4); - renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rectHeight - 5); + BaseTheme::drawBatteryOutline(renderer, x, y, battWidth, rectHeight); const bool charging = gpio.isUsbConnected(); - // Draw bars - if (percentage > 10 || charging) { - renderer.fillRect(x + 2, y + 2, 3, rectHeight - 4); - } - if (percentage > 40 || charging) { - renderer.fillRect(x + 6, y + 2, 3, rectHeight - 4); - } - if (percentage > 70) { - renderer.fillRect(x + 10, y + 2, 3, rectHeight - 4); - } - if (charging) { - const int boltX = x + 4; - const int boltY = y + 2; - renderer.drawLine(boltX + 4, boltY + 0, boltX + 5, boltY + 0, false); - renderer.drawLine(boltX + 3, boltY + 1, boltX + 4, boltY + 1, false); - renderer.drawLine(boltX + 2, boltY + 2, boltX + 5, boltY + 2, false); - renderer.drawLine(boltX + 3, boltY + 3, boltX + 4, boltY + 3, false); - renderer.drawLine(boltX + 2, boltY + 4, boltX + 3, boltY + 4, false); - renderer.drawLine(boltX + 1, boltY + 5, boltX + 4, boltY + 5, false); - renderer.drawLine(boltX + 2, boltY + 6, boltX + 3, boltY + 6, false); - renderer.drawLine(boltX + 1, boltY + 7, boltX + 2, boltY + 7, false); + // Draw solid fill when charging so lightning bolt is visible + renderer.fillRect(x + 2, y + 2, battWidth - 5, rectHeight - 4); + BaseTheme::drawBatteryLightningBolt(renderer, x + 4, y + 2); + } else { + // Draw bars when not charging + if (percentage > 10) { + renderer.fillRect(x + 2, y + 2, 3, rectHeight - 4); + } + if (percentage > 40) { + renderer.fillRect(x + 6, y + 2, 3, rectHeight - 4); + } + if (percentage > 70) { + renderer.fillRect(x + 10, y + 2, 3, rectHeight - 4); + } } } @@ -132,8 +114,8 @@ void LyraTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bo if (showPercentage) { const auto percentageText = std::to_string(percentage) + "%"; - renderer.drawText(SMALL_FONT_ID, rect.x + batteryPercentSpacing + LyraMetrics::values.batteryWidth, rect.y, - percentageText.c_str()); + renderer.drawText(SMALL_FONT_ID, rect.x + BaseTheme::batteryPercentSpacing + LyraMetrics::values.batteryWidth, + rect.y, percentageText.c_str()); } drawLyraBatteryIcon(renderer, rect.x, rect.y + 6, LyraMetrics::values.batteryWidth, rect.height, percentage); @@ -148,9 +130,10 @@ void LyraTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const b const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()); // Clear the area where we're going to draw the text to prevent ghosting const auto textHeight = renderer.getTextHeight(SMALL_FONT_ID); - renderer.fillRect(rect.x - textWidth - batteryPercentSpacing, rect.y, textWidth, textHeight, false); + renderer.fillRect(rect.x - textWidth - BaseTheme::batteryPercentSpacing, rect.y, textWidth, textHeight, false); // Draw text to the left of the icon - renderer.drawText(SMALL_FONT_ID, rect.x - textWidth - batteryPercentSpacing, rect.y, percentageText.c_str()); + renderer.drawText(SMALL_FONT_ID, rect.x - textWidth - BaseTheme::batteryPercentSpacing, rect.y, + percentageText.c_str()); } drawLyraBatteryIcon(renderer, rect.x, rect.y + 6, LyraMetrics::values.batteryWidth, rect.height, percentage); From cced77783fb76ded480771114d42fb6fa9921665 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B3=D0=BE=D1=80=20=D0=9C=D0=B0=D1=80=D1=82=D1=8B?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2?= Date: Wed, 15 Apr 2026 05:21:55 +0700 Subject: [PATCH 32/37] feat: add orientation-aware popups for reader activities (#1428) ## Summary Make popups (like "Going to sleep") respect the current screen orientation when shown from reader activities. **What changes are included?** - Apply reader orientation in SleepActivity before showing popup when `lastSleepFromReader` is true - Make popup Y-position proportional to screen height (7.5% for BaseTheme, 16.5% for LyraTheme) instead of hardcoded pixel values, ensuring correct positioning in both portrait and landscape modes. - Add `isReaderActivity()` override to all reader sub-screens (menu, chapter selection, percent selection, footnotes, QR display, KOReader sync), so sleep popups rotate correctly when entering sleep from any reader context. ## Additional Context --- ### 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**_ (Claude Opus 4.5) --- src/activities/boot_sleep/SleepActivity.cpp | 11 ++++++++++- .../reader/EpubReaderChapterSelectionActivity.h | 1 + src/activities/reader/EpubReaderFootnotesActivity.h | 1 + src/activities/reader/EpubReaderMenuActivity.h | 1 + .../reader/EpubReaderPercentSelectionActivity.h | 1 + src/activities/reader/KOReaderSyncActivity.h | 1 + src/activities/reader/QrDisplayActivity.h | 1 + src/components/themes/BaseTheme.cpp | 3 ++- src/components/themes/lyra/LyraTheme.cpp | 3 ++- 9 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 64fa11d91..88a15aa13 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -10,13 +10,22 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" +#include "activities/reader/ReaderUtils.h" #include "components/UITheme.h" #include "fontIds.h" #include "images/Logo120.h" void SleepActivity::onEnter() { Activity::onEnter(); - GUI.drawPopup(renderer, tr(STR_ENTERING_SLEEP)); + + // Show popup with reader orientation only when going to sleep from reader + if (APP_STATE.lastSleepFromReader) { + ReaderUtils::applyOrientation(renderer, SETTINGS.orientation); + GUI.drawPopup(renderer, tr(STR_ENTERING_SLEEP)); + renderer.setOrientation(GfxRenderer::Orientation::Portrait); + } else { + GUI.drawPopup(renderer, tr(STR_ENTERING_SLEEP)); + } switch (SETTINGS.sleepScreen) { case (CrossPointSettings::SLEEP_SCREEN_MODE::BLANK): diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.h b/src/activities/reader/EpubReaderChapterSelectionActivity.h index 20b53aa43..216eadf46 100644 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.h +++ b/src/activities/reader/EpubReaderChapterSelectionActivity.h @@ -32,4 +32,5 @@ class EpubReaderChapterSelectionActivity final : public Activity { void onExit() override; void loop() override; void render(RenderLock&&) override; + bool isReaderActivity() const override { return true; } }; diff --git a/src/activities/reader/EpubReaderFootnotesActivity.h b/src/activities/reader/EpubReaderFootnotesActivity.h index 7336d038c..85fe692d1 100644 --- a/src/activities/reader/EpubReaderFootnotesActivity.h +++ b/src/activities/reader/EpubReaderFootnotesActivity.h @@ -19,6 +19,7 @@ class EpubReaderFootnotesActivity final : public Activity { void onExit() override; void loop() override; void render(RenderLock&&) override; + bool isReaderActivity() const override { return true; } private: const std::vector& footnotes; diff --git a/src/activities/reader/EpubReaderMenuActivity.h b/src/activities/reader/EpubReaderMenuActivity.h index 9ddba93db..3937d62c7 100644 --- a/src/activities/reader/EpubReaderMenuActivity.h +++ b/src/activities/reader/EpubReaderMenuActivity.h @@ -32,6 +32,7 @@ class EpubReaderMenuActivity final : public Activity { void onExit() override; void loop() override; void render(RenderLock&&) override; + bool isReaderActivity() const override { return true; } private: struct MenuItem { diff --git a/src/activities/reader/EpubReaderPercentSelectionActivity.h b/src/activities/reader/EpubReaderPercentSelectionActivity.h index 8cba8664f..ad68fc32c 100644 --- a/src/activities/reader/EpubReaderPercentSelectionActivity.h +++ b/src/activities/reader/EpubReaderPercentSelectionActivity.h @@ -15,6 +15,7 @@ class EpubReaderPercentSelectionActivity final : public Activity { void onExit() override; void loop() override; void render(RenderLock&&) override; + bool isReaderActivity() const override { return true; } private: // Current percent value (0-100) shown on the slider. diff --git a/src/activities/reader/KOReaderSyncActivity.h b/src/activities/reader/KOReaderSyncActivity.h index 71bdbf2f4..afd331ece 100644 --- a/src/activities/reader/KOReaderSyncActivity.h +++ b/src/activities/reader/KOReaderSyncActivity.h @@ -38,6 +38,7 @@ class KOReaderSyncActivity final : public Activity { void loop() override; void render(RenderLock&&) override; bool preventAutoSleep() override { return state == CONNECTING || state == SYNCING; } + bool isReaderActivity() const override { return true; } private: enum State { diff --git a/src/activities/reader/QrDisplayActivity.h b/src/activities/reader/QrDisplayActivity.h index 3cfdb6b37..d6ff236a4 100644 --- a/src/activities/reader/QrDisplayActivity.h +++ b/src/activities/reader/QrDisplayActivity.h @@ -14,6 +14,7 @@ class QrDisplayActivity final : public Activity { void onExit() override; void loop() override; void render(RenderLock&&) override; + bool isReaderActivity() const override { return true; } private: std::string textPayload; diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index 0d6dcd336..dff5ec065 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -648,7 +648,8 @@ void BaseTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount Rect BaseTheme::drawPopup(const GfxRenderer& renderer, const char* message) const { constexpr int margin = 15; - constexpr int y = 60; + // Scale y position proportionally to screen height (7.5% from top) + const int y = static_cast(renderer.getScreenHeight() * 0.075f); const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::BOLD); const int textHeight = renderer.getLineHeight(UI_12_FONT_ID); const int w = textWidth + margin * 2; diff --git a/src/components/themes/lyra/LyraTheme.cpp b/src/components/themes/lyra/LyraTheme.cpp index e2451f684..56903f892 100644 --- a/src/components/themes/lyra/LyraTheme.cpp +++ b/src/components/themes/lyra/LyraTheme.cpp @@ -541,7 +541,8 @@ void LyraTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount } Rect LyraTheme::drawPopup(const GfxRenderer& renderer, const char* message) const { - constexpr int y = 132; + // Scale y position proportionally to screen height (16.5% from top) + const int y = static_cast(renderer.getScreenHeight() * 0.165f); constexpr int outline = 2; const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::REGULAR); const int textHeight = renderer.getLineHeight(UI_12_FONT_ID); From 45cd00889e5a8658e0f52b21f765a815b13ce03e Mon Sep 17 00:00:00 2001 From: jpirnay Date: Wed, 15 Apr 2026 01:50:37 +0200 Subject: [PATCH 33/37] chore: clang-format.fix.ps1 script: Add .venv to list of path exclusions (#1515) ## Summary * **What is the goal of this PR?** Dont format files in .venv directory * **What changes are included?** ## Additional Context * Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on). --- ### 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 | PARTIALLY | NO >**_ --- bin/clang-format-fix.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/bin/clang-format-fix.ps1 b/bin/clang-format-fix.ps1 index 9eae1715f..f90a81b73 100644 --- a/bin/clang-format-fix.ps1 +++ b/bin/clang-format-fix.ps1 @@ -97,6 +97,7 @@ $exclude = @( 'lib\Epub\Epub\hyphenation\generated' 'lib\uzlib' '.pio' + '.venv' ) function Test-Excluded($fullPath) { From ed54f97909f25d09a53bae05834b5d73af9a8057 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Thu, 16 Apr 2026 00:52:29 +0200 Subject: [PATCH 34/37] fix: Fix ghosting on exit of BMPViewer (#1432) ## Summary * **What is the goal of this PR?** After displaying an image via the filebrowser ghosting artifacts remained on the screen * **What changes are included?** ## Additional Context * Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on). --- ### 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? _**< NO >**_ --- src/activities/util/BmpViewerActivity.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/activities/util/BmpViewerActivity.cpp b/src/activities/util/BmpViewerActivity.cpp index 62a02ce67..37fa8fe19 100644 --- a/src/activities/util/BmpViewerActivity.cpp +++ b/src/activities/util/BmpViewerActivity.cpp @@ -61,7 +61,7 @@ void BmpViewerActivity::onEnter() { GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); // Single pass for non-grayscale images - renderer.displayBuffer(HalDisplay::FULL_REFRESH); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); } else { // Handle file parsing error @@ -69,7 +69,7 @@ void BmpViewerActivity::onEnter() { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "Invalid BMP File"); const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - renderer.displayBuffer(HalDisplay::FAST_REFRESH); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); } file.close(); @@ -79,14 +79,14 @@ void BmpViewerActivity::onEnter() { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "Could not open file"); const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - renderer.displayBuffer(HalDisplay::FULL_REFRESH); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); } } void BmpViewerActivity::onExit() { Activity::onExit(); renderer.clearScreen(); - renderer.displayBuffer(HalDisplay::FAST_REFRESH); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); } void BmpViewerActivity::loop() { From 57fc6555f2b2045f6fd5da8b2c60e03c7d9e6646 Mon Sep 17 00:00:00 2001 From: Stefan Blixten Karlsson Date: Thu, 16 Apr 2026 02:05:09 +0200 Subject: [PATCH 35/37] fix: missing swedish translations (#1667) ## Summary * Add missing swedish translations --- ### 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? _**NO**_ --- lib/I18n/translations/swedish.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index 34c013249..456d2afda 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -127,6 +127,7 @@ STR_ALWAYS: "Alltid" STR_IGNORE: "Ignorera" STR_SLEEP: "Vila" STR_PAGE_TURN: "Sidvändning" +STR_FORCE_REFRESH: "Uppdatera skärmen" STR_PORTRAIT: "Porträtt" STR_LANDSCAPE_CW: "Landskap medurs" STR_INVERTED: "Inverterad" @@ -175,6 +176,8 @@ STR_UNNAMED: "Ej namngiven" STR_NO_SERVER_URL: "Ingen serveradress konfigurerad" STR_FETCH_FEED_FAILED: "Misslyckades att hämta flöde" STR_PARSE_FEED_FAILED: "Misslyckades att analysera flöde" +STR_NEXT_PAGE: "Nästa sida »" +STR_PREV_PAGE: "« Föregående sida" STR_NETWORK_PREFIX: "Nätverk:" STR_IP_ADDRESS_PREFIX: "IP-adress;" STR_ERROR_GENERAL_FAILURE: "Fel: Generellt fel" @@ -229,6 +232,7 @@ STR_THEME_LYRA_EXTENDED: "Lyra utökad" STR_SUNLIGHT_FADING_FIX: "Fix för solskensmattning" STR_REMAP_FRONT_BUTTONS: "Ändra frontknappar" STR_OPDS_BROWSER: "OPDS-webbläsare" +STR_SEARCH: "Sök" STR_COVER_CUSTOM: "Omslag + Valfri" STR_MENU_RECENT_BOOKS: "Senaste böckerna" STR_NO_RECENT_BOOKS: "Inga senaste böcker" @@ -290,3 +294,7 @@ STR_LINK: "[länk]" STR_SCREENSHOT_BUTTON: "Ta en skärmdump" STR_AUTO_TURN_ENABLED: "Automatisk vändning aktiverad: " STR_AUTO_TURN_PAGES_PER_MIN: "Automatisk vändning (sidor per minut)" +STR_CRASH_TITLE: "Systemkrasch" +STR_CRASH_DESCRIPTION: "En detaljerad rapport sparades till crash_report.txt. Vänligen inkludera den här filen i din felrapport." +STR_CRASH_REASON: "Orsak till kraschen:" +STR_CRASH_NO_REASON: "(Ingen orsak registrerades)" From 80772ff6b808b9d65ebf031ac132009c1d10bd22 Mon Sep 17 00:00:00 2001 From: Stefan Blixten Karlsson Date: Thu, 16 Apr 2026 04:47:10 +0200 Subject: [PATCH 36/37] fix: footnote link text (#1666) ## Summary Previouls where ALL whitespace (and square brackes) removed from the footnote link text, however some link texts are multiworded, like "`turn to 252`" which were truncated into "`turnto252`", (an example from the first book "Flight from the Dark" of the Lone Wolf book series by Joe Dever, see [link](https://www.projectaon.org/en/Main/FlightFromTheDark)) * This change will only remove whitespaces from the beginning and end of the string so "` [ 12 ] `" will become "`12`" just like before, and "` turn to 252 `" will become "`turn to 252`". --- ### 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? _**NO**_ --- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 957a6b0a3..6245d2ae4 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -697,14 +697,29 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char // Collect footnote link display text (for the number label) // Skip whitespace and brackets to normalize noterefs like "[1]" → "1" if (self->insideFootnoteLink) { - for (int i = 0; i < len; i++) { - unsigned char c = static_cast(s[i]); - if (isWhitespace(c) || c == '[' || c == ']') continue; - if (self->currentFootnoteLinkTextLen < static_cast(sizeof(self->currentFootnoteLinkText)) - 1) { - self->currentFootnoteLinkText[self->currentFootnoteLinkTextLen++] = c; - self->currentFootnoteLinkText[self->currentFootnoteLinkTextLen] = '\0'; - } + int start = 0; + int end = len - 1; + + // Example input and output texts: + // " [ 12 ] " => "12" + // " turn to 256 " => "turn to 256" + + // Ignore leading whitespaces and left square brackets + while (start < len && (isWhitespace(s[start]) || (s[start] == '['))) { + ++start; } + + // Ignore trailing whitespaces and right square brackets + while (end >= start && (isWhitespace(s[end]) || (s[end] == ']'))) { + --end; + } + + // Extract footnote link text + for (int i = start; (self->currentFootnoteLinkTextLen < sizeof(self->currentFootnoteLinkText) - 1) && (i <= end); + ++i) { + self->currentFootnoteLinkText[self->currentFootnoteLinkTextLen++] = s[i]; + } + self->currentFootnoteLinkText[self->currentFootnoteLinkTextLen] = '\0'; } for (int i = 0; i < len; i++) { From 40e4c9690608b5e4b8d0fd27beb6db09112a1559 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Thu, 16 Apr 2026 06:20:24 +0200 Subject: [PATCH 37/37] refactor: replace picojpeg with JPEGDEC for cover art conversion (#1517) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Removes the vendored `picojpeg` library and rewrites `JpegToBmpConverter` to use the already-present `JPEGDEC` (bitbank2) dependency - Eliminates the redundancy of having two JPEG decoders in the firmware - All BMP output (headers, fixed-point scaling, Atkinson/Floyd-Steinberg dithering) is identical to before — cached cover BMPs are unaffected ## Size impact | | Before | After | Delta | |---|---|---|---| | Flash | 5,754,089 bytes (87.8%) | 5,744,777 bytes (87.7%) | **−9,312 bytes** | | RAM | 95,212 bytes (29.1%) | 92,852 bytes (28.3%) | **−2,360 bytes** | ## Implementation notes - `bmpDrawCallback` receives MCU-sized blocks from JPEGDEC (up to 16 rows × MCU-width), accumulates them into a pre-allocated `mcuBuf`, and applies the same scaling + dithering logic once each MCU row is complete - File I/O uses a file-scope static `FsFile*` (safe in single-threaded embedded context) via JPEGDEC's open/read/seek callbacks — same pattern as `JpegToFramebufferConverter` - Added a 52 KB free-heap guard before allocating the JPEGDEC object (~17 KB) - `lib/picojpeg/` deleted (2,087 lines of C removed) ## Test plan - [ ] Build compiles without warnings - [ ] Cover art BMP cache regenerates correctly for EPUB books - [ ] Home screen thumbnails (1-bit BMP path) render correctly - [ ] Custom-size thumbnails (`jpegFileToBmpStreamWithSize`) render correctly 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- lib/JpegToBmpConverter/JpegToBmpConverter.cpp | 629 +++-- lib/JpegToBmpConverter/JpegToBmpConverter.h | 2 - lib/picojpeg/picojpeg.c | 2087 ----------------- lib/picojpeg/picojpeg.h | 124 - 4 files changed, 305 insertions(+), 2537 deletions(-) delete mode 100644 lib/picojpeg/picojpeg.c delete mode 100644 lib/picojpeg/picojpeg.h diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp index 4b87d632c..0dd787275 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp @@ -2,22 +2,15 @@ #include #include +#include #include -#include #include #include +#include #include "BitmapHelpers.h" -// Context structure for picojpeg callback -struct JpegReadContext { - FsFile& file; - uint8_t buffer[512]; - size_t bufferPos; - size_t bufferFilled; -}; - // ============================================================================ // IMAGE PROCESSING OPTIONS - Toggle these to test different configurations // ============================================================================ @@ -165,103 +158,292 @@ static void writeBmpHeader2bit(Print& bmpOut, const int width, const int height) } } -// Callback function for picojpeg to read JPEG data -unsigned char JpegToBmpConverter::jpegReadCallback(unsigned char* pBuf, const unsigned char buf_size, - unsigned char* pBytes_actually_read, void* pCallback_data) { - auto* context = static_cast(pCallback_data); +namespace { - if (!context || !context->file) { - return PJPG_STREAM_READ_ERROR; +// Max MCU height supported by any JPEG (4:2:0 chroma = 16 rows, 4:4:4 = 8 rows) +constexpr int MAX_MCU_HEIGHT = 16; +constexpr size_t JPEG_DECODER_SIZE = 20 * 1024; +constexpr size_t MIN_FREE_HEAP = JPEG_DECODER_SIZE + 32 * 1024; + +// Static file pointer for JPEGDEC open callback. +// Safe in single-threaded embedded context; never accessed concurrently. +static FsFile* s_jpegFile = nullptr; + +void* bmpJpegOpen(const char* /*filename*/, int32_t* size) { + if (!s_jpegFile || !*s_jpegFile) return nullptr; + s_jpegFile->seek(0); + *size = static_cast(s_jpegFile->size()); + return s_jpegFile; +} + +void bmpJpegClose(void* /*handle*/) { + // Caller owns the file — do not close it here +} + +int32_t bmpJpegRead(JPEGFILE* pFile, uint8_t* pBuf, int32_t len) { + auto* f = reinterpret_cast(pFile->fHandle); + if (!f) return 0; + int32_t n = f->read(pBuf, len); + if (n < 0) n = 0; + pFile->iPos += n; + return n; +} + +int32_t bmpJpegSeek(JPEGFILE* pFile, int32_t pos) { + auto* f = reinterpret_cast(pFile->fHandle); + if (!f || !f->seek(pos)) return -1; + pFile->iPos = pos; + return pos; +} + +// Context passed to the JPEGDEC draw callback via setUserPointer() +struct BmpConvertCtx { + Print* bmpOut; + int srcWidth; + int srcHeight; + int outWidth; + int outHeight; + bool oneBit; + int bytesPerRow; + bool needsScaling; + uint32_t scaleX_fp; // source pixels per output pixel, 16.16 fixed-point + uint32_t scaleY_fp; + + // Accumulates one MCU row (up to MAX_MCU_HEIGHT source rows × srcWidth pixels) + // Filled column-by-column as JPEGDEC callbacks arrive for the same MCU row + uint8_t* mcuBuf; + + // Y-axis area averaging accumulators (needsScaling only) + int currentOutY; + uint32_t nextOutY_srcStart; // 16.16 fixed-point boundary for the next output row + uint32_t* rowAccum; + uint32_t* rowCount; + + uint8_t* bmpRow; + + AtkinsonDitherer* atkinsonDitherer; + FloydSteinbergDitherer* fsDitherer; + Atkinson1BitDitherer* atkinson1BitDitherer; + + bool error; +}; + +// Write a fully-assembled output row (grayscale bytes, length outWidth) to BMP +static void writeOutputRow(BmpConvertCtx* ctx, const uint8_t* srcRow, int outY) { + memset(ctx->bmpRow, 0, ctx->bytesPerRow); + + if (USE_8BIT_OUTPUT && !ctx->oneBit) { + for (int x = 0; x < ctx->outWidth; x++) { + ctx->bmpRow[x] = adjustPixel(srcRow[x]); + } + } else if (ctx->oneBit) { + for (int x = 0; x < ctx->outWidth; x++) { + const uint8_t bit = ctx->atkinson1BitDitherer ? ctx->atkinson1BitDitherer->processPixel(srcRow[x], x) + : quantize1bit(srcRow[x], x, outY); + ctx->bmpRow[x / 8] |= (bit << (7 - (x % 8))); + } + if (ctx->atkinson1BitDitherer) ctx->atkinson1BitDitherer->nextRow(); + } else { + for (int x = 0; x < ctx->outWidth; x++) { + const uint8_t gray = adjustPixel(srcRow[x]); + uint8_t twoBit; + if (ctx->atkinsonDitherer) { + twoBit = ctx->atkinsonDitherer->processPixel(gray, x); + } else if (ctx->fsDitherer) { + twoBit = ctx->fsDitherer->processPixel(gray, x); + } else { + twoBit = quantize(gray, x, outY); + } + ctx->bmpRow[(x * 2) / 8] |= (twoBit << (6 - ((x * 2) % 8))); + } + if (ctx->atkinsonDitherer) + ctx->atkinsonDitherer->nextRow(); + else if (ctx->fsDitherer) + ctx->fsDitherer->nextRow(); } - // Check if we need to refill our context buffer - if (context->bufferPos >= context->bufferFilled) { - context->bufferFilled = context->file.read(context->buffer, sizeof(context->buffer)); - context->bufferPos = 0; + ctx->bmpOut->write(ctx->bmpRow, ctx->bytesPerRow); +} - if (context->bufferFilled == 0) { - // EOF or error - *pBytes_actually_read = 0; - return 0; // Success (EOF is normal) +// Flush one scaled output row from Y-axis accumulators and advance currentOutY +static void flushScaledRow(BmpConvertCtx* ctx) { + memset(ctx->bmpRow, 0, ctx->bytesPerRow); + + if (USE_8BIT_OUTPUT && !ctx->oneBit) { + for (int x = 0; x < ctx->outWidth; x++) { + const uint8_t gray = (ctx->rowCount[x] > 0) ? (ctx->rowAccum[x] / ctx->rowCount[x]) : 0; + ctx->bmpRow[x] = adjustPixel(gray); + } + } else if (ctx->oneBit) { + for (int x = 0; x < ctx->outWidth; x++) { + const uint8_t gray = (ctx->rowCount[x] > 0) ? (ctx->rowAccum[x] / ctx->rowCount[x]) : 0; + const uint8_t bit = ctx->atkinson1BitDitherer ? ctx->atkinson1BitDitherer->processPixel(gray, x) + : quantize1bit(gray, x, ctx->currentOutY); + ctx->bmpRow[x / 8] |= (bit << (7 - (x % 8))); + } + if (ctx->atkinson1BitDitherer) ctx->atkinson1BitDitherer->nextRow(); + } else { + for (int x = 0; x < ctx->outWidth; x++) { + const uint8_t gray = adjustPixel((ctx->rowCount[x] > 0) ? (ctx->rowAccum[x] / ctx->rowCount[x]) : 0); + uint8_t twoBit; + if (ctx->atkinsonDitherer) { + twoBit = ctx->atkinsonDitherer->processPixel(gray, x); + } else if (ctx->fsDitherer) { + twoBit = ctx->fsDitherer->processPixel(gray, x); + } else { + twoBit = quantize(gray, x, ctx->currentOutY); + } + ctx->bmpRow[(x * 2) / 8] |= (twoBit << (6 - ((x * 2) % 8))); + } + if (ctx->atkinsonDitherer) + ctx->atkinsonDitherer->nextRow(); + else if (ctx->fsDitherer) + ctx->fsDitherer->nextRow(); + } + + ctx->bmpOut->write(ctx->bmpRow, ctx->bytesPerRow); + ctx->currentOutY++; +} + +// JPEGDEC draw callback — receives one MCU-width × MCU-height block at a time, +// in left-to-right, top-to-bottom order (baseline JPEG). +// Accumulates columns into mcuBuf; once the last column arrives (completing the MCU +// row), applies scaling + dithering and writes packed BMP rows to bmpOut. +int bmpDrawCallback(JPEGDRAW* pDraw) { + auto* ctx = reinterpret_cast(pDraw->pUser); + if (!ctx || ctx->error) return 0; + + const uint8_t* pixels = reinterpret_cast(pDraw->pPixels); + const int stride = pDraw->iWidth; + const int validW = pDraw->iWidthUsed; + const int blockH = pDraw->iHeight; + const int blockX = pDraw->x; + const int blockY = pDraw->y; + + // Copy block pixels into MCU row buffer + for (int r = 0; r < blockH && r < MAX_MCU_HEIGHT; r++) { + const int copyW = (blockX + validW <= ctx->srcWidth) ? validW : (ctx->srcWidth - blockX); + if (copyW <= 0) continue; + memcpy(ctx->mcuBuf + r * ctx->srcWidth + blockX, pixels + r * stride, copyW); + } + + // Wait for the last MCU column before processing any rows + if (blockX + validW < ctx->srcWidth) return 1; + + // Process each complete source row in this MCU row + const int endRow = blockY + blockH; + + for (int y = blockY; y < endRow && y < ctx->srcHeight; y++) { + const uint8_t* srcRow = ctx->mcuBuf + (y - blockY) * ctx->srcWidth; + + if (!ctx->needsScaling) { + // 1:1 — outWidth == srcWidth, write directly + writeOutputRow(ctx, srcRow, y); + } else { + // Fixed-point area averaging on X axis + for (int outX = 0; outX < ctx->outWidth; outX++) { + const int srcXStart = (static_cast(outX) * ctx->scaleX_fp) >> 16; + const int srcXEnd = (static_cast(outX + 1) * ctx->scaleX_fp) >> 16; + int sum = 0; + int count = 0; + for (int srcX = srcXStart; srcX < srcXEnd && srcX < ctx->srcWidth; srcX++) { + sum += srcRow[srcX]; + count++; + } + if (count == 0 && srcXStart < ctx->srcWidth) { + sum = srcRow[srcXStart]; + count = 1; + } + ctx->rowAccum[outX] += sum; + ctx->rowCount[outX] += count; + } + + // Flush output row(s) whose Y boundary we've crossed + const uint32_t srcY_fp = static_cast(y + 1) << 16; + while (srcY_fp >= ctx->nextOutY_srcStart && ctx->currentOutY < ctx->outHeight) { + flushScaledRow(ctx); + ctx->nextOutY_srcStart = static_cast(ctx->currentOutY + 1) * ctx->scaleY_fp; + if (srcY_fp >= ctx->nextOutY_srcStart) continue; + memset(ctx->rowAccum, 0, ctx->outWidth * sizeof(uint32_t)); + memset(ctx->rowCount, 0, ctx->outWidth * sizeof(uint32_t)); + } } } - // Copy available bytes to picojpeg's buffer - const size_t available = context->bufferFilled - context->bufferPos; - const size_t toRead = available < buf_size ? available : buf_size; - - memcpy(pBuf, context->buffer + context->bufferPos, toRead); - context->bufferPos += toRead; - *pBytes_actually_read = static_cast(toRead); - - return 0; // Success + return ctx->error ? 0 : 1; } +} // namespace + // Internal implementation with configurable target size and bit depth bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight, bool oneBit, bool crop) { LOG_DBG("JPG", "Converting JPEG to %s BMP (target: %dx%d)", oneBit ? "1-bit" : "2-bit", targetWidth, targetHeight); - // Setup context for picojpeg callback - JpegReadContext context = {.file = jpegFile, .bufferPos = 0, .bufferFilled = 0}; - - // Initialize picojpeg decoder - pjpeg_image_info_t imageInfo; - const unsigned char status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0); - if (status != 0) { - LOG_ERR("JPG", "JPEG decode init failed with error code: %d", status); + if (ESP.getFreeHeap() < MIN_FREE_HEAP) { + LOG_ERR("JPG", "Not enough heap for JPEG decoder (%u free, need %u)", ESP.getFreeHeap(), MIN_FREE_HEAP); return false; } - LOG_DBG("JPG", "JPEG dimensions: %dx%d, components: %d, MCUs: %dx%d", imageInfo.m_width, imageInfo.m_height, - imageInfo.m_comps, imageInfo.m_MCUSPerRow, imageInfo.m_MCUSPerCol); + s_jpegFile = &jpegFile; + + JPEGDEC* jpeg = new (std::nothrow) JPEGDEC(); + if (!jpeg) { + LOG_ERR("JPG", "Failed to allocate JPEG decoder"); + return false; + } + + int rc = jpeg->open("", bmpJpegOpen, bmpJpegClose, bmpJpegRead, bmpJpegSeek, bmpDrawCallback); + if (rc != 1) { + LOG_ERR("JPG", "JPEG open failed (err=%d)", jpeg->getLastError()); + delete jpeg; + return false; + } + + const int srcWidth = jpeg->getWidth(); + const int srcHeight = jpeg->getHeight(); + + LOG_DBG("JPG", "JPEG dimensions: %dx%d", srcWidth, srcHeight); - // Safety limits to prevent memory issues on ESP32 constexpr int MAX_IMAGE_WIDTH = 2048; constexpr int MAX_IMAGE_HEIGHT = 3072; - constexpr int MAX_MCU_ROW_BYTES = 65536; - if (imageInfo.m_width > MAX_IMAGE_WIDTH || imageInfo.m_height > MAX_IMAGE_HEIGHT) { - LOG_DBG("JPG", "Image too large (%dx%d), max supported: %dx%d", imageInfo.m_width, imageInfo.m_height, - MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT); + if (srcWidth <= 0 || srcHeight <= 0 || srcWidth > MAX_IMAGE_WIDTH || srcHeight > MAX_IMAGE_HEIGHT) { + LOG_DBG("JPG", "Image too large or invalid (%dx%d), max supported: %dx%d", srcWidth, srcHeight, MAX_IMAGE_WIDTH, + MAX_IMAGE_HEIGHT); + jpeg->close(); + delete jpeg; return false; } // Calculate output dimensions (pre-scale to fit display exactly) - int outWidth = imageInfo.m_width; - int outHeight = imageInfo.m_height; - // Use fixed-point scaling (16.16) for sub-pixel accuracy + int outWidth = srcWidth; + int outHeight = srcHeight; uint32_t scaleX_fp = 65536; // 1.0 in 16.16 fixed point uint32_t scaleY_fp = 65536; bool needsScaling = false; - if (targetWidth > 0 && targetHeight > 0 && (imageInfo.m_width != targetWidth || imageInfo.m_height != targetHeight)) { - // Calculate scale to fit/fill target dimensions while maintaining aspect ratio - const float scaleToFitWidth = static_cast(targetWidth) / imageInfo.m_width; - const float scaleToFitHeight = static_cast(targetHeight) / imageInfo.m_height; - // We scale to the smaller dimension, so we can potentially crop later. - float scale = 1.0; - if (crop) { // if we will crop, scale to the smaller dimension + if (targetWidth > 0 && targetHeight > 0 && (srcWidth != targetWidth || srcHeight != targetHeight)) { + const float scaleToFitWidth = static_cast(targetWidth) / srcWidth; + const float scaleToFitHeight = static_cast(targetHeight) / srcHeight; + float scale = 1.0f; + if (crop) { scale = (scaleToFitWidth > scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight; - } else { // else, scale to the larger dimension to fit + } else { scale = (scaleToFitWidth < scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight; } - outWidth = static_cast(imageInfo.m_width * scale); - outHeight = static_cast(imageInfo.m_height * scale); - - // Ensure at least 1 pixel + outWidth = static_cast(srcWidth * scale); + outHeight = static_cast(srcHeight * scale); if (outWidth < 1) outWidth = 1; if (outHeight < 1) outHeight = 1; - // Calculate fixed-point scale factors (source pixels per output pixel) - // scaleX_fp = (srcWidth << 16) / outWidth - scaleX_fp = (static_cast(imageInfo.m_width) << 16) / outWidth; - scaleY_fp = (static_cast(imageInfo.m_height) << 16) / outHeight; + scaleX_fp = (static_cast(srcWidth) << 16) / outWidth; + scaleY_fp = (static_cast(srcHeight) << 16) / outHeight; needsScaling = true; - LOG_DBG("JPG", "Scaling %dx%d -> %dx%d (target %dx%d)", imageInfo.m_width, imageInfo.m_height, outWidth, outHeight, - targetWidth, targetHeight); + LOG_DBG("JPG", "Scaling %dx%d -> %dx%d (target %dx%d)", srcWidth, srcHeight, outWidth, outHeight, targetWidth, + targetHeight); } // Write BMP header with output dimensions @@ -271,285 +453,84 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm bytesPerRow = (outWidth + 3) / 4 * 4; } else if (oneBit) { writeBmpHeader1bit(bmpOut, outWidth, outHeight); - bytesPerRow = (outWidth + 31) / 32 * 4; // 1 bit per pixel + bytesPerRow = (outWidth + 31) / 32 * 4; } else { writeBmpHeader2bit(bmpOut, outWidth, outHeight); bytesPerRow = (outWidth * 2 + 31) / 32 * 4; } - uint8_t* rowBuffer = nullptr; - uint8_t* mcuRowBuffer = nullptr; - AtkinsonDitherer* atkinsonDitherer = nullptr; - FloydSteinbergDitherer* fsDitherer = nullptr; - Atkinson1BitDitherer* atkinson1BitDitherer = nullptr; - uint32_t* rowAccum = nullptr; // Accumulator for each output X (32-bit for larger sums) - uint32_t* rowCount = nullptr; // Count of source pixels accumulated per output X + BmpConvertCtx ctx = {}; + ctx.bmpOut = &bmpOut; + ctx.srcWidth = srcWidth; + ctx.srcHeight = srcHeight; + ctx.outWidth = outWidth; + ctx.outHeight = outHeight; + ctx.oneBit = oneBit; + ctx.bytesPerRow = bytesPerRow; + ctx.needsScaling = needsScaling; + ctx.scaleX_fp = scaleX_fp; + ctx.scaleY_fp = scaleY_fp; + ctx.error = false; - // RAII guard: frees all heap resources on any return path, including early exits. - // Holds references so it always sees the latest pointer values assigned below. + // RAII guard: frees all heap resources on any return path struct Cleanup { - uint8_t*& rowBuffer; - uint8_t*& mcuRowBuffer; - AtkinsonDitherer*& atkinsonDitherer; - FloydSteinbergDitherer*& fsDitherer; - Atkinson1BitDitherer*& atkinson1BitDitherer; - uint32_t*& rowAccum; - uint32_t*& rowCount; + BmpConvertCtx& ctx; + JPEGDEC* jpeg; ~Cleanup() { - delete[] rowAccum; - delete[] rowCount; - delete atkinsonDitherer; - delete fsDitherer; - delete atkinson1BitDitherer; - free(mcuRowBuffer); - free(rowBuffer); + delete[] ctx.rowAccum; + delete[] ctx.rowCount; + delete ctx.atkinsonDitherer; + delete ctx.fsDitherer; + delete ctx.atkinson1BitDitherer; + free(ctx.mcuBuf); + free(ctx.bmpRow); + jpeg->close(); + delete jpeg; } - } cleanup{rowBuffer, mcuRowBuffer, atkinsonDitherer, fsDitherer, atkinson1BitDitherer, rowAccum, rowCount}; + } cleanup{ctx, jpeg}; - // Allocate row buffer - rowBuffer = static_cast(malloc(bytesPerRow)); - if (!rowBuffer) { - LOG_ERR("JPG", "Failed to allocate row buffer"); + // MCU row buffer: MAX_MCU_HEIGHT rows × srcWidth columns of grayscale + ctx.mcuBuf = static_cast(malloc(MAX_MCU_HEIGHT * srcWidth)); + if (!ctx.mcuBuf) { + LOG_ERR("JPG", "Failed to allocate MCU buffer (%d bytes)", MAX_MCU_HEIGHT * srcWidth); return false; } + memset(ctx.mcuBuf, 0, MAX_MCU_HEIGHT * srcWidth); - // Allocate a buffer for one MCU row worth of grayscale pixels - // This is the minimal memory needed for streaming conversion - const int mcuPixelHeight = imageInfo.m_MCUHeight; - const int mcuRowPixels = imageInfo.m_width * mcuPixelHeight; - - // Validate MCU row buffer size before allocation - if (mcuRowPixels > MAX_MCU_ROW_BYTES) { - LOG_DBG("JPG", "MCU row buffer too large (%d bytes), max: %d", mcuRowPixels, MAX_MCU_ROW_BYTES); + ctx.bmpRow = static_cast(malloc(bytesPerRow)); + if (!ctx.bmpRow) { + LOG_ERR("JPG", "Failed to allocate BMP row buffer"); return false; } - mcuRowBuffer = static_cast(malloc(mcuRowPixels)); - if (!mcuRowBuffer) { - LOG_ERR("JPG", "Failed to allocate MCU row buffer (%d bytes)", mcuRowPixels); - return false; - } - - // Create ditherer if enabled - // Use OUTPUT dimensions for dithering (after prescaling) - if (oneBit) { - // For 1-bit output, use Atkinson dithering for better quality - atkinson1BitDitherer = new Atkinson1BitDitherer(outWidth); - } else if (!USE_8BIT_OUTPUT) { - if (USE_ATKINSON) { - atkinsonDitherer = new AtkinsonDitherer(outWidth); - } else if (USE_FLOYD_STEINBERG) { - fsDitherer = new FloydSteinbergDitherer(outWidth); - } - } - - // For scaling: accumulate source rows into scaled output rows - // We need to track which source Y maps to which output Y - // Using fixed-point: srcY_fp = outY * scaleY_fp (gives source Y in 16.16 format) - int currentOutY = 0; // Current output row being accumulated - uint32_t nextOutY_srcStart = 0; // Source Y where next output row starts (16.16 fixed point) - if (needsScaling) { - rowAccum = new uint32_t[outWidth](); - rowCount = new uint32_t[outWidth](); - nextOutY_srcStart = scaleY_fp; // First boundary is at scaleY_fp (source Y for outY=1) + ctx.rowAccum = new (std::nothrow) uint32_t[outWidth](); + ctx.rowCount = new (std::nothrow) uint32_t[outWidth](); + if (!ctx.rowAccum || !ctx.rowCount) { + LOG_ERR("JPG", "Failed to allocate scaling buffers"); + return false; + } + ctx.nextOutY_srcStart = scaleY_fp; } - // Process MCUs row-by-row and write to BMP as we go (top-down) - const int mcuPixelWidth = imageInfo.m_MCUWidth; - - for (int mcuY = 0; mcuY < imageInfo.m_MCUSPerCol; mcuY++) { - // Clear the MCU row buffer - memset(mcuRowBuffer, 0, mcuRowPixels); - - // Decode one row of MCUs - for (int mcuX = 0; mcuX < imageInfo.m_MCUSPerRow; mcuX++) { - const unsigned char mcuStatus = pjpeg_decode_mcu(); - if (mcuStatus != 0) { - if (mcuStatus == PJPG_NO_MORE_BLOCKS) { - LOG_ERR("JPG", "Unexpected end of blocks at MCU (%d, %d)", mcuX, mcuY); - } else { - LOG_ERR("JPG", "JPEG decode MCU failed at (%d, %d) with error code: %d", mcuX, mcuY, mcuStatus); - } - return false; - } - - // picojpeg stores MCU data in 8x8 blocks - // Block layout: H2V2(16x16)=0,64,128,192 H2V1(16x8)=0,64 H1V2(8x16)=0,128 - for (int blockY = 0; blockY < mcuPixelHeight; blockY++) { - for (int blockX = 0; blockX < mcuPixelWidth; blockX++) { - const int pixelX = mcuX * mcuPixelWidth + blockX; - if (pixelX >= imageInfo.m_width) continue; - - // Calculate proper block offset for picojpeg buffer - const int blockCol = blockX / 8; - const int blockRow = blockY / 8; - const int localX = blockX % 8; - const int localY = blockY % 8; - const int blocksPerRow = mcuPixelWidth / 8; - const int blockIndex = blockRow * blocksPerRow + blockCol; - const int pixelOffset = blockIndex * 64 + localY * 8 + localX; - - uint8_t gray; - if (imageInfo.m_comps == 1) { - gray = imageInfo.m_pMCUBufR[pixelOffset]; - } else { - const uint8_t r = imageInfo.m_pMCUBufR[pixelOffset]; - const uint8_t g = imageInfo.m_pMCUBufG[pixelOffset]; - const uint8_t b = imageInfo.m_pMCUBufB[pixelOffset]; - gray = (r * 25 + g * 50 + b * 25) / 100; - } - - mcuRowBuffer[blockY * imageInfo.m_width + pixelX] = gray; - } - } + if (oneBit) { + ctx.atkinson1BitDitherer = new (std::nothrow) Atkinson1BitDitherer(outWidth); + } else if (!USE_8BIT_OUTPUT) { + if (USE_ATKINSON) { + ctx.atkinsonDitherer = new (std::nothrow) AtkinsonDitherer(outWidth); + } else if (USE_FLOYD_STEINBERG) { + ctx.fsDitherer = new (std::nothrow) FloydSteinbergDitherer(outWidth); } + } - // Process source rows from this MCU row - const int startRow = mcuY * mcuPixelHeight; - const int endRow = (mcuY + 1) * mcuPixelHeight; + jpeg->setPixelType(EIGHT_BIT_GRAYSCALE); + jpeg->setUserPointer(&ctx); - for (int y = startRow; y < endRow && y < imageInfo.m_height; y++) { - const int bufferY = y - startRow; + rc = jpeg->decode(0, 0, 0); - if (!needsScaling) { - // No scaling - direct output (1:1 mapping) - memset(rowBuffer, 0, bytesPerRow); - - if (USE_8BIT_OUTPUT && !oneBit) { - for (int x = 0; x < outWidth; x++) { - const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x]; - rowBuffer[x] = adjustPixel(gray); - } - } else if (oneBit) { - // 1-bit output with Atkinson dithering for better quality - for (int x = 0; x < outWidth; x++) { - const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x]; - const uint8_t bit = - atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x) : quantize1bit(gray, x, y); - // Pack 1-bit value: MSB first, 8 pixels per byte - const int byteIndex = x / 8; - const int bitOffset = 7 - (x % 8); - rowBuffer[byteIndex] |= (bit << bitOffset); - } - if (atkinson1BitDitherer) atkinson1BitDitherer->nextRow(); - } else { - // 2-bit output - for (int x = 0; x < outWidth; x++) { - const uint8_t gray = adjustPixel(mcuRowBuffer[bufferY * imageInfo.m_width + x]); - uint8_t twoBit; - if (atkinsonDitherer) { - twoBit = atkinsonDitherer->processPixel(gray, x); - } else if (fsDitherer) { - twoBit = fsDitherer->processPixel(gray, x); - } else { - twoBit = quantize(gray, x, y); - } - const int byteIndex = (x * 2) / 8; - const int bitOffset = 6 - ((x * 2) % 8); - rowBuffer[byteIndex] |= (twoBit << bitOffset); - } - if (atkinsonDitherer) - atkinsonDitherer->nextRow(); - else if (fsDitherer) - fsDitherer->nextRow(); - } - bmpOut.write(rowBuffer, bytesPerRow); - } else { - // Fixed-point area averaging for exact fit scaling - // For each output pixel X, accumulate source pixels that map to it - // srcX range for outX: [outX * scaleX_fp >> 16, (outX+1) * scaleX_fp >> 16) - const uint8_t* srcRow = mcuRowBuffer + bufferY * imageInfo.m_width; - - for (int outX = 0; outX < outWidth; outX++) { - // Calculate source X range for this output pixel - const int srcXStart = (static_cast(outX) * scaleX_fp) >> 16; - const int srcXEnd = (static_cast(outX + 1) * scaleX_fp) >> 16; - - // Accumulate all source pixels in this range - int sum = 0; - int count = 0; - for (int srcX = srcXStart; srcX < srcXEnd && srcX < imageInfo.m_width; srcX++) { - sum += srcRow[srcX]; - count++; - } - - // Handle edge case: if no pixels in range, use nearest - if (count == 0 && srcXStart < imageInfo.m_width) { - sum = srcRow[srcXStart]; - count = 1; - } - - rowAccum[outX] += sum; - rowCount[outX] += count; - } - - // Check if we've crossed into the next output row(s) - // Current source Y in fixed point: y << 16 - const uint32_t srcY_fp = static_cast(y + 1) << 16; - - // Output all rows whose boundaries we've crossed (handles both up and downscaling) - // For upscaling, one source row may produce multiple output rows - while (srcY_fp >= nextOutY_srcStart && currentOutY < outHeight) { - memset(rowBuffer, 0, bytesPerRow); - - if (USE_8BIT_OUTPUT && !oneBit) { - for (int x = 0; x < outWidth; x++) { - const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0; - rowBuffer[x] = adjustPixel(gray); - } - } else if (oneBit) { - // 1-bit output with Atkinson dithering for better quality - for (int x = 0; x < outWidth; x++) { - const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0; - const uint8_t bit = atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x) - : quantize1bit(gray, x, currentOutY); - // Pack 1-bit value: MSB first, 8 pixels per byte - const int byteIndex = x / 8; - const int bitOffset = 7 - (x % 8); - rowBuffer[byteIndex] |= (bit << bitOffset); - } - if (atkinson1BitDitherer) atkinson1BitDitherer->nextRow(); - } else { - // 2-bit output - for (int x = 0; x < outWidth; x++) { - const uint8_t gray = adjustPixel((rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0); - uint8_t twoBit; - if (atkinsonDitherer) { - twoBit = atkinsonDitherer->processPixel(gray, x); - } else if (fsDitherer) { - twoBit = fsDitherer->processPixel(gray, x); - } else { - twoBit = quantize(gray, x, currentOutY); - } - const int byteIndex = (x * 2) / 8; - const int bitOffset = 6 - ((x * 2) % 8); - rowBuffer[byteIndex] |= (twoBit << bitOffset); - } - if (atkinsonDitherer) - atkinsonDitherer->nextRow(); - else if (fsDitherer) - fsDitherer->nextRow(); - } - - bmpOut.write(rowBuffer, bytesPerRow); - currentOutY++; - - // Update boundary for next output row - nextOutY_srcStart = static_cast(currentOutY + 1) * scaleY_fp; - - // For upscaling: don't reset accumulators if next output row uses same source data - // Only reset when we'll move to a new source row - if (srcY_fp >= nextOutY_srcStart) { - // More output rows to emit from same source - keep accumulator data - continue; - } - // Moving to next source row - reset accumulators - memset(rowAccum, 0, outWidth * sizeof(uint32_t)); - memset(rowCount, 0, outWidth * sizeof(uint32_t)); - } - } - } + if (rc != 1 || ctx.error) { + LOG_ERR("JPG", "JPEG decode failed (rc=%d, err=%d)", rc, jpeg->getLastError()); + return false; } LOG_DBG("JPG", "Successfully converted JPEG to BMP"); diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.h b/lib/JpegToBmpConverter/JpegToBmpConverter.h index 125692e46..66f77f673 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.h +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.h @@ -6,8 +6,6 @@ class Print; class ZipFile; class JpegToBmpConverter { - static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size, - unsigned char* pBytes_actually_read, void* pCallback_data); static bool jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight, bool oneBit, bool crop = true); diff --git a/lib/picojpeg/picojpeg.c b/lib/picojpeg/picojpeg.c deleted file mode 100644 index f612b73c0..000000000 --- a/lib/picojpeg/picojpeg.c +++ /dev/null @@ -1,2087 +0,0 @@ -//------------------------------------------------------------------------------ -// picojpeg.c v1.1 - Public domain, Rich Geldreich -// Nov. 27, 2010 - Initial release -// Feb. 9, 2013 - Added H1V2/H2V1 support, cleaned up macros, signed shift fixes -// Also integrated and tested changes from Chris Phoenix . -//------------------------------------------------------------------------------ -#include "picojpeg.h" -//------------------------------------------------------------------------------ -// Set to 1 if right shifts on signed ints are always unsigned (logical) shifts -// When 1, arithmetic right shifts will be emulated by using a logical shift -// with special case code to ensure the sign bit is replicated. -#define PJPG_RIGHT_SHIFT_IS_ALWAYS_UNSIGNED 0 - -// Define PJPG_INLINE to "inline" if your C compiler supports explicit inlining -#define PJPG_INLINE -//------------------------------------------------------------------------------ -typedef unsigned char uint8; -typedef unsigned short uint16; -typedef signed char int8; -typedef signed short int16; -//------------------------------------------------------------------------------ -#if PJPG_RIGHT_SHIFT_IS_ALWAYS_UNSIGNED -static int16 replicateSignBit16(int8 n) { - switch (n) { - case 0: - return 0x0000; - case 1: - return 0x8000; - case 2: - return 0xC000; - case 3: - return 0xE000; - case 4: - return 0xF000; - case 5: - return 0xF800; - case 6: - return 0xFC00; - case 7: - return 0xFE00; - case 8: - return 0xFF00; - case 9: - return 0xFF80; - case 10: - return 0xFFC0; - case 11: - return 0xFFE0; - case 12: - return 0xFFF0; - case 13: - return 0xFFF8; - case 14: - return 0xFFFC; - case 15: - return 0xFFFE; - default: - return 0xFFFF; - } -} -static PJPG_INLINE int16 arithmeticRightShiftN16(int16 x, int8 n) { - int16 r = (uint16)x >> (uint8)n; - if (x < 0) r |= replicateSignBit16(n); - return r; -} -static PJPG_INLINE long arithmeticRightShift8L(long x) { - long r = (unsigned long)x >> 8U; - if (x < 0) r |= ~(~(unsigned long)0U >> 8U); - return r; -} -#define PJPG_ARITH_SHIFT_RIGHT_N_16(x, n) arithmeticRightShiftN16(x, n) -#define PJPG_ARITH_SHIFT_RIGHT_8_L(x) arithmeticRightShift8L(x) -#else -#define PJPG_ARITH_SHIFT_RIGHT_N_16(x, n) ((x) >> (n)) -#define PJPG_ARITH_SHIFT_RIGHT_8_L(x) ((x) >> 8) -#endif -//------------------------------------------------------------------------------ -// Change as needed - the PJPG_MAX_WIDTH/PJPG_MAX_HEIGHT checks are only present -// to quickly detect bogus files. -#define PJPG_MAX_WIDTH 16384 -#define PJPG_MAX_HEIGHT 16384 -#define PJPG_MAXCOMPSINSCAN 3 -//------------------------------------------------------------------------------ -typedef enum { - M_SOF0 = 0xC0, - M_SOF1 = 0xC1, - M_SOF2 = 0xC2, - M_SOF3 = 0xC3, - - M_SOF5 = 0xC5, - M_SOF6 = 0xC6, - M_SOF7 = 0xC7, - - M_JPG = 0xC8, - M_SOF9 = 0xC9, - M_SOF10 = 0xCA, - M_SOF11 = 0xCB, - - M_SOF13 = 0xCD, - M_SOF14 = 0xCE, - M_SOF15 = 0xCF, - - M_DHT = 0xC4, - - M_DAC = 0xCC, - - M_RST0 = 0xD0, - M_RST1 = 0xD1, - M_RST2 = 0xD2, - M_RST3 = 0xD3, - M_RST4 = 0xD4, - M_RST5 = 0xD5, - M_RST6 = 0xD6, - M_RST7 = 0xD7, - - M_SOI = 0xD8, - M_EOI = 0xD9, - M_SOS = 0xDA, - M_DQT = 0xDB, - M_DNL = 0xDC, - M_DRI = 0xDD, - M_DHP = 0xDE, - M_EXP = 0xDF, - - M_APP0 = 0xE0, - M_APP15 = 0xEF, - - M_JPG0 = 0xF0, - M_JPG13 = 0xFD, - M_COM = 0xFE, - - M_TEM = 0x01, - - M_ERROR = 0x100, - - RST0 = 0xD0 -} JPEG_MARKER; -//------------------------------------------------------------------------------ -static const int8 ZAG[] = { - 0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, - 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29, 22, 15, 23, - 30, 37, 44, 51, 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63, -}; -//------------------------------------------------------------------------------ -// 128 bytes -static int16 gCoeffBuf[8 * 8]; - -// 8*8*4 bytes * 3 = 768 -static uint8 gMCUBufR[256]; -static uint8 gMCUBufG[256]; -static uint8 gMCUBufB[256]; - -// 256 bytes -static int16 gQuant0[8 * 8]; -static int16 gQuant1[8 * 8]; - -// 6 bytes -static int16 gLastDC[3]; - -typedef struct HuffTableT { - uint16 mMinCode[16]; - uint16 mMaxCode[16]; - uint8 mValPtr[16]; -} HuffTable; - -// DC - 192 -static HuffTable gHuffTab0; - -static uint8 gHuffVal0[16]; - -static HuffTable gHuffTab1; -static uint8 gHuffVal1[16]; - -// AC - 672 -static HuffTable gHuffTab2; -static uint8 gHuffVal2[256]; - -static HuffTable gHuffTab3; -static uint8 gHuffVal3[256]; - -static uint8 gValidHuffTables; -static uint8 gValidQuantTables; - -static uint8 gTemFlag; -#define PJPG_MAX_IN_BUF_SIZE 256 -static uint8 gInBuf[PJPG_MAX_IN_BUF_SIZE]; -static uint8 gInBufOfs; -static uint8 gInBufLeft; - -static uint16 gBitBuf; -static uint8 gBitsLeft; -//------------------------------------------------------------------------------ -static uint16 gImageXSize; -static uint16 gImageYSize; -static uint8 gCompsInFrame; -static uint8 gCompIdent[3]; -static uint8 gCompHSamp[3]; -static uint8 gCompVSamp[3]; -static uint8 gCompQuant[3]; - -static uint16 gRestartInterval; -static uint16 gNextRestartNum; -static uint16 gRestartsLeft; - -static uint8 gCompsInScan; -static uint8 gCompList[3]; -static uint8 gCompDCTab[3]; // 0,1 -static uint8 gCompACTab[3]; // 0,1 - -static pjpeg_scan_type_t gScanType; - -static uint8 gMaxBlocksPerMCU; -static uint8 gMaxMCUXSize; -static uint8 gMaxMCUYSize; -static uint16 gMaxMCUSPerRow; -static uint16 gMaxMCUSPerCol; - -static uint16 gNumMCUSRemainingX, gNumMCUSRemainingY; - -static uint8 gMCUOrg[6]; - -static pjpeg_need_bytes_callback_t g_pNeedBytesCallback; -static void* g_pCallback_data; -static uint8 gCallbackStatus; -static uint8 gReduce; -//------------------------------------------------------------------------------ -static void fillInBuf(void) { - unsigned char status; - - // Reserve a few bytes at the beginning of the buffer for putting back ("stuffing") chars. - gInBufOfs = 4; - gInBufLeft = 0; - - status = (*g_pNeedBytesCallback)(gInBuf + gInBufOfs, PJPG_MAX_IN_BUF_SIZE - gInBufOfs, &gInBufLeft, g_pCallback_data); - if (status) { - // The user provided need bytes callback has indicated an error, so record the error and continue trying to decode. - // The highest level pjpeg entrypoints will catch the error and return the non-zero status. - gCallbackStatus = status; - } -} -//------------------------------------------------------------------------------ -static PJPG_INLINE uint8 getChar(void) { - if (!gInBufLeft) { - fillInBuf(); - if (!gInBufLeft) { - gTemFlag = ~gTemFlag; - return gTemFlag ? 0xFF : 0xD9; - } - } - - gInBufLeft--; - return gInBuf[gInBufOfs++]; -} -//------------------------------------------------------------------------------ -static PJPG_INLINE void stuffChar(uint8 i) { - gInBufOfs--; - gInBuf[gInBufOfs] = i; - gInBufLeft++; -} -//------------------------------------------------------------------------------ -static PJPG_INLINE uint8 getOctet(uint8 FFCheck) { - uint8 c = getChar(); - - if ((FFCheck) && (c == 0xFF)) { - uint8 n = getChar(); - - if (n) { - stuffChar(n); - stuffChar(0xFF); - } - } - - return c; -} -//------------------------------------------------------------------------------ -static uint16 getBits(uint8 numBits, uint8 FFCheck) { - uint8 origBits = numBits; - uint16 ret = gBitBuf; - - if (numBits > 8) { - numBits -= 8; - - gBitBuf <<= gBitsLeft; - - gBitBuf |= getOctet(FFCheck); - - gBitBuf <<= (8 - gBitsLeft); - - ret = (ret & 0xFF00) | (gBitBuf >> 8); - } - - if (gBitsLeft < numBits) { - gBitBuf <<= gBitsLeft; - - gBitBuf |= getOctet(FFCheck); - - gBitBuf <<= (numBits - gBitsLeft); - - gBitsLeft = 8 - (numBits - gBitsLeft); - } else { - gBitsLeft = (uint8)(gBitsLeft - numBits); - gBitBuf <<= numBits; - } - - return ret >> (16 - origBits); -} -//------------------------------------------------------------------------------ -static PJPG_INLINE uint16 getBits1(uint8 numBits) { return getBits(numBits, 0); } -//------------------------------------------------------------------------------ -static PJPG_INLINE uint16 getBits2(uint8 numBits) { return getBits(numBits, 1); } -//------------------------------------------------------------------------------ -static PJPG_INLINE uint8 getBit(void) { - uint8 ret = 0; - if (gBitBuf & 0x8000) ret = 1; - - if (!gBitsLeft) { - gBitBuf |= getOctet(1); - - gBitsLeft += 8; - } - - gBitsLeft--; - gBitBuf <<= 1; - - return ret; -} -//------------------------------------------------------------------------------ -static uint16 getExtendTest(uint8 i) { - switch (i) { - case 0: - return 0; - case 1: - return 0x0001; - case 2: - return 0x0002; - case 3: - return 0x0004; - case 4: - return 0x0008; - case 5: - return 0x0010; - case 6: - return 0x0020; - case 7: - return 0x0040; - case 8: - return 0x0080; - case 9: - return 0x0100; - case 10: - return 0x0200; - case 11: - return 0x0400; - case 12: - return 0x0800; - case 13: - return 0x1000; - case 14: - return 0x2000; - case 15: - return 0x4000; - default: - return 0; - } -} -//------------------------------------------------------------------------------ -static int16 getExtendOffset(uint8 i) { - switch (i) { - case 0: - return 0; - case 1: - return ((-1) << 1) + 1; - case 2: - return ((-1) << 2) + 1; - case 3: - return ((-1) << 3) + 1; - case 4: - return ((-1) << 4) + 1; - case 5: - return ((-1) << 5) + 1; - case 6: - return ((-1) << 6) + 1; - case 7: - return ((-1) << 7) + 1; - case 8: - return ((-1) << 8) + 1; - case 9: - return ((-1) << 9) + 1; - case 10: - return ((-1) << 10) + 1; - case 11: - return ((-1) << 11) + 1; - case 12: - return ((-1) << 12) + 1; - case 13: - return ((-1) << 13) + 1; - case 14: - return ((-1) << 14) + 1; - case 15: - return ((-1) << 15) + 1; - default: - return 0; - } -}; -//------------------------------------------------------------------------------ -static PJPG_INLINE int16 huffExtend(uint16 x, uint8 s) { - return ((x < getExtendTest(s)) ? ((int16)x + getExtendOffset(s)) : (int16)x); -} -//------------------------------------------------------------------------------ -static PJPG_INLINE uint8 huffDecode(const HuffTable* pHuffTable, const uint8* pHuffVal) { - uint8 i = 0; - uint8 j; - uint16 code = getBit(); - - // This func only reads a bit at a time, which on modern CPU's is not terribly efficient. - // But on microcontrollers without strong integer shifting support this seems like a - // more reasonable approach. - for (;;) { - uint16 maxCode; - - if (i == 16) return 0; - - maxCode = pHuffTable->mMaxCode[i]; - if ((code <= maxCode) && (maxCode != 0xFFFF)) break; - - i++; - code <<= 1; - code |= getBit(); - } - - j = pHuffTable->mValPtr[i]; - j = (uint8)(j + (code - pHuffTable->mMinCode[i])); - - return pHuffVal[j]; -} -//------------------------------------------------------------------------------ -static void huffCreate(const uint8* pBits, HuffTable* pHuffTable) { - uint8 i = 0; - uint8 j = 0; - - uint16 code = 0; - - for (;;) { - uint8 num = pBits[i]; - - if (!num) { - pHuffTable->mMinCode[i] = 0x0000; - pHuffTable->mMaxCode[i] = 0xFFFF; - pHuffTable->mValPtr[i] = 0; - } else { - pHuffTable->mMinCode[i] = code; - pHuffTable->mMaxCode[i] = code + num - 1; - pHuffTable->mValPtr[i] = j; - - j = (uint8)(j + num); - - code = (uint16)(code + num); - } - - code <<= 1; - - i++; - if (i > 15) break; - } -} -//------------------------------------------------------------------------------ -static HuffTable* getHuffTable(uint8 index) { - // 0-1 = DC - // 2-3 = AC - switch (index) { - case 0: - return &gHuffTab0; - case 1: - return &gHuffTab1; - case 2: - return &gHuffTab2; - case 3: - return &gHuffTab3; - default: - return 0; - } -} -//------------------------------------------------------------------------------ -static uint8* getHuffVal(uint8 index) { - // 0-1 = DC - // 2-3 = AC - switch (index) { - case 0: - return gHuffVal0; - case 1: - return gHuffVal1; - case 2: - return gHuffVal2; - case 3: - return gHuffVal3; - default: - return 0; - } -} -//------------------------------------------------------------------------------ -static uint16 getMaxHuffCodes(uint8 index) { return (index < 2) ? 12 : 255; } -//------------------------------------------------------------------------------ -static uint8 readDHTMarker(void) { - uint8 bits[16]; - uint16 left = getBits1(16); - - if (left < 2) return PJPG_BAD_DHT_MARKER; - - left -= 2; - - while (left) { - uint8 i, tableIndex, index; - uint8* pHuffVal; - HuffTable* pHuffTable; - uint16 count, totalRead; - - index = (uint8)getBits1(8); - - if (((index & 0xF) > 1) || ((index & 0xF0) > 0x10)) return PJPG_BAD_DHT_INDEX; - - tableIndex = ((index >> 3) & 2) + (index & 1); - - pHuffTable = getHuffTable(tableIndex); - pHuffVal = getHuffVal(tableIndex); - - gValidHuffTables |= (1 << tableIndex); - - count = 0; - for (i = 0; i <= 15; i++) { - uint8 n = (uint8)getBits1(8); - bits[i] = n; - count = (uint16)(count + n); - } - - if (count > getMaxHuffCodes(tableIndex)) return PJPG_BAD_DHT_COUNTS; - - for (i = 0; i < count; i++) pHuffVal[i] = (uint8)getBits1(8); - - totalRead = 1 + 16 + count; - - if (left < totalRead) return PJPG_BAD_DHT_MARKER; - - left = (uint16)(left - totalRead); - - huffCreate(bits, pHuffTable); - } - - return 0; -} -//------------------------------------------------------------------------------ -static void createWinogradQuant(int16* pQuant); - -static uint8 readDQTMarker(void) { - uint16 left = getBits1(16); - - if (left < 2) return PJPG_BAD_DQT_MARKER; - - left -= 2; - - while (left) { - uint8 i; - uint8 n = (uint8)getBits1(8); - uint8 prec = n >> 4; - uint16 totalRead; - - n &= 0x0F; - - if (n > 1) return PJPG_BAD_DQT_TABLE; - - gValidQuantTables |= (n ? 2 : 1); - - // read quantization entries, in zag order - for (i = 0; i < 64; i++) { - uint16 temp = getBits1(8); - - if (prec) temp = (temp << 8) + getBits1(8); - - if (n) - gQuant1[i] = (int16)temp; - else - gQuant0[i] = (int16)temp; - } - - createWinogradQuant(n ? gQuant1 : gQuant0); - - totalRead = 64 + 1; - - if (prec) totalRead += 64; - - if (left < totalRead) return PJPG_BAD_DQT_LENGTH; - - left = (uint16)(left - totalRead); - } - - return 0; -} -//------------------------------------------------------------------------------ -static uint8 readSOFMarker(void) { - uint8 i; - uint16 left = getBits1(16); - - if (getBits1(8) != 8) return PJPG_BAD_PRECISION; - - gImageYSize = getBits1(16); - - if ((!gImageYSize) || (gImageYSize > PJPG_MAX_HEIGHT)) return PJPG_BAD_HEIGHT; - - gImageXSize = getBits1(16); - - if ((!gImageXSize) || (gImageXSize > PJPG_MAX_WIDTH)) return PJPG_BAD_WIDTH; - - gCompsInFrame = (uint8)getBits1(8); - - if (gCompsInFrame > 3) return PJPG_TOO_MANY_COMPONENTS; - - if (left != (gCompsInFrame + gCompsInFrame + gCompsInFrame + 8)) return PJPG_BAD_SOF_LENGTH; - - for (i = 0; i < gCompsInFrame; i++) { - gCompIdent[i] = (uint8)getBits1(8); - gCompHSamp[i] = (uint8)getBits1(4); - gCompVSamp[i] = (uint8)getBits1(4); - gCompQuant[i] = (uint8)getBits1(8); - - if (gCompQuant[i] > 1) return PJPG_UNSUPPORTED_QUANT_TABLE; - } - - return 0; -} -//------------------------------------------------------------------------------ -// Used to skip unrecognized markers. -static uint8 skipVariableMarker(void) { - uint16 left = getBits1(16); - - if (left < 2) return PJPG_BAD_VARIABLE_MARKER; - - left -= 2; - - while (left) { - getBits1(8); - left--; - } - - return 0; -} -//------------------------------------------------------------------------------ -// Read a define restart interval (DRI) marker. -static uint8 readDRIMarker(void) { - if (getBits1(16) != 4) return PJPG_BAD_DRI_LENGTH; - - gRestartInterval = getBits1(16); - - return 0; -} -//------------------------------------------------------------------------------ -// Read a start of scan (SOS) marker. -static uint8 readSOSMarker(void) { - uint8 i; - uint16 left = getBits1(16); - uint8 spectral_start, spectral_end, successive_high, successive_low; - - gCompsInScan = (uint8)getBits1(8); - - left -= 3; - - if ((left != (gCompsInScan + gCompsInScan + 3)) || (gCompsInScan < 1) || (gCompsInScan > PJPG_MAXCOMPSINSCAN)) - return PJPG_BAD_SOS_LENGTH; - - for (i = 0; i < gCompsInScan; i++) { - uint8 cc = (uint8)getBits1(8); - uint8 c = (uint8)getBits1(8); - uint8 ci; - - left -= 2; - - for (ci = 0; ci < gCompsInFrame; ci++) - if (cc == gCompIdent[ci]) break; - - if (ci >= gCompsInFrame) return PJPG_BAD_SOS_COMP_ID; - - gCompList[i] = ci; - gCompDCTab[ci] = (c >> 4) & 15; - gCompACTab[ci] = (c & 15); - } - - spectral_start = (uint8)getBits1(8); - spectral_end = (uint8)getBits1(8); - successive_high = (uint8)getBits1(4); - successive_low = (uint8)getBits1(4); - - left -= 3; - - while (left) { - getBits1(8); - left--; - } - - return 0; -} -//------------------------------------------------------------------------------ -static uint8 nextMarker(void) { - uint8 c; - uint8 bytes = 0; - - do { - do { - bytes++; - - c = (uint8)getBits1(8); - - } while (c != 0xFF); - - do { - c = (uint8)getBits1(8); - - } while (c == 0xFF); - - } while (c == 0); - - // If bytes > 0 here, there where extra bytes before the marker (not good). - - return c; -} -//------------------------------------------------------------------------------ -// Process markers. Returns when an SOFx, SOI, EOI, or SOS marker is -// encountered. -static uint8 processMarkers(uint8* pMarker) { - for (;;) { - uint8 c = nextMarker(); - - switch (c) { - case M_SOF0: - case M_SOF1: - case M_SOF2: - case M_SOF3: - case M_SOF5: - case M_SOF6: - case M_SOF7: - // case M_JPG: - case M_SOF9: - case M_SOF10: - case M_SOF11: - case M_SOF13: - case M_SOF14: - case M_SOF15: - case M_SOI: - case M_EOI: - case M_SOS: { - *pMarker = c; - return 0; - } - case M_DHT: { - readDHTMarker(); - break; - } - // Sorry, no arithmetic support at this time. Dumb patents! - case M_DAC: { - return PJPG_NO_ARITHMITIC_SUPPORT; - } - case M_DQT: { - readDQTMarker(); - break; - } - case M_DRI: { - readDRIMarker(); - break; - } - // case M_APP0: /* no need to read the JFIF marker */ - - case M_JPG: - case M_RST0: /* no parameters */ - case M_RST1: - case M_RST2: - case M_RST3: - case M_RST4: - case M_RST5: - case M_RST6: - case M_RST7: - case M_TEM: { - return PJPG_UNEXPECTED_MARKER; - } - default: /* must be DNL, DHP, EXP, APPn, JPGn, COM, or RESn or APP0 */ - { - skipVariableMarker(); - break; - } - } - } - // return 0; -} -//------------------------------------------------------------------------------ -// Finds the start of image (SOI) marker. -static uint8 locateSOIMarker(void) { - uint16 bytesleft; - - uint8 lastchar = (uint8)getBits1(8); - - uint8 thischar = (uint8)getBits1(8); - - /* ok if it's a normal JPEG file without a special header */ - - if ((lastchar == 0xFF) && (thischar == M_SOI)) return 0; - - bytesleft = 4096; // 512; - - for (;;) { - if (--bytesleft == 0) return PJPG_NOT_JPEG; - - lastchar = thischar; - - thischar = (uint8)getBits1(8); - - if (lastchar == 0xFF) { - if (thischar == M_SOI) - break; - else if (thischar == M_EOI) // getBits1 will keep returning M_EOI if we read past the end - return PJPG_NOT_JPEG; - } - } - - /* Check the next character after marker: if it's not 0xFF, it can't - be the start of the next marker, so the file is bad */ - - thischar = (uint8)((gBitBuf >> 8) & 0xFF); - - if (thischar != 0xFF) return PJPG_NOT_JPEG; - - return 0; -} -//------------------------------------------------------------------------------ -// Find a start of frame (SOF) marker. -static uint8 locateSOFMarker(void) { - uint8 c; - - uint8 status = locateSOIMarker(); - if (status) return status; - - status = processMarkers(&c); - if (status) return status; - - switch (c) { - case M_SOF2: { - // Progressive JPEG - not supported by picojpeg (would require too - // much memory, or too many IDCT's for embedded systems). - return PJPG_UNSUPPORTED_MODE; - } - case M_SOF0: /* baseline DCT */ - { - status = readSOFMarker(); - if (status) return status; - - break; - } - case M_SOF9: { - return PJPG_NO_ARITHMITIC_SUPPORT; - } - case M_SOF1: /* extended sequential DCT */ - default: { - return PJPG_UNSUPPORTED_MARKER; - } - } - - return 0; -} -//------------------------------------------------------------------------------ -// Find a start of scan (SOS) marker. -static uint8 locateSOSMarker(uint8* pFoundEOI) { - uint8 c; - uint8 status; - - *pFoundEOI = 0; - - status = processMarkers(&c); - if (status) return status; - - if (c == M_EOI) { - *pFoundEOI = 1; - return 0; - } else if (c != M_SOS) - return PJPG_UNEXPECTED_MARKER; - - return readSOSMarker(); -} -//------------------------------------------------------------------------------ -static uint8 init(void) { - gImageXSize = 0; - gImageYSize = 0; - gCompsInFrame = 0; - gRestartInterval = 0; - gCompsInScan = 0; - gValidHuffTables = 0; - gValidQuantTables = 0; - gTemFlag = 0; - gInBufOfs = 0; - gInBufLeft = 0; - gBitBuf = 0; - gBitsLeft = 8; - - getBits1(8); - getBits1(8); - - return 0; -} -//------------------------------------------------------------------------------ -// This method throws back into the stream any bytes that where read -// into the bit buffer during initial marker scanning. -static void fixInBuffer(void) { - /* In case any 0xFF's where pulled into the buffer during marker scanning */ - - if (gBitsLeft > 0) stuffChar((uint8)gBitBuf); - - stuffChar((uint8)(gBitBuf >> 8)); - - gBitsLeft = 8; - getBits2(8); - getBits2(8); -} -//------------------------------------------------------------------------------ -// Restart interval processing. -static uint8 processRestart(void) { - // Let's scan a little bit to find the marker, but not _too_ far. - // 1536 is a "fudge factor" that determines how much to scan. - uint16 i; - uint8 c = 0; - - for (i = 1536; i > 0; i--) - if (getChar() == 0xFF) break; - - if (i == 0) return PJPG_BAD_RESTART_MARKER; - - for (; i > 0; i--) - if ((c = getChar()) != 0xFF) break; - - if (i == 0) return PJPG_BAD_RESTART_MARKER; - - // Is it the expected marker? If not, something bad happened. - if (c != (gNextRestartNum + M_RST0)) return PJPG_BAD_RESTART_MARKER; - - // Reset each component's DC prediction values. - gLastDC[0] = 0; - gLastDC[1] = 0; - gLastDC[2] = 0; - - gRestartsLeft = gRestartInterval; - - gNextRestartNum = (gNextRestartNum + 1) & 7; - - // Get the bit buffer going again - - gBitsLeft = 8; - getBits2(8); - getBits2(8); - - return 0; -} -//------------------------------------------------------------------------------ -// FIXME: findEOI() is not actually called at the end of the image -// (it's optional, and probably not needed on embedded devices) -static uint8 findEOI(void) { - uint8 c; - uint8 status; - - // Prime the bit buffer - gBitsLeft = 8; - getBits1(8); - getBits1(8); - - // The next marker _should_ be EOI - status = processMarkers(&c); - if (status) - return status; - else if (gCallbackStatus) - return gCallbackStatus; - - // gTotalBytesRead -= in_buf_left; - if (c != M_EOI) return PJPG_UNEXPECTED_MARKER; - - return 0; -} -//------------------------------------------------------------------------------ -static uint8 checkHuffTables(void) { - uint8 i; - - for (i = 0; i < gCompsInScan; i++) { - uint8 compDCTab = gCompDCTab[gCompList[i]]; - uint8 compACTab = gCompACTab[gCompList[i]] + 2; - - if (((gValidHuffTables & (1 << compDCTab)) == 0) || ((gValidHuffTables & (1 << compACTab)) == 0)) - return PJPG_UNDEFINED_HUFF_TABLE; - } - - return 0; -} -//------------------------------------------------------------------------------ -static uint8 checkQuantTables(void) { - uint8 i; - - for (i = 0; i < gCompsInScan; i++) { - uint8 compQuantMask = gCompQuant[gCompList[i]] ? 2 : 1; - - if ((gValidQuantTables & compQuantMask) == 0) return PJPG_UNDEFINED_QUANT_TABLE; - } - - return 0; -} -//------------------------------------------------------------------------------ -static uint8 initScan(void) { - uint8 foundEOI; - uint8 status = locateSOSMarker(&foundEOI); - if (status) return status; - if (foundEOI) return PJPG_UNEXPECTED_MARKER; - - status = checkHuffTables(); - if (status) return status; - - status = checkQuantTables(); - if (status) return status; - - gLastDC[0] = 0; - gLastDC[1] = 0; - gLastDC[2] = 0; - - if (gRestartInterval) { - gRestartsLeft = gRestartInterval; - gNextRestartNum = 0; - } - - fixInBuffer(); - - return 0; -} -//------------------------------------------------------------------------------ -static uint8 initFrame(void) { - if (gCompsInFrame == 1) { - if ((gCompHSamp[0] != 1) || (gCompVSamp[0] != 1)) return PJPG_UNSUPPORTED_SAMP_FACTORS; - - gScanType = PJPG_GRAYSCALE; - - gMaxBlocksPerMCU = 1; - gMCUOrg[0] = 0; - - gMaxMCUXSize = 8; - gMaxMCUYSize = 8; - } else if (gCompsInFrame == 3) { - if (((gCompHSamp[1] != 1) || (gCompVSamp[1] != 1)) || ((gCompHSamp[2] != 1) || (gCompVSamp[2] != 1))) - return PJPG_UNSUPPORTED_SAMP_FACTORS; - - if ((gCompHSamp[0] == 1) && (gCompVSamp[0] == 1)) { - gScanType = PJPG_YH1V1; - - gMaxBlocksPerMCU = 3; - gMCUOrg[0] = 0; - gMCUOrg[1] = 1; - gMCUOrg[2] = 2; - - gMaxMCUXSize = 8; - gMaxMCUYSize = 8; - } else if ((gCompHSamp[0] == 1) && (gCompVSamp[0] == 2)) { - gScanType = PJPG_YH1V2; - - gMaxBlocksPerMCU = 4; - gMCUOrg[0] = 0; - gMCUOrg[1] = 0; - gMCUOrg[2] = 1; - gMCUOrg[3] = 2; - - gMaxMCUXSize = 8; - gMaxMCUYSize = 16; - } else if ((gCompHSamp[0] == 2) && (gCompVSamp[0] == 1)) { - gScanType = PJPG_YH2V1; - - gMaxBlocksPerMCU = 4; - gMCUOrg[0] = 0; - gMCUOrg[1] = 0; - gMCUOrg[2] = 1; - gMCUOrg[3] = 2; - - gMaxMCUXSize = 16; - gMaxMCUYSize = 8; - } else if ((gCompHSamp[0] == 2) && (gCompVSamp[0] == 2)) { - gScanType = PJPG_YH2V2; - - gMaxBlocksPerMCU = 6; - gMCUOrg[0] = 0; - gMCUOrg[1] = 0; - gMCUOrg[2] = 0; - gMCUOrg[3] = 0; - gMCUOrg[4] = 1; - gMCUOrg[5] = 2; - - gMaxMCUXSize = 16; - gMaxMCUYSize = 16; - } else - return PJPG_UNSUPPORTED_SAMP_FACTORS; - } else - return PJPG_UNSUPPORTED_COLORSPACE; - - gMaxMCUSPerRow = (gImageXSize + (gMaxMCUXSize - 1)) >> ((gMaxMCUXSize == 8) ? 3 : 4); - gMaxMCUSPerCol = (gImageYSize + (gMaxMCUYSize - 1)) >> ((gMaxMCUYSize == 8) ? 3 : 4); - - // This can overflow on large JPEG's. - // gNumMCUSRemaining = gMaxMCUSPerRow * gMaxMCUSPerCol; - gNumMCUSRemainingX = gMaxMCUSPerRow; - gNumMCUSRemainingY = gMaxMCUSPerCol; - - return 0; -} -//---------------------------------------------------------------------------- -// Winograd IDCT: 5 multiplies per row/col, up to 80 muls for the 2D IDCT - -#define PJPG_DCT_SCALE_BITS 7 - -#define PJPG_DCT_SCALE (1U << PJPG_DCT_SCALE_BITS) - -#define PJPG_DESCALE(x) PJPG_ARITH_SHIFT_RIGHT_N_16(((x) + (1 << (PJPG_DCT_SCALE_BITS - 1))), PJPG_DCT_SCALE_BITS) - -#define PJPG_WFIX(x) ((x) * PJPG_DCT_SCALE + 0.5f) - -#define PJPG_WINOGRAD_QUANT_SCALE_BITS 10 - -const uint8 gWinogradQuant[] = { - 128, 178, 178, 167, 246, 167, 151, 232, 232, 151, 128, 209, 219, 209, 128, 101, 178, 197, 197, 178, 101, 69, - 139, 167, 177, 167, 139, 69, 35, 96, 131, 151, 151, 131, 96, 35, 49, 91, 118, 128, 118, 91, 49, 46, - 81, 101, 101, 81, 46, 42, 69, 79, 69, 42, 35, 54, 54, 35, 28, 37, 28, 19, 19, 10, -}; - -// Multiply quantization matrix by the Winograd IDCT scale factors -static void createWinogradQuant(int16* pQuant) { - uint8 i; - - for (i = 0; i < 64; i++) { - long x = pQuant[i]; - x *= gWinogradQuant[i]; - pQuant[i] = (int16)((x + (1 << (PJPG_WINOGRAD_QUANT_SCALE_BITS - PJPG_DCT_SCALE_BITS - 1))) >> - (PJPG_WINOGRAD_QUANT_SCALE_BITS - PJPG_DCT_SCALE_BITS)); - } -} - -// These multiply helper functions are the 4 types of signed multiplies needed by the Winograd IDCT. -// A smart C compiler will optimize them to use 16x8 = 24 bit muls, if not you may need to tweak -// these functions or drop to CPU specific inline assembly. - -// 1/cos(4*pi/16) -// 362, 256+106 -static PJPG_INLINE int16 imul_b1_b3(int16 w) { - long x = (w * 362L); - x += 128L; - return (int16)(PJPG_ARITH_SHIFT_RIGHT_8_L(x)); -} - -// 1/cos(6*pi/16) -// 669, 256+256+157 -static PJPG_INLINE int16 imul_b2(int16 w) { - long x = (w * 669L); - x += 128L; - return (int16)(PJPG_ARITH_SHIFT_RIGHT_8_L(x)); -} - -// 1/cos(2*pi/16) -// 277, 256+21 -static PJPG_INLINE int16 imul_b4(int16 w) { - long x = (w * 277L); - x += 128L; - return (int16)(PJPG_ARITH_SHIFT_RIGHT_8_L(x)); -} - -// 1/(cos(2*pi/16) + cos(6*pi/16)) -// 196, 196 -static PJPG_INLINE int16 imul_b5(int16 w) { - long x = (w * 196L); - x += 128L; - return (int16)(PJPG_ARITH_SHIFT_RIGHT_8_L(x)); -} - -static PJPG_INLINE uint8 clamp(int16 s) { - if ((uint16)s > 255U) { - if (s < 0) - return 0; - else if (s > 255) - return 255; - } - - return (uint8)s; -} - -static void idctRows(void) { - uint8 i; - int16* pSrc = gCoeffBuf; - - for (i = 0; i < 8; i++) { - if ((pSrc[1] | pSrc[2] | pSrc[3] | pSrc[4] | pSrc[5] | pSrc[6] | pSrc[7]) == 0) { - // Short circuit the 1D IDCT if only the DC component is non-zero - int16 src0 = *pSrc; - - *(pSrc + 1) = src0; - *(pSrc + 2) = src0; - *(pSrc + 3) = src0; - *(pSrc + 4) = src0; - *(pSrc + 5) = src0; - *(pSrc + 6) = src0; - *(pSrc + 7) = src0; - } else { - int16 src4 = *(pSrc + 5); - int16 src7 = *(pSrc + 3); - int16 x4 = src4 - src7; - int16 x7 = src4 + src7; - - int16 src5 = *(pSrc + 1); - int16 src6 = *(pSrc + 7); - int16 x5 = src5 + src6; - int16 x6 = src5 - src6; - - int16 tmp1 = imul_b5(x4 - x6); - int16 stg26 = imul_b4(x6) - tmp1; - - int16 x24 = tmp1 - imul_b2(x4); - - int16 x15 = x5 - x7; - int16 x17 = x5 + x7; - - int16 tmp2 = stg26 - x17; - int16 tmp3 = imul_b1_b3(x15) - tmp2; - int16 x44 = tmp3 + x24; - - int16 src0 = *(pSrc + 0); - int16 src1 = *(pSrc + 4); - int16 x30 = src0 + src1; - int16 x31 = src0 - src1; - - int16 src2 = *(pSrc + 2); - int16 src3 = *(pSrc + 6); - int16 x12 = src2 - src3; - int16 x13 = src2 + src3; - - int16 x32 = imul_b1_b3(x12) - x13; - - int16 x40 = x30 + x13; - int16 x43 = x30 - x13; - int16 x41 = x31 + x32; - int16 x42 = x31 - x32; - - *(pSrc + 0) = x40 + x17; - *(pSrc + 1) = x41 + tmp2; - *(pSrc + 2) = x42 + tmp3; - *(pSrc + 3) = x43 - x44; - *(pSrc + 4) = x43 + x44; - *(pSrc + 5) = x42 - tmp3; - *(pSrc + 6) = x41 - tmp2; - *(pSrc + 7) = x40 - x17; - } - - pSrc += 8; - } -} - -static void idctCols(void) { - uint8 i; - - int16* pSrc = gCoeffBuf; - - for (i = 0; i < 8; i++) { - if ((pSrc[1 * 8] | pSrc[2 * 8] | pSrc[3 * 8] | pSrc[4 * 8] | pSrc[5 * 8] | pSrc[6 * 8] | pSrc[7 * 8]) == 0) { - // Short circuit the 1D IDCT if only the DC component is non-zero - uint8 c = clamp(PJPG_DESCALE(*pSrc) + 128); - *(pSrc + 0 * 8) = c; - *(pSrc + 1 * 8) = c; - *(pSrc + 2 * 8) = c; - *(pSrc + 3 * 8) = c; - *(pSrc + 4 * 8) = c; - *(pSrc + 5 * 8) = c; - *(pSrc + 6 * 8) = c; - *(pSrc + 7 * 8) = c; - } else { - int16 src4 = *(pSrc + 5 * 8); - int16 src7 = *(pSrc + 3 * 8); - int16 x4 = src4 - src7; - int16 x7 = src4 + src7; - - int16 src5 = *(pSrc + 1 * 8); - int16 src6 = *(pSrc + 7 * 8); - int16 x5 = src5 + src6; - int16 x6 = src5 - src6; - - int16 tmp1 = imul_b5(x4 - x6); - int16 stg26 = imul_b4(x6) - tmp1; - - int16 x24 = tmp1 - imul_b2(x4); - - int16 x15 = x5 - x7; - int16 x17 = x5 + x7; - - int16 tmp2 = stg26 - x17; - int16 tmp3 = imul_b1_b3(x15) - tmp2; - int16 x44 = tmp3 + x24; - - int16 src0 = *(pSrc + 0 * 8); - int16 src1 = *(pSrc + 4 * 8); - int16 x30 = src0 + src1; - int16 x31 = src0 - src1; - - int16 src2 = *(pSrc + 2 * 8); - int16 src3 = *(pSrc + 6 * 8); - int16 x12 = src2 - src3; - int16 x13 = src2 + src3; - - int16 x32 = imul_b1_b3(x12) - x13; - - int16 x40 = x30 + x13; - int16 x43 = x30 - x13; - int16 x41 = x31 + x32; - int16 x42 = x31 - x32; - - // descale, convert to unsigned and clamp to 8-bit - *(pSrc + 0 * 8) = clamp(PJPG_DESCALE(x40 + x17) + 128); - *(pSrc + 1 * 8) = clamp(PJPG_DESCALE(x41 + tmp2) + 128); - *(pSrc + 2 * 8) = clamp(PJPG_DESCALE(x42 + tmp3) + 128); - *(pSrc + 3 * 8) = clamp(PJPG_DESCALE(x43 - x44) + 128); - *(pSrc + 4 * 8) = clamp(PJPG_DESCALE(x43 + x44) + 128); - *(pSrc + 5 * 8) = clamp(PJPG_DESCALE(x42 - tmp3) + 128); - *(pSrc + 6 * 8) = clamp(PJPG_DESCALE(x41 - tmp2) + 128); - *(pSrc + 7 * 8) = clamp(PJPG_DESCALE(x40 - x17) + 128); - } - - pSrc++; - } -} - -/*----------------------------------------------------------------------------*/ -static PJPG_INLINE uint8 addAndClamp(uint8 a, int16 b) { - b = a + b; - - if ((uint16)b > 255U) { - if (b < 0) - return 0; - else if (b > 255) - return 255; - } - - return (uint8)b; -} -/*----------------------------------------------------------------------------*/ -static PJPG_INLINE uint8 subAndClamp(uint8 a, int16 b) { - b = a - b; - - if ((uint16)b > 255U) { - if (b < 0) - return 0; - else if (b > 255) - return 255; - } - - return (uint8)b; -} -/*----------------------------------------------------------------------------*/ -// 103/256 -// R = Y + 1.402 (Cr-128) - -// 88/256, 183/256 -// G = Y - 0.34414 (Cb-128) - 0.71414 (Cr-128) - -// 198/256 -// B = Y + 1.772 (Cb-128) -/*----------------------------------------------------------------------------*/ -// Cb upsample and accumulate, 4x4 to 8x8 -static void upsampleCb(uint8 srcOfs, uint8 dstOfs) { - // Cb - affects G and B - uint8 x, y; - int16* pSrc = gCoeffBuf + srcOfs; - uint8* pDstG = gMCUBufG + dstOfs; - uint8* pDstB = gMCUBufB + dstOfs; - for (y = 0; y < 4; y++) { - for (x = 0; x < 4; x++) { - uint8 cb = (uint8)*pSrc++; - int16 cbG, cbB; - - cbG = ((cb * 88U) >> 8U) - 44U; - pDstG[0] = subAndClamp(pDstG[0], cbG); - pDstG[1] = subAndClamp(pDstG[1], cbG); - pDstG[8] = subAndClamp(pDstG[8], cbG); - pDstG[9] = subAndClamp(pDstG[9], cbG); - - cbB = (cb + ((cb * 198U) >> 8U)) - 227U; - pDstB[0] = addAndClamp(pDstB[0], cbB); - pDstB[1] = addAndClamp(pDstB[1], cbB); - pDstB[8] = addAndClamp(pDstB[8], cbB); - pDstB[9] = addAndClamp(pDstB[9], cbB); - - pDstG += 2; - pDstB += 2; - } - - pSrc = pSrc - 4 + 8; - pDstG = pDstG - 8 + 16; - pDstB = pDstB - 8 + 16; - } -} -/*----------------------------------------------------------------------------*/ -// Cb upsample and accumulate, 4x8 to 8x8 -static void upsampleCbH(uint8 srcOfs, uint8 dstOfs) { - // Cb - affects G and B - uint8 x, y; - int16* pSrc = gCoeffBuf + srcOfs; - uint8* pDstG = gMCUBufG + dstOfs; - uint8* pDstB = gMCUBufB + dstOfs; - for (y = 0; y < 8; y++) { - for (x = 0; x < 4; x++) { - uint8 cb = (uint8)*pSrc++; - int16 cbG, cbB; - - cbG = ((cb * 88U) >> 8U) - 44U; - pDstG[0] = subAndClamp(pDstG[0], cbG); - pDstG[1] = subAndClamp(pDstG[1], cbG); - - cbB = (cb + ((cb * 198U) >> 8U)) - 227U; - pDstB[0] = addAndClamp(pDstB[0], cbB); - pDstB[1] = addAndClamp(pDstB[1], cbB); - - pDstG += 2; - pDstB += 2; - } - - pSrc = pSrc - 4 + 8; - } -} -/*----------------------------------------------------------------------------*/ -// Cb upsample and accumulate, 8x4 to 8x8 -static void upsampleCbV(uint8 srcOfs, uint8 dstOfs) { - // Cb - affects G and B - uint8 x, y; - int16* pSrc = gCoeffBuf + srcOfs; - uint8* pDstG = gMCUBufG + dstOfs; - uint8* pDstB = gMCUBufB + dstOfs; - for (y = 0; y < 4; y++) { - for (x = 0; x < 8; x++) { - uint8 cb = (uint8)*pSrc++; - int16 cbG, cbB; - - cbG = ((cb * 88U) >> 8U) - 44U; - pDstG[0] = subAndClamp(pDstG[0], cbG); - pDstG[8] = subAndClamp(pDstG[8], cbG); - - cbB = (cb + ((cb * 198U) >> 8U)) - 227U; - pDstB[0] = addAndClamp(pDstB[0], cbB); - pDstB[8] = addAndClamp(pDstB[8], cbB); - - ++pDstG; - ++pDstB; - } - - pDstG = pDstG - 8 + 16; - pDstB = pDstB - 8 + 16; - } -} -/*----------------------------------------------------------------------------*/ -// 103/256 -// R = Y + 1.402 (Cr-128) - -// 88/256, 183/256 -// G = Y - 0.34414 (Cb-128) - 0.71414 (Cr-128) - -// 198/256 -// B = Y + 1.772 (Cb-128) -/*----------------------------------------------------------------------------*/ -// Cr upsample and accumulate, 4x4 to 8x8 -static void upsampleCr(uint8 srcOfs, uint8 dstOfs) { - // Cr - affects R and G - uint8 x, y; - int16* pSrc = gCoeffBuf + srcOfs; - uint8* pDstR = gMCUBufR + dstOfs; - uint8* pDstG = gMCUBufG + dstOfs; - for (y = 0; y < 4; y++) { - for (x = 0; x < 4; x++) { - uint8 cr = (uint8)*pSrc++; - int16 crR, crG; - - crR = (cr + ((cr * 103U) >> 8U)) - 179; - pDstR[0] = addAndClamp(pDstR[0], crR); - pDstR[1] = addAndClamp(pDstR[1], crR); - pDstR[8] = addAndClamp(pDstR[8], crR); - pDstR[9] = addAndClamp(pDstR[9], crR); - - crG = ((cr * 183U) >> 8U) - 91; - pDstG[0] = subAndClamp(pDstG[0], crG); - pDstG[1] = subAndClamp(pDstG[1], crG); - pDstG[8] = subAndClamp(pDstG[8], crG); - pDstG[9] = subAndClamp(pDstG[9], crG); - - pDstR += 2; - pDstG += 2; - } - - pSrc = pSrc - 4 + 8; - pDstR = pDstR - 8 + 16; - pDstG = pDstG - 8 + 16; - } -} -/*----------------------------------------------------------------------------*/ -// Cr upsample and accumulate, 4x8 to 8x8 -static void upsampleCrH(uint8 srcOfs, uint8 dstOfs) { - // Cr - affects R and G - uint8 x, y; - int16* pSrc = gCoeffBuf + srcOfs; - uint8* pDstR = gMCUBufR + dstOfs; - uint8* pDstG = gMCUBufG + dstOfs; - for (y = 0; y < 8; y++) { - for (x = 0; x < 4; x++) { - uint8 cr = (uint8)*pSrc++; - int16 crR, crG; - - crR = (cr + ((cr * 103U) >> 8U)) - 179; - pDstR[0] = addAndClamp(pDstR[0], crR); - pDstR[1] = addAndClamp(pDstR[1], crR); - - crG = ((cr * 183U) >> 8U) - 91; - pDstG[0] = subAndClamp(pDstG[0], crG); - pDstG[1] = subAndClamp(pDstG[1], crG); - - pDstR += 2; - pDstG += 2; - } - - pSrc = pSrc - 4 + 8; - } -} -/*----------------------------------------------------------------------------*/ -// Cr upsample and accumulate, 8x4 to 8x8 -static void upsampleCrV(uint8 srcOfs, uint8 dstOfs) { - // Cr - affects R and G - uint8 x, y; - int16* pSrc = gCoeffBuf + srcOfs; - uint8* pDstR = gMCUBufR + dstOfs; - uint8* pDstG = gMCUBufG + dstOfs; - for (y = 0; y < 4; y++) { - for (x = 0; x < 8; x++) { - uint8 cr = (uint8)*pSrc++; - int16 crR, crG; - - crR = (cr + ((cr * 103U) >> 8U)) - 179; - pDstR[0] = addAndClamp(pDstR[0], crR); - pDstR[8] = addAndClamp(pDstR[8], crR); - - crG = ((cr * 183U) >> 8U) - 91; - pDstG[0] = subAndClamp(pDstG[0], crG); - pDstG[8] = subAndClamp(pDstG[8], crG); - - ++pDstR; - ++pDstG; - } - - pDstR = pDstR - 8 + 16; - pDstG = pDstG - 8 + 16; - } -} -/*----------------------------------------------------------------------------*/ -// Convert Y to RGB -static void copyY(uint8 dstOfs) { - uint8 i; - uint8* pRDst = gMCUBufR + dstOfs; - uint8* pGDst = gMCUBufG + dstOfs; - uint8* pBDst = gMCUBufB + dstOfs; - int16* pSrc = gCoeffBuf; - - for (i = 64; i > 0; i--) { - uint8 c = (uint8)*pSrc++; - - *pRDst++ = c; - *pGDst++ = c; - *pBDst++ = c; - } -} -/*----------------------------------------------------------------------------*/ -// Cb convert to RGB and accumulate -static void convertCb(uint8 dstOfs) { - uint8 i; - uint8* pDstG = gMCUBufG + dstOfs; - uint8* pDstB = gMCUBufB + dstOfs; - int16* pSrc = gCoeffBuf; - - for (i = 64; i > 0; i--) { - uint8 cb = (uint8)*pSrc++; - int16 cbG, cbB; - - cbG = ((cb * 88U) >> 8U) - 44U; - *pDstG++ = subAndClamp(pDstG[0], cbG); - - cbB = (cb + ((cb * 198U) >> 8U)) - 227U; - *pDstB++ = addAndClamp(pDstB[0], cbB); - } -} -/*----------------------------------------------------------------------------*/ -// Cr convert to RGB and accumulate -static void convertCr(uint8 dstOfs) { - uint8 i; - uint8* pDstR = gMCUBufR + dstOfs; - uint8* pDstG = gMCUBufG + dstOfs; - int16* pSrc = gCoeffBuf; - - for (i = 64; i > 0; i--) { - uint8 cr = (uint8)*pSrc++; - int16 crR, crG; - - crR = (cr + ((cr * 103U) >> 8U)) - 179; - *pDstR++ = addAndClamp(pDstR[0], crR); - - crG = ((cr * 183U) >> 8U) - 91; - *pDstG++ = subAndClamp(pDstG[0], crG); - } -} -/*----------------------------------------------------------------------------*/ -static void transformBlock(uint8 mcuBlock) { - idctRows(); - idctCols(); - - switch (gScanType) { - case PJPG_GRAYSCALE: { - // MCU size: 1, 1 block per MCU - copyY(0); - break; - } - case PJPG_YH1V1: { - // MCU size: 8x8, 3 blocks per MCU - switch (mcuBlock) { - case 0: { - copyY(0); - break; - } - case 1: { - convertCb(0); - break; - } - case 2: { - convertCr(0); - break; - } - } - - break; - } - case PJPG_YH1V2: { - // MCU size: 8x16, 4 blocks per MCU - switch (mcuBlock) { - case 0: { - copyY(0); - break; - } - case 1: { - copyY(128); - break; - } - case 2: { - upsampleCbV(0, 0); - upsampleCbV(4 * 8, 128); - break; - } - case 3: { - upsampleCrV(0, 0); - upsampleCrV(4 * 8, 128); - break; - } - } - - break; - } - case PJPG_YH2V1: { - // MCU size: 16x8, 4 blocks per MCU - switch (mcuBlock) { - case 0: { - copyY(0); - break; - } - case 1: { - copyY(64); - break; - } - case 2: { - upsampleCbH(0, 0); - upsampleCbH(4, 64); - break; - } - case 3: { - upsampleCrH(0, 0); - upsampleCrH(4, 64); - break; - } - } - - break; - } - case PJPG_YH2V2: { - // MCU size: 16x16, 6 blocks per MCU - switch (mcuBlock) { - case 0: { - copyY(0); - break; - } - case 1: { - copyY(64); - break; - } - case 2: { - copyY(128); - break; - } - case 3: { - copyY(192); - break; - } - case 4: { - upsampleCb(0, 0); - upsampleCb(4, 64); - upsampleCb(4 * 8, 128); - upsampleCb(4 + 4 * 8, 192); - break; - } - case 5: { - upsampleCr(0, 0); - upsampleCr(4, 64); - upsampleCr(4 * 8, 128); - upsampleCr(4 + 4 * 8, 192); - break; - } - } - - break; - } - } -} -//------------------------------------------------------------------------------ -static void transformBlockReduce(uint8 mcuBlock) { - uint8 c = clamp(PJPG_DESCALE(gCoeffBuf[0]) + 128); - int16 cbG, cbB, crR, crG; - - switch (gScanType) { - case PJPG_GRAYSCALE: { - // MCU size: 1, 1 block per MCU - gMCUBufR[0] = c; - break; - } - case PJPG_YH1V1: { - // MCU size: 8x8, 3 blocks per MCU - switch (mcuBlock) { - case 0: { - gMCUBufR[0] = c; - gMCUBufG[0] = c; - gMCUBufB[0] = c; - break; - } - case 1: { - cbG = ((c * 88U) >> 8U) - 44U; - gMCUBufG[0] = subAndClamp(gMCUBufG[0], cbG); - - cbB = (c + ((c * 198U) >> 8U)) - 227U; - gMCUBufB[0] = addAndClamp(gMCUBufB[0], cbB); - break; - } - case 2: { - crR = (c + ((c * 103U) >> 8U)) - 179; - gMCUBufR[0] = addAndClamp(gMCUBufR[0], crR); - - crG = ((c * 183U) >> 8U) - 91; - gMCUBufG[0] = subAndClamp(gMCUBufG[0], crG); - break; - } - } - - break; - } - case PJPG_YH1V2: { - // MCU size: 8x16, 4 blocks per MCU - switch (mcuBlock) { - case 0: { - gMCUBufR[0] = c; - gMCUBufG[0] = c; - gMCUBufB[0] = c; - break; - } - case 1: { - gMCUBufR[128] = c; - gMCUBufG[128] = c; - gMCUBufB[128] = c; - break; - } - case 2: { - cbG = ((c * 88U) >> 8U) - 44U; - gMCUBufG[0] = subAndClamp(gMCUBufG[0], cbG); - gMCUBufG[128] = subAndClamp(gMCUBufG[128], cbG); - - cbB = (c + ((c * 198U) >> 8U)) - 227U; - gMCUBufB[0] = addAndClamp(gMCUBufB[0], cbB); - gMCUBufB[128] = addAndClamp(gMCUBufB[128], cbB); - - break; - } - case 3: { - crR = (c + ((c * 103U) >> 8U)) - 179; - gMCUBufR[0] = addAndClamp(gMCUBufR[0], crR); - gMCUBufR[128] = addAndClamp(gMCUBufR[128], crR); - - crG = ((c * 183U) >> 8U) - 91; - gMCUBufG[0] = subAndClamp(gMCUBufG[0], crG); - gMCUBufG[128] = subAndClamp(gMCUBufG[128], crG); - - break; - } - } - break; - } - case PJPG_YH2V1: { - // MCU size: 16x8, 4 blocks per MCU - switch (mcuBlock) { - case 0: { - gMCUBufR[0] = c; - gMCUBufG[0] = c; - gMCUBufB[0] = c; - break; - } - case 1: { - gMCUBufR[64] = c; - gMCUBufG[64] = c; - gMCUBufB[64] = c; - break; - } - case 2: { - cbG = ((c * 88U) >> 8U) - 44U; - gMCUBufG[0] = subAndClamp(gMCUBufG[0], cbG); - gMCUBufG[64] = subAndClamp(gMCUBufG[64], cbG); - - cbB = (c + ((c * 198U) >> 8U)) - 227U; - gMCUBufB[0] = addAndClamp(gMCUBufB[0], cbB); - gMCUBufB[64] = addAndClamp(gMCUBufB[64], cbB); - - break; - } - case 3: { - crR = (c + ((c * 103U) >> 8U)) - 179; - gMCUBufR[0] = addAndClamp(gMCUBufR[0], crR); - gMCUBufR[64] = addAndClamp(gMCUBufR[64], crR); - - crG = ((c * 183U) >> 8U) - 91; - gMCUBufG[0] = subAndClamp(gMCUBufG[0], crG); - gMCUBufG[64] = subAndClamp(gMCUBufG[64], crG); - - break; - } - } - break; - } - case PJPG_YH2V2: { - // MCU size: 16x16, 6 blocks per MCU - switch (mcuBlock) { - case 0: { - gMCUBufR[0] = c; - gMCUBufG[0] = c; - gMCUBufB[0] = c; - break; - } - case 1: { - gMCUBufR[64] = c; - gMCUBufG[64] = c; - gMCUBufB[64] = c; - break; - } - case 2: { - gMCUBufR[128] = c; - gMCUBufG[128] = c; - gMCUBufB[128] = c; - break; - } - case 3: { - gMCUBufR[192] = c; - gMCUBufG[192] = c; - gMCUBufB[192] = c; - break; - } - case 4: { - cbG = ((c * 88U) >> 8U) - 44U; - gMCUBufG[0] = subAndClamp(gMCUBufG[0], cbG); - gMCUBufG[64] = subAndClamp(gMCUBufG[64], cbG); - gMCUBufG[128] = subAndClamp(gMCUBufG[128], cbG); - gMCUBufG[192] = subAndClamp(gMCUBufG[192], cbG); - - cbB = (c + ((c * 198U) >> 8U)) - 227U; - gMCUBufB[0] = addAndClamp(gMCUBufB[0], cbB); - gMCUBufB[64] = addAndClamp(gMCUBufB[64], cbB); - gMCUBufB[128] = addAndClamp(gMCUBufB[128], cbB); - gMCUBufB[192] = addAndClamp(gMCUBufB[192], cbB); - - break; - } - case 5: { - crR = (c + ((c * 103U) >> 8U)) - 179; - gMCUBufR[0] = addAndClamp(gMCUBufR[0], crR); - gMCUBufR[64] = addAndClamp(gMCUBufR[64], crR); - gMCUBufR[128] = addAndClamp(gMCUBufR[128], crR); - gMCUBufR[192] = addAndClamp(gMCUBufR[192], crR); - - crG = ((c * 183U) >> 8U) - 91; - gMCUBufG[0] = subAndClamp(gMCUBufG[0], crG); - gMCUBufG[64] = subAndClamp(gMCUBufG[64], crG); - gMCUBufG[128] = subAndClamp(gMCUBufG[128], crG); - gMCUBufG[192] = subAndClamp(gMCUBufG[192], crG); - - break; - } - } - break; - } - } -} -//------------------------------------------------------------------------------ -static uint8 decodeNextMCU(void) { - uint8 status; - uint8 mcuBlock; - - if (gRestartInterval) { - if (gRestartsLeft == 0) { - status = processRestart(); - if (status) return status; - } - gRestartsLeft--; - } - - for (mcuBlock = 0; mcuBlock < gMaxBlocksPerMCU; mcuBlock++) { - uint8 componentID = gMCUOrg[mcuBlock]; - uint8 compQuant = gCompQuant[componentID]; - uint8 compDCTab = gCompDCTab[componentID]; - uint8 numExtraBits, compACTab, k; - const int16* pQ = compQuant ? gQuant1 : gQuant0; - uint16 r, dc; - - uint8 s = huffDecode(compDCTab ? &gHuffTab1 : &gHuffTab0, compDCTab ? gHuffVal1 : gHuffVal0); - - r = 0; - numExtraBits = s & 0xF; - if (numExtraBits) r = getBits2(numExtraBits); - dc = huffExtend(r, s); - - dc = dc + gLastDC[componentID]; - gLastDC[componentID] = dc; - - gCoeffBuf[0] = dc * pQ[0]; - - compACTab = gCompACTab[componentID]; - - if (gReduce) { - // Decode, but throw out the AC coefficients in reduce mode. - for (k = 1; k < 64; k++) { - s = huffDecode(compACTab ? &gHuffTab3 : &gHuffTab2, compACTab ? gHuffVal3 : gHuffVal2); - - numExtraBits = s & 0xF; - if (numExtraBits) getBits2(numExtraBits); - - r = s >> 4; - s &= 15; - - if (s) { - if (r) { - if ((k + r) > 63) return PJPG_DECODE_ERROR; - - k = (uint8)(k + r); - } - } else { - if (r == 15) { - if ((k + 16) > 64) return PJPG_DECODE_ERROR; - - k += (16 - 1); // - 1 because the loop counter is k - } else - break; - } - } - - transformBlockReduce(mcuBlock); - } else { - // Decode and dequantize AC coefficients - for (k = 1; k < 64; k++) { - uint16 extraBits; - - s = huffDecode(compACTab ? &gHuffTab3 : &gHuffTab2, compACTab ? gHuffVal3 : gHuffVal2); - - extraBits = 0; - numExtraBits = s & 0xF; - if (numExtraBits) extraBits = getBits2(numExtraBits); - - r = s >> 4; - s &= 15; - - if (s) { - int16 ac; - - if (r) { - if ((k + r) > 63) return PJPG_DECODE_ERROR; - - while (r) { - gCoeffBuf[ZAG[k++]] = 0; - r--; - } - } - - ac = huffExtend(extraBits, s); - - gCoeffBuf[ZAG[k]] = ac * pQ[k]; - } else { - if (r == 15) { - if ((k + 16) > 64) return PJPG_DECODE_ERROR; - - for (r = 16; r > 0; r--) gCoeffBuf[ZAG[k++]] = 0; - - k--; // - 1 because the loop counter is k - } else - break; - } - } - - while (k < 64) gCoeffBuf[ZAG[k++]] = 0; - - transformBlock(mcuBlock); - } - } - - return 0; -} -//------------------------------------------------------------------------------ -unsigned char pjpeg_decode_mcu(void) { - uint8 status; - - if (gCallbackStatus) return gCallbackStatus; - - if ((!gNumMCUSRemainingX) && (!gNumMCUSRemainingY)) return PJPG_NO_MORE_BLOCKS; - - status = decodeNextMCU(); - if ((status) || (gCallbackStatus)) return gCallbackStatus ? gCallbackStatus : status; - - gNumMCUSRemainingX--; - if (!gNumMCUSRemainingX) { - gNumMCUSRemainingY--; - if (gNumMCUSRemainingY > 0) gNumMCUSRemainingX = gMaxMCUSPerRow; - } - - return 0; -} -//------------------------------------------------------------------------------ -unsigned char pjpeg_decode_init(pjpeg_image_info_t* pInfo, pjpeg_need_bytes_callback_t pNeed_bytes_callback, - void* pCallback_data, unsigned char reduce) { - uint8 status; - - pInfo->m_width = 0; - pInfo->m_height = 0; - pInfo->m_comps = 0; - pInfo->m_MCUSPerRow = 0; - pInfo->m_MCUSPerCol = 0; - pInfo->m_scanType = PJPG_GRAYSCALE; - pInfo->m_MCUWidth = 0; - pInfo->m_MCUHeight = 0; - pInfo->m_pMCUBufR = (unsigned char*)0; - pInfo->m_pMCUBufG = (unsigned char*)0; - pInfo->m_pMCUBufB = (unsigned char*)0; - - g_pNeedBytesCallback = pNeed_bytes_callback; - g_pCallback_data = pCallback_data; - gCallbackStatus = 0; - gReduce = reduce; - - status = init(); - if ((status) || (gCallbackStatus)) return gCallbackStatus ? gCallbackStatus : status; - - status = locateSOFMarker(); - if ((status) || (gCallbackStatus)) return gCallbackStatus ? gCallbackStatus : status; - - status = initFrame(); - if ((status) || (gCallbackStatus)) return gCallbackStatus ? gCallbackStatus : status; - - status = initScan(); - if ((status) || (gCallbackStatus)) return gCallbackStatus ? gCallbackStatus : status; - - pInfo->m_width = gImageXSize; - pInfo->m_height = gImageYSize; - pInfo->m_comps = gCompsInFrame; - pInfo->m_scanType = gScanType; - pInfo->m_MCUSPerRow = gMaxMCUSPerRow; - pInfo->m_MCUSPerCol = gMaxMCUSPerCol; - pInfo->m_MCUWidth = gMaxMCUXSize; - pInfo->m_MCUHeight = gMaxMCUYSize; - pInfo->m_pMCUBufR = gMCUBufR; - pInfo->m_pMCUBufG = gMCUBufG; - pInfo->m_pMCUBufB = gMCUBufB; - - return 0; -} diff --git a/lib/picojpeg/picojpeg.h b/lib/picojpeg/picojpeg.h deleted file mode 100644 index 11345fb71..000000000 --- a/lib/picojpeg/picojpeg.h +++ /dev/null @@ -1,124 +0,0 @@ -//------------------------------------------------------------------------------ -// picojpeg - Public domain, Rich Geldreich -//------------------------------------------------------------------------------ -#ifndef PICOJPEG_H -#define PICOJPEG_H - -#ifdef __cplusplus -extern "C" { -#endif - -// Error codes -enum { - PJPG_NO_MORE_BLOCKS = 1, - PJPG_BAD_DHT_COUNTS, - PJPG_BAD_DHT_INDEX, - PJPG_BAD_DHT_MARKER, - PJPG_BAD_DQT_MARKER, - PJPG_BAD_DQT_TABLE, - PJPG_BAD_PRECISION, - PJPG_BAD_HEIGHT, - PJPG_BAD_WIDTH, - PJPG_TOO_MANY_COMPONENTS, - PJPG_BAD_SOF_LENGTH, - PJPG_BAD_VARIABLE_MARKER, - PJPG_BAD_DRI_LENGTH, - PJPG_BAD_SOS_LENGTH, - PJPG_BAD_SOS_COMP_ID, - PJPG_W_EXTRA_BYTES_BEFORE_MARKER, - PJPG_NO_ARITHMITIC_SUPPORT, - PJPG_UNEXPECTED_MARKER, - PJPG_NOT_JPEG, - PJPG_UNSUPPORTED_MARKER, - PJPG_BAD_DQT_LENGTH, - PJPG_TOO_MANY_BLOCKS, - PJPG_UNDEFINED_QUANT_TABLE, - PJPG_UNDEFINED_HUFF_TABLE, - PJPG_NOT_SINGLE_SCAN, - PJPG_UNSUPPORTED_COLORSPACE, - PJPG_UNSUPPORTED_SAMP_FACTORS, - PJPG_DECODE_ERROR, - PJPG_BAD_RESTART_MARKER, - PJPG_ASSERTION_ERROR, - PJPG_BAD_SOS_SPECTRAL, - PJPG_BAD_SOS_SUCCESSIVE, - PJPG_STREAM_READ_ERROR, - PJPG_NOTENOUGHMEM, - PJPG_UNSUPPORTED_COMP_IDENT, - PJPG_UNSUPPORTED_QUANT_TABLE, - PJPG_UNSUPPORTED_MODE, // picojpeg doesn't support progressive JPEG's -}; - -// Scan types -typedef enum { PJPG_GRAYSCALE, PJPG_YH1V1, PJPG_YH2V1, PJPG_YH1V2, PJPG_YH2V2 } pjpeg_scan_type_t; - -typedef struct { - // Image resolution - int m_width; - int m_height; - - // Number of components (1 or 3) - int m_comps; - - // Total number of minimum coded units (MCU's) per row/col. - int m_MCUSPerRow; - int m_MCUSPerCol; - - // Scan type - pjpeg_scan_type_t m_scanType; - - // MCU width/height in pixels (each is either 8 or 16 depending on the scan type) - int m_MCUWidth; - int m_MCUHeight; - - // m_pMCUBufR, m_pMCUBufG, and m_pMCUBufB are pointers to internal MCU Y or RGB pixel component buffers. - // Each time pjpegDecodeMCU() is called successfully these buffers will be filled with 8x8 pixel blocks of Y or RGB - // pixels. Each MCU consists of (m_MCUWidth/8)*(m_MCUHeight/8) Y/RGB blocks: 1 for greyscale/no subsampling, 2 for - // H1V2/H2V1, or 4 blocks for H2V2 sampling factors. Each block is a contiguous array of 64 (8x8) bytes of a single - // component: either Y for grayscale images, or R, G or B components for color images. - // - // The 8x8 pixel blocks are organized in these byte arrays like this: - // - // PJPG_GRAYSCALE: Each MCU is decoded to a single block of 8x8 grayscale pixels. - // Only the values in m_pMCUBufR are valid. Each 8 bytes is a row of pixels (raster order: left to right, top to - // bottom) from the 8x8 block. - // - // PJPG_H1V1: Each MCU contains is decoded to a single block of 8x8 RGB pixels. - // - // PJPG_YH2V1: Each MCU is decoded to 2 blocks, or 16x8 pixels. - // The 2 RGB blocks are at byte offsets: 0, 64 - // - // PJPG_YH1V2: Each MCU is decoded to 2 blocks, or 8x16 pixels. - // The 2 RGB blocks are at byte offsets: 0, - // 128 - // - // PJPG_YH2V2: Each MCU is decoded to 4 blocks, or 16x16 pixels. - // The 2x2 block array is organized at byte offsets: 0, 64, - // 128, 192 - // - // It is up to the caller to copy or blit these pixels from these buffers into the destination bitmap. - unsigned char* m_pMCUBufR; - unsigned char* m_pMCUBufG; - unsigned char* m_pMCUBufB; -} pjpeg_image_info_t; - -typedef unsigned char (*pjpeg_need_bytes_callback_t)(unsigned char* pBuf, unsigned char buf_size, - unsigned char* pBytes_actually_read, void* pCallback_data); - -// Initializes the decompressor. Returns 0 on success, or one of the above error codes on failure. -// pNeed_bytes_callback will be called to fill the decompressor's internal input buffer. -// If reduce is 1, only the first pixel of each block will be decoded. This mode is much faster because it skips the AC -// dequantization, IDCT and chroma upsampling of every image pixel. Not thread safe. -unsigned char pjpeg_decode_init(pjpeg_image_info_t* pInfo, pjpeg_need_bytes_callback_t pNeed_bytes_callback, - void* pCallback_data, unsigned char reduce); - -// Decompresses the file's next MCU. Returns 0 on success, PJPG_NO_MORE_BLOCKS if no more blocks are available, or an -// error code. Must be called a total of m_MCUSPerRow*m_MCUSPerCol times to completely decompress the image. Not thread -// safe. -unsigned char pjpeg_decode_mcu(void); - -#ifdef __cplusplus -} -#endif - -#endif // PICOJPEG_H