diff --git a/lib/EpdFont/EpdFont.cpp b/lib/EpdFont/EpdFont.cpp index 9aedbdb2..fd834fa9 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,22 @@ 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; + : lastBaseX; const int glyphBaseY = startY - raiseBy; *minX = std::min(*minX, glyphBaseX + glyph->left); @@ -53,11 +56,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/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 diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 039e5c74..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); @@ -1773,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 @@ -1855,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); diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index f92dad41..2a74fa7e 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: "Встроенный стиль" 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: "Автоперегортання увімк.: " diff --git a/lib/Logging/Logging.cpp b/lib/Logging/Logging.cpp index 2aec1081..256abb5c 100644 --- a/lib/Logging/Logging.cpp +++ b/lib/Logging/Logging.cpp @@ -2,6 +2,7 @@ #include +#include #include #define MAX_ENTRY_LEN 256 @@ -40,44 +41,33 @@ 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, wall clock, 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); + len = snprintf(c, sizeof(buf), "[%lu %s] [%s] [%s] ", ms, wallClock, level, origin); } else { - len = snprintf(c, sizeof(buf), "[%lu] ", ms); + 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 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) 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" "$@"