From 1398aeb1edb3a2819e90d52bd96f339c8e073491 Mon Sep 17 00:00:00 2001 From: Zach Nelson Date: Tue, 7 Apr 2026 22:20:50 -0500 Subject: [PATCH] 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" "$@"