From 773238fdf50971a2ee6b15b2f5b1ce891cdaf627 Mon Sep 17 00:00:00 2001 From: martin brook Date: Wed, 8 Apr 2026 09:22:25 +0100 Subject: [PATCH 01/11] 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 10696034..901b652a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -51,7 +51,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 @@ -63,7 +62,7 @@ lib_deps = bblanchon/ArduinoJson @ 7.4.2 QRCode=symlink://lib/QRCode 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 4dd5a554..00000000 --- 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 db232e04f32463d63ac6cd264a4e4b97abf49df0 Mon Sep 17 00:00:00 2001 From: CSCMe Date: Wed, 8 Apr 2026 05:25:08 +0200 Subject: [PATCH 02/11] refactor: logPrintf and predefined log level strings (#1546) * 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 * 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 --- 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 | 46 +++++++++++++---------------------------- lib/Logging/Logging.h | 6 +++--- 2 files changed, 17 insertions(+), 35 deletions(-) diff --git a/lib/Logging/Logging.cpp b/lib/Logging/Logging.cpp index 2aec1081..8a398389 100644 --- a/lib/Logging/Logging.cpp +++ b/lib/Logging/Logging.cpp @@ -40,44 +40,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(); - char wallClock[12]; - HalClock::formatLogTime(wallClock, sizeof(wallClock)); - int len; - if (wallClock[0] != '\0') { - len = snprintf(c, sizeof(buf), "[%lu %s] ", ms, wallClock); - } else { - len = snprintf(c, sizeof(buf), "[%lu] ", ms); - } + int len = snprintf(c, sizeof(buf), "[%lu] [%s] [%s] ", ms, level, origin); + // error 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 47e8eb7d..8784a341 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 86d636bf6a14e60974380792aa2afb74ebd37ec0 Mon Sep 17 00:00:00 2001 From: Zach Nelson Date: Tue, 7 Apr 2026 22:20:50 -0500 Subject: [PATCH 03/11] fix: Use differential rounding for consistent inter-glyph spacing (#1413) **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. --- 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 | 18 +- lib/EpdFont/EpdFontData.h | 10 +- lib/GfxRenderer/GfxRenderer.cpp | 50 ++- .../DifferentialRoundingTest.cpp | 381 ++++++++++++++++++ test/run_differential_rounding_test.sh | 30 ++ 5 files changed, 460 insertions(+), 29 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 9aedbdb2..d3290264 100644 --- a/lib/EpdFont/EpdFont.cpp +++ b/lib/EpdFont/EpdFont.cpp @@ -15,11 +15,12 @@ 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 lastBaseLeft = 0; int lastBaseWidth = 0; int lastBaseTop = 0; + int lastBaseAdvanceFP = 0; // 12.4 fixed-point + int32_t prevAdvanceFP = 0; // 12.4 fixed-point: prev glyph's advance + next kern for snap uint32_t cp; uint32_t prevCp = 0; while ((cp = utf8NextCodepoint(reinterpret_cast(&string)))) { @@ -31,20 +32,21 @@ 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; } const int raiseBy = isCombining ? combiningMark::raiseAboveBase(glyph->top, glyph->height, lastBaseTop) : 0; 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 ? combiningMark::centerOver(lastBaseX, lastBaseLeft, lastBaseWidth, glyph->left, glyph->width) - : cursorXPixels; + 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); @@ -53,11 +55,11 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star *maxY = std::max(*maxY, glyphBaseY + glyph->top); if (!isCombining) { - lastBaseX = cursorXPixels; lastBaseLeft = glyph->left; lastBaseWidth = glyph->width; + 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 8ea5eaed..380c5733 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 bce22e08..a2c33855 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -751,11 +751,13 @@ 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 lastBaseLeft = 0; int lastBaseWidth = 0; int lastBaseTop = 0; + int lastBaseAdvanceFP = 0; // 12.4 fixed-point + int lastBaseAdvanceFP = 0; // 12.4 fixed-point + 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') { @@ -788,20 +790,24 @@ 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); lastBaseLeft = glyph ? glyph->left : 0; lastBaseWidth = glyph ? glyph->width : 0; lastBaseTop = glyph ? glyph->top : 0; + lastBaseAdvanceFP = glyph ? glyph->advanceX : 0; + prevAdvanceFP = lastBaseAdvanceFP; renderCharImpl(*this, renderMode, font, cp, lastBaseX, yPos, black, style); - if (glyph) { - xPosFP += glyph->advanceX; // 12.4 fixed-point advance - } prevCp = cp; } } @@ -1744,21 +1750,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 { @@ -1805,11 +1818,12 @@ 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 lastBaseLeft = 0; int lastBaseWidth = 0; int lastBaseTop = 0; + int lastBaseAdvanceFP = 0; // 12.4 fixed-point + int32_t prevAdvanceFP = 0; // 12.4 fixed-point: prev glyph's advance + next kern for snap uint32_t cp; uint32_t prevCp = 0; @@ -1826,21 +1840,23 @@ 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); lastBaseLeft = glyph ? glyph->left : 0; lastBaseWidth = glyph ? glyph->width : 0; lastBaseTop = glyph ? glyph->top : 0; + lastBaseAdvanceFP = glyph ? glyph->advanceX : 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 00000000..fd454839 --- /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 00000000..dd7aa5c7 --- /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 94c9f90e9fd465d4011c32835cba057149951392 Mon Sep 17 00:00:00 2001 From: Mirus Date: Tue, 7 Apr 2026 17:21:32 +0300 Subject: [PATCH 04/11] 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 5145157d..28f9b59b 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 27e246cb22cb36dabc9a0bf6d6d58e7cf8908dca 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 05/11] fix: correct Russian auto-turn translations (#1566) * **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 | Before| After | |--------|--------| | image| image| --- 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 | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index f92dad41..f3c20558 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -286,7 +286,7 @@ STR_UPLOAD_PROMPT: "Отправить текущую позицию?" STR_UPLOAD_SUCCESS: "Прогресс отправлен!" STR_PULL_SUCCESS: "Удалённый прогресс применён!" STR_SYNC_FAILED_MSG: "Ошибка синхронизации" -STR_SECTION_PREFIX: "Раздел" +STR_SECTION_PREFIX: "Раздел " STR_UPLOAD: "Отправить" STR_BOOK_S_STYLE: "Стиль книги" STR_EMBEDDED_STYLE: "Встроенный стиль" @@ -294,6 +294,7 @@ STR_OPDS_SERVER_URL: "URL OPDS сервера" STR_SCREENSHOT_BUTTON: "Сделать снимок экрана" STR_AUTO_TURN_ENABLED: "Автоперелистывание: " STR_AUTO_TURN_PAGES_PER_MIN: "Автоперелистывание (стр./мин)" +<<<<<<< HEAD STR_REGISTER: "Регистрация" STR_REGISTERING: "Регистрация..." STR_REGISTER_SUCCESS: "Аккаунт создан!" @@ -451,3 +452,5 @@ STR_WEATHER_DESC_THUNDERSTORM: "Гроза" STR_WEATHER_DESC_THUNDERSTORM_HAIL: "Гроза с градом" STR_WEATHER_DESC_THUNDERSTORM_HEAVY_HAIL: "Гроза с сильным градом" STR_WEATHER_DESC_UNKNOWN: "Неизвестно" +======= +>>>>>>> fa3c7d96 (fix: correct Russian auto-turn translations (#1566)) From 93f285d2969f24dc4ea1ce5e13ff9f752447f2d5 Mon Sep 17 00:00:00 2001 From: Zach Nelson Date: Tue, 7 Apr 2026 09:13:21 -0500 Subject: [PATCH 06/11] refactor: Use default member initializers for JpegContext and PngContext (#1435) **What is the goal of this PR?** Replace verbose constructor initializer lists with in-class default member initializers in JpegContext and PngContext --- 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 | 55 +++++------------- .../converters/PngToFramebufferConverter.cpp | 58 ++++++------------- 2 files changed, 33 insertions(+), 80 deletions(-) diff --git a/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp b/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp index 0968fb5d..d05c3d34 100644 --- a/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp +++ b/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp @@ -20,60 +20,37 @@ 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; + bool caching{false}; // See PngContext for the rationale: monochromeOutput requests a 1-bit Atkinson dither // emitting only 0/3 so the BW DirectPixelWriter (`pixelValue < 3` rule) maps cleanly. - int oneBitDitherRow; - Atkinson1BitDitherer* atkinson1BitDitherer; + int oneBitDitherRow{-1}; + Atkinson1BitDitherer* atkinson1BitDitherer{nullptr}; #ifdef ENABLE_IMAGE_DITHERING_EXTENSION - int currentDitherRow; - AtkinsonDitherer* atkinsonDitherer; - DiffusedBayerDitherer* diffusedBayerDitherer; + int currentDitherRow{-1}; + AtkinsonDitherer* atkinsonDitherer{nullptr}; + DiffusedBayerDitherer* diffusedBayerDitherer{nullptr}; #endif - 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), - oneBitDitherRow(-1), - atkinson1BitDitherer(nullptr) -#ifdef ENABLE_IMAGE_DITHERING_EXTENSION - , - currentDitherRow(-1), - atkinsonDitherer(nullptr), - diffusedBayerDitherer(nullptr) -#endif - { - } - ~JpegContext() { delete atkinson1BitDitherer; #ifdef ENABLE_IMAGE_DITHERING_EXTENSION diff --git a/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp b/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp index 53bbbea1..014cdceb 100644 --- a/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp +++ b/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp @@ -20,62 +20,38 @@ 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; + uint8_t* grayLineBuffer{nullptr}; // When the caller requests monochrome output (RenderConfig::monochromeOutput), // we run a proper 1-bit Atkinson dither (matching PngToBmpConverter's BW path) // and emit only values 0 or 3, which round-trip cleanly through the BW writer's // `pixelValue < 3` rule. The 4-level dither path collapses mid-grays to solid // black under that rule. - int oneBitDitherRow; - Atkinson1BitDitherer* atkinson1BitDitherer; + int oneBitDitherRow{-1}; + Atkinson1BitDitherer* atkinson1BitDitherer{nullptr}; #ifdef ENABLE_IMAGE_DITHERING_EXTENSION - int currentDitherRow; - AtkinsonDitherer* atkinsonDitherer; - DiffusedBayerDitherer* diffusedBayerDitherer; + int currentDitherRow{-1}; + AtkinsonDitherer* atkinsonDitherer{nullptr}; + DiffusedBayerDitherer* diffusedBayerDitherer{nullptr}; #endif - 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), - oneBitDitherRow(-1), - atkinson1BitDitherer(nullptr) -#ifdef ENABLE_IMAGE_DITHERING_EXTENSION - , - currentDitherRow(-1), - atkinsonDitherer(nullptr), - diffusedBayerDitherer(nullptr) -#endif - { - } - ~PngContext() { delete atkinson1BitDitherer; #ifdef ENABLE_IMAGE_DITHERING_EXTENSION From 93659502f7cbe028087bc83d5729b368653faacf Mon Sep 17 00:00:00 2001 From: jpirnay Date: Wed, 8 Apr 2026 10:57:51 +0200 Subject: [PATCH 07/11] Reintroducing wallclock --- lib/Logging/Logging.cpp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/Logging/Logging.cpp b/lib/Logging/Logging.cpp index 8a398389..2b9a4f5a 100644 --- a/lib/Logging/Logging.cpp +++ b/lib/Logging/Logging.cpp @@ -1,6 +1,7 @@ #include "Logging.h" #include +#include #include @@ -40,10 +41,17 @@ void logPrintf(const char* level, const char* origin, const char* format, ...) { va_start(args, format); char buf[MAX_ENTRY_LEN]; char* c = buf; - // add timestamp, level and origin + // add timestamp, wall clock, level and origin { unsigned long ms = millis(); - int len = snprintf(c, sizeof(buf), "[%lu] [%s] [%s] ", ms, level, origin); + char wallClock[12]; + HalClock::formatLogTime(wallClock, sizeof(wallClock)); + int len; + if (wallClock[0] != '\0') { + len = snprintf(c, sizeof(buf), "[%lu %s] [%s] [%s] ", ms, wallClock, level, origin); + } else { + len = snprintf(c, sizeof(buf), "[%lu] [%s] [%s] ", ms, level, origin); + } // error while writing => return if (len < 0) { va_end(args); From 0d0d7373d3a39bd2c34db29d27c10e0811fa2603 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Wed, 8 Apr 2026 10:58:06 +0200 Subject: [PATCH 08/11] Fix russian --- lib/I18n/translations/russian.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index f3c20558..2a74fa7e 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -294,7 +294,6 @@ STR_OPDS_SERVER_URL: "URL OPDS сервера" STR_SCREENSHOT_BUTTON: "Сделать снимок экрана" STR_AUTO_TURN_ENABLED: "Автоперелистывание: " STR_AUTO_TURN_PAGES_PER_MIN: "Автоперелистывание (стр./мин)" -<<<<<<< HEAD STR_REGISTER: "Регистрация" STR_REGISTERING: "Регистрация..." STR_REGISTER_SUCCESS: "Аккаунт создан!" @@ -452,5 +451,3 @@ STR_WEATHER_DESC_THUNDERSTORM: "Гроза" STR_WEATHER_DESC_THUNDERSTORM_HAIL: "Гроза с градом" STR_WEATHER_DESC_THUNDERSTORM_HEAVY_HAIL: "Гроза с сильным градом" STR_WEATHER_DESC_UNKNOWN: "Неизвестно" -======= ->>>>>>> fa3c7d96 (fix: correct Russian auto-turn translations (#1566)) From c5e0e0cb78ea160d7139823bd78bdeb526638f8e Mon Sep 17 00:00:00 2001 From: jpirnay Date: Wed, 8 Apr 2026 10:58:41 +0200 Subject: [PATCH 09/11] Fix merge conflict --- lib/GfxRenderer/GfxRenderer.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index a2c33855..4bcf1873 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -756,7 +756,6 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha int lastBaseWidth = 0; int lastBaseTop = 0; int lastBaseAdvanceFP = 0; // 12.4 fixed-point - int lastBaseAdvanceFP = 0; // 12.4 fixed-point int32_t prevAdvanceFP = 0; // 12.4 fixed-point: prev glyph's advance + next kern for snap // cannot draw a NULL / empty string From 4bc987297acf86ac3302e970011f93941ce9af87 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Wed, 8 Apr 2026 11:29:34 +0200 Subject: [PATCH 10/11] Review comments --- lib/GfxRenderer/GfxRenderer.cpp | 53 ++++++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 4bcf1873..5f5263a3 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -799,11 +799,21 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha } const EpdGlyph* glyph = font.getGlyph(cp, style); + if (!glyph) { + lastBaseX += fp4::toPixel(prevAdvanceFP); + prevCp = 0; + prevAdvanceFP = 0; + lastBaseLeft = 0; + lastBaseWidth = 0; + lastBaseTop = 0; + lastBaseAdvanceFP = 0; + continue; + } - lastBaseLeft = glyph ? glyph->left : 0; - lastBaseWidth = glyph ? glyph->width : 0; - lastBaseTop = glyph ? glyph->top : 0; - lastBaseAdvanceFP = glyph ? glyph->advanceX : 0; + lastBaseLeft = glyph->left; + lastBaseWidth = glyph->width; + lastBaseTop = glyph->top; + lastBaseAdvanceFP = glyph->advanceX; prevAdvanceFP = lastBaseAdvanceFP; renderCharImpl(*this, renderMode, font, cp, lastBaseX, yPos, black, style); @@ -1588,10 +1598,17 @@ void GfxRenderer::invertScreen() const { } } +void GfxRenderer::setNextDisplayRefreshMode(const HalDisplay::RefreshMode refreshMode) const { + useNextRefreshOverride = true; + nextRefreshOverride = refreshMode; +} + void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const { + const auto effectiveMode = useNextRefreshOverride ? nextRefreshOverride : refreshMode; + useNextRefreshOverride = false; auto elapsed = millis() - start_ms; LOG_DBG("GFX", "Time = %lu ms from clearScreen to displayBuffer", elapsed); - display.displayBuffer(refreshMode, fadingFix); + display.displayBuffer(effectiveMode, fadingFix); } std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth, @@ -1766,7 +1783,13 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFami } const EpdGlyph* glyph = font.getGlyph(cp, style); - prevAdvanceFP = glyph ? glyph->advanceX : 0; + if (!glyph) { + widthPx += fp4::toPixel(prevAdvanceFP); + prevCp = 0; + prevAdvanceFP = 0; + continue; + } + prevAdvanceFP = glyph->advanceX; prevCp = cp; } widthPx += fp4::toPixel(prevAdvanceFP); // final glyph's advance @@ -1848,11 +1871,21 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y } const EpdGlyph* glyph = font.getGlyph(cp, style); + if (!glyph) { + lastBaseY -= fp4::toPixel(prevAdvanceFP); + prevCp = 0; + prevAdvanceFP = 0; + lastBaseLeft = 0; + lastBaseWidth = 0; + lastBaseTop = 0; + lastBaseAdvanceFP = 0; + continue; + } - lastBaseLeft = glyph ? glyph->left : 0; - lastBaseWidth = glyph ? glyph->width : 0; - lastBaseTop = glyph ? glyph->top : 0; - lastBaseAdvanceFP = glyph ? glyph->advanceX : 0; + lastBaseLeft = glyph->left; + lastBaseWidth = glyph->width; + lastBaseTop = glyph->top; + lastBaseAdvanceFP = glyph->advanceX; prevAdvanceFP = lastBaseAdvanceFP; renderCharImpl(*this, renderMode, font, cp, x, lastBaseY, black, style); From 7a5242ad0308b55fc7a32489494080a900ac0fc7 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Wed, 8 Apr 2026 11:53:29 +0200 Subject: [PATCH 11/11] yaclf --- lib/EpdFont/EpdFont.cpp | 5 +++-- lib/Logging/Logging.cpp | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/EpdFont/EpdFont.cpp b/lib/EpdFont/EpdFont.cpp index d3290264..fd834fa9 100644 --- a/lib/EpdFont/EpdFont.cpp +++ b/lib/EpdFont/EpdFont.cpp @@ -45,8 +45,9 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star lastBaseX += fp4::toPixel(prevAdvanceFP + kernFP); } - const int glyphBaseX = isCombining ? combiningMark::centerOver(lastBaseX, lastBaseLeft, lastBaseWidth, glyph->left, glyph->width) - : 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); diff --git a/lib/Logging/Logging.cpp b/lib/Logging/Logging.cpp index 2b9a4f5a..256abb5c 100644 --- a/lib/Logging/Logging.cpp +++ b/lib/Logging/Logging.cpp @@ -1,8 +1,8 @@ #include "Logging.h" #include -#include +#include #include #define MAX_ENTRY_LEN 256