A fork of https://github.com/crosspoint-reader/crosspoint-reader
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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**_

authored by

Zach Nelson and committed by
GitHub
1398aeb1 6cd19f56

+452 -27
+7 -6
lib/EpdFont/EpdFont.cpp
··· 15 15 return; 16 16 } 17 17 18 - int32_t cursorXFP = fp4::fromPixel(startX); // 12.4 fixed-point accumulator 19 18 int lastBaseX = startX; 20 19 int lastBaseAdvanceFP = 0; // 12.4 fixed-point 21 20 int lastBaseTop = 0; 21 + int32_t prevAdvanceFP = 0; // 12.4 fixed-point: prev glyph's advance + next kern for snap 22 22 constexpr int MIN_COMBINING_GAP_PX = 1; 23 23 uint32_t cp; 24 24 uint32_t prevCp = 0; ··· 31 31 32 32 const EpdGlyph* glyph = getGlyph(cp); 33 33 if (!glyph) { 34 + lastBaseX += fp4::toPixel(prevAdvanceFP); // flush pending advance before resetting 34 35 prevCp = 0; 36 + prevAdvanceFP = 0; 35 37 continue; 36 38 } 37 39 ··· 44 46 } 45 47 46 48 if (!isCombining && prevCp != 0) { 47 - cursorXFP += getKerning(prevCp, cp); // 4.4 fixed-point kern 49 + const auto kernFP = getKerning(prevCp, cp); // 4.4 fixed-point kern 50 + lastBaseX += fp4::toPixel(prevAdvanceFP + kernFP); 48 51 } 49 52 50 - const int cursorXPixels = fp4::toPixel(cursorXFP); // snap 12.4 fixed-point to nearest pixel 51 - const int glyphBaseX = isCombining ? (lastBaseX + fp4::toPixel(lastBaseAdvanceFP / 2)) : cursorXPixels; 53 + const int glyphBaseX = isCombining ? (lastBaseX + fp4::toPixel(lastBaseAdvanceFP / 2)) : lastBaseX; 52 54 const int glyphBaseY = startY - raiseBy; 53 55 54 56 *minX = std::min(*minX, glyphBaseX + glyph->left); ··· 57 59 *maxY = std::max(*maxY, glyphBaseY + glyph->top); 58 60 59 61 if (!isCombining) { 60 - lastBaseX = cursorXPixels; 61 62 lastBaseAdvanceFP = glyph->advanceX; // 12.4 fixed-point 62 63 lastBaseTop = glyph->top; 63 - cursorXFP += glyph->advanceX; // 12.4 fixed-point advance 64 + prevAdvanceFP = lastBaseAdvanceFP; 64 65 prevCp = cp; 65 66 } 66 67 }
+6 -4
lib/EpdFont/EpdFontData.h
··· 7 7 /// Font metrics use "fixed-point 4" (4 fractional bits, i.e. 1/16-pixel 8 8 /// resolution). Both the 12.4 glyph advances (uint16_t) and the 4.4 kern 9 9 /// values (int8_t) share the same 4 fractional bits, so they can be freely 10 - /// added into a single int32_t accumulator during text layout. The 11 - /// accumulator is snapped to the nearest whole pixel only at render time, 12 - /// which avoids the per-character rounding errors that plagued integer-only 13 - /// layout. 10 + /// added before snapping to whole pixels. 11 + /// 12 + /// Rendering and measurement use "differential rounding": each glyph step 13 + /// (previous advance + current kern) is combined in fixed-point and snapped 14 + /// to a pixel as one unit. This guarantees identical character pairs always 15 + /// produce the same pixel spacing, regardless of position on the line. 14 16 /// 15 17 /// The helpers below eliminate the raw bit-shifts that would otherwise be 16 18 /// scattered across every layout / measurement call site.
+28 -17
lib/GfxRenderer/GfxRenderer.cpp
··· 215 215 void GfxRenderer::drawText(const int fontId, const int x, const int y, const char* text, const bool black, 216 216 const EpdFontFamily::Style style) const { 217 217 const int yPos = y + getFontAscenderSize(fontId); 218 - int32_t xPosFP = fp4::fromPixel(x); // 12.4 fixed-point accumulator 219 218 int lastBaseX = x; 220 219 int lastBaseAdvanceFP = 0; // 12.4 fixed-point 221 220 int lastBaseTop = 0; 221 + int32_t prevAdvanceFP = 0; // 12.4 fixed-point: prev glyph's advance + next kern for snap 222 222 223 223 // cannot draw a NULL / empty string 224 224 if (text == nullptr || *text == '\0') { ··· 258 258 } 259 259 260 260 cp = font.applyLigatures(cp, text, style); 261 - const int kernFP = (prevCp != 0) ? font.getKerning(prevCp, cp, style) : 0; // 4.4 fixed-point kern 262 - xPosFP += kernFP; 261 + 262 + // Differential rounding: snap (previous advance + current kern) as one unit so 263 + // identical character pairs always produce the same pixel step regardless of 264 + // where they fall on the line. 265 + if (prevCp != 0) { 266 + const auto kernFP = font.getKerning(prevCp, cp, style); // 4.4 fixed-point kern 267 + lastBaseX += fp4::toPixel(prevAdvanceFP + kernFP); // snap 12.4 fixed-point to nearest pixel 268 + } 263 269 264 - lastBaseX = fp4::toPixel(xPosFP); // snap 12.4 fixed-point to nearest pixel 265 270 const EpdGlyph* glyph = font.getGlyph(cp, style); 266 271 267 272 lastBaseAdvanceFP = glyph ? glyph->advanceX : 0; 268 273 lastBaseTop = glyph ? glyph->top : 0; 274 + prevAdvanceFP = lastBaseAdvanceFP; 269 275 270 276 renderCharImpl<TextRotation::None>(*this, renderMode, font, cp, lastBaseX, yPos, black, style); 271 - if (glyph) { 272 - xPosFP += glyph->advanceX; // 12.4 fixed-point advance 273 - } 274 277 prevCp = cp; 275 278 } 276 279 } ··· 1007 1010 1008 1011 uint32_t cp; 1009 1012 uint32_t prevCp = 0; 1010 - int32_t widthFP = 0; // 12.4 fixed-point accumulator 1013 + int widthPx = 0; 1014 + int32_t prevAdvanceFP = 0; // 12.4 fixed-point: prev glyph's advance + next kern for snap 1011 1015 const auto& font = fontIt->second; 1012 1016 while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) { 1013 1017 if (utf8IsCombiningMark(cp)) { 1014 1018 continue; 1015 1019 } 1016 1020 cp = font.applyLigatures(cp, text, style); 1021 + 1022 + // Differential rounding: snap (previous advance + current kern) together, 1023 + // matching drawText so measurement and rendering agree exactly. 1017 1024 if (prevCp != 0) { 1018 - widthFP += font.getKerning(prevCp, cp, style); // 4.4 fixed-point kern 1025 + const auto kernFP = font.getKerning(prevCp, cp, style); // 4.4 fixed-point kern 1026 + widthPx += fp4::toPixel(prevAdvanceFP + kernFP); // snap 12.4 fixed-point to nearest pixel 1019 1027 } 1028 + 1020 1029 const EpdGlyph* glyph = font.getGlyph(cp, style); 1021 - if (glyph) widthFP += glyph->advanceX; // 12.4 fixed-point advance 1030 + prevAdvanceFP = glyph ? glyph->advanceX : 0; 1022 1031 prevCp = cp; 1023 1032 } 1024 - return fp4::toPixel(widthFP); // snap 12.4 fixed-point to nearest pixel 1033 + widthPx += fp4::toPixel(prevAdvanceFP); // final glyph's advance 1034 + return widthPx; 1025 1035 } 1026 1036 1027 1037 int GfxRenderer::getFontAscenderSize(const int fontId) const { ··· 1068 1078 1069 1079 const auto& font = fontIt->second; 1070 1080 1071 - int32_t yPosFP = fp4::fromPixel(y); // 12.4 fixed-point accumulator 1072 1081 int lastBaseY = y; 1073 1082 int lastBaseAdvanceFP = 0; // 12.4 fixed-point 1074 1083 int lastBaseTop = 0; 1084 + int32_t prevAdvanceFP = 0; // 12.4 fixed-point: prev glyph's advance + next kern for snap 1075 1085 constexpr int MIN_COMBINING_GAP_PX = 1; 1076 1086 1077 1087 uint32_t cp; ··· 1094 1104 } 1095 1105 1096 1106 cp = font.applyLigatures(cp, text, style); 1107 + 1108 + // Differential rounding: snap (previous advance + current kern) as one unit, 1109 + // subtracting for the rotated coordinate direction. 1097 1110 if (prevCp != 0) { 1098 - yPosFP -= font.getKerning(prevCp, cp, style); // 4.4 fixed-point kern (subtract for rotated) 1111 + const auto kernFP = font.getKerning(prevCp, cp, style); // 4.4 fixed-point kern 1112 + lastBaseY -= fp4::toPixel(prevAdvanceFP + kernFP); // snap 12.4 fixed-point to nearest pixel 1099 1113 } 1100 1114 1101 - lastBaseY = fp4::toPixel(yPosFP); // snap 12.4 fixed-point to nearest pixel 1102 1115 const EpdGlyph* glyph = font.getGlyph(cp, style); 1103 1116 1104 1117 lastBaseAdvanceFP = glyph ? glyph->advanceX : 0; // 12.4 fixed-point 1105 1118 lastBaseTop = glyph ? glyph->top : 0; 1119 + prevAdvanceFP = lastBaseAdvanceFP; 1106 1120 1107 1121 renderCharImpl<TextRotation::Rotated90CW>(*this, renderMode, font, cp, x, lastBaseY, black, style); 1108 - if (glyph) { 1109 - yPosFP -= glyph->advanceX; // 12.4 fixed-point advance (subtract for rotated) 1110 - } 1111 1122 prevCp = cp; 1112 1123 } 1113 1124 }
+381
test/differential_rounding/DifferentialRoundingTest.cpp
··· 1 + #include <cassert> 2 + #include <cmath> 3 + #include <cstdio> 4 + #include <cstdlib> 5 + 6 + #include "lib/EpdFont/EpdFont.h" 7 + #include "lib/EpdFont/EpdFontData.h" 8 + 9 + static int testsPassed = 0; 10 + static int testsFailed = 0; 11 + 12 + #define ASSERT_EQ(a, b) \ 13 + do { \ 14 + if ((a) != (b)) { \ 15 + fprintf(stderr, " FAIL: %s:%d: %s == %d, expected %d\n", __FILE__, __LINE__, #a, (a), (b)); \ 16 + testsFailed++; \ 17 + return; \ 18 + } \ 19 + } while (0) 20 + 21 + #define ASSERT_TRUE(cond) \ 22 + do { \ 23 + if (!(cond)) { \ 24 + fprintf(stderr, " FAIL: %s:%d: %s\n", __FILE__, __LINE__, #cond); \ 25 + testsFailed++; \ 26 + return; \ 27 + } \ 28 + } while (0) 29 + 30 + #define PASS() testsPassed++ 31 + 32 + // ============================================================================ 33 + // Synthetic test font 34 + // 35 + // Glyphs: 'T' (0x54), 'a' (0x61), 'o' (0x6F), 'x' (0x78) 36 + // - 'x' advance is 136 FP (8.5px) -- frac = 8, exactly at the rounding 37 + // boundary where absolute vs differential snapping diverges for "oo". 38 + // - No U+FFFD replacement glyph, so unknown codepoints trigger the 39 + // null-glyph path in getTextBounds. 40 + // 41 + // Kern pairs (4.4 fixed-point): 42 + // T->a: -5 (-0.3125px) T->o: -7 (-0.4375px) 43 + // o->a: -2 (-0.125px) o->o: -3 (-0.1875px) 44 + // ============================================================================ 45 + 46 + // clang-format off 47 + static const EpdGlyph kGlyphs[] = { 48 + // idx width height advanceX left top dataLength dataOffset 49 + /* 0 'T' */ { 8, 12, 137, 0, 12, 0, 0 }, 50 + /* 1 'a' */ { 7, 8, 130, 0, 8, 0, 0 }, 51 + /* 2 'o' */ { 8, 8, 145, 0, 8, 0, 0 }, 52 + /* 3 'x' */ { 7, 8, 136, 0, 8, 0, 0 }, 53 + }; 54 + 55 + static const EpdUnicodeInterval kIntervals[] = { 56 + { 0x54, 0x54, 0 }, // 'T' -> glyph[0] 57 + { 0x61, 0x61, 1 }, // 'a' -> glyph[1] 58 + { 0x6F, 0x6F, 2 }, // 'o' -> glyph[2] 59 + { 0x78, 0x78, 3 }, // 'x' -> glyph[3] 60 + }; 61 + 62 + static const EpdKernClassEntry kKernLeft[] = { 63 + { 0x54, 1 }, // 'T' -> left class 1 64 + { 0x6F, 2 }, // 'o' -> left class 2 65 + }; 66 + 67 + static const EpdKernClassEntry kKernRight[] = { 68 + { 0x61, 1 }, // 'a' -> right class 1 69 + { 0x6F, 2 }, // 'o' -> right class 2 70 + }; 71 + 72 + // Flat matrix: leftClassCount(2) x rightClassCount(2), 4.4 fixed-point 73 + // [L1,R1]=kern(T,a) [L1,R2]=kern(T,o) [L2,R1]=kern(o,a) [L2,R2]=kern(o,o) 74 + static const int8_t kKernMatrix[] = { -5, -7, -2, -3 }; 75 + 76 + static const EpdFontData kTestFontData = { 77 + .bitmap = nullptr, 78 + .glyph = kGlyphs, 79 + .intervals = kIntervals, 80 + .intervalCount = 4, 81 + .advanceY = 16, 82 + .ascender = 12, 83 + .descender = 0, 84 + .is2Bit = false, 85 + .groups = nullptr, 86 + .groupCount = 0, 87 + .glyphToGroup = nullptr, 88 + .kernLeftClasses = kKernLeft, 89 + .kernRightClasses = kKernRight, 90 + .kernMatrix = kKernMatrix, 91 + .kernLeftEntryCount = 2, 92 + .kernRightEntryCount = 2, 93 + .kernLeftClassCount = 2, 94 + .kernRightClassCount = 2, 95 + .ligaturePairs = nullptr, 96 + .ligaturePairCount = 0, 97 + }; 98 + // clang-format on 99 + 100 + static EpdFont testFont(&kTestFontData); 101 + 102 + // Helper: return width from getTextDimensions 103 + static int textWidth(const char* str) { 104 + int w = 0, h = 0; 105 + testFont.getTextDimensions(str, &w, &h); 106 + return w; 107 + } 108 + 109 + static int textHeight(const char* str) { 110 + int w = 0, h = 0; 111 + testFont.getTextDimensions(str, &w, &h); 112 + return h; 113 + } 114 + 115 + // ============================================================================ 116 + // Part 1: Pure fp4 math tests 117 + // ============================================================================ 118 + 119 + // Simulate the old absolute-snap gap for comparison 120 + static int absoluteGap(int32_t startFP, int32_t advanceFP, int32_t kernFP) { 121 + int32_t nextFP = startFP + advanceFP + kernFP; 122 + return fp4::toPixel(nextFP) - fp4::toPixel(startFP); 123 + } 124 + 125 + void testFp4Basics() { 126 + printf("testFp4Basics...\n"); 127 + 128 + for (int px = 0; px < 500; px++) { 129 + ASSERT_EQ(fp4::toPixel(fp4::fromPixel(px)), px); 130 + } 131 + 132 + ASSERT_EQ(fp4::toPixel(0), 0); 133 + ASSERT_EQ(fp4::toPixel(7), 0); // 0.4375 -> 0 134 + ASSERT_EQ(fp4::toPixel(8), 1); // 0.5 -> 1 (round half up) 135 + ASSERT_EQ(fp4::toPixel(15), 1); // 0.9375 -> 1 136 + ASSERT_EQ(fp4::toPixel(16), 1); // 1.0 -> 1 137 + ASSERT_EQ(fp4::toPixel(24), 2); // 1.5 -> 2 138 + ASSERT_EQ(fp4::toPixel(-8), 0); // -0.5 -> 0 139 + ASSERT_EQ(fp4::toPixel(-9), -1); // -0.5625 -> -1 140 + ASSERT_EQ(fp4::toPixel(-16), -1); 141 + 142 + ASSERT_EQ(fp4::toPixel(137 + (-9)), 8); // 128 = 8.0 exact 143 + ASSERT_EQ(fp4::toPixel(137 + (-5)), 8); // 132 = 8.25 144 + ASSERT_EQ(fp4::toPixel(137 + (-1)), 9); // 136 = 8.5 (half rounds up) 145 + 146 + printf(" All fp4 basics passed\n"); 147 + PASS(); 148 + } 149 + 150 + void testOldApproachInconsistency() { 151 + printf("testOldApproachInconsistency...\n"); 152 + 153 + // 'oo' pair: advance=145 (9.0625px), kern=-3 (-0.1875px), combined=142 (8.875px) 154 + const int32_t advance = 145; 155 + const int32_t kern = -3; 156 + 157 + int minGap = 999, maxGap = -999; 158 + for (int startPx = 0; startPx < 100; startPx++) { 159 + for (int frac = 0; frac < 16; frac++) { 160 + int32_t startFP = fp4::fromPixel(startPx) + frac; 161 + int gap = absoluteGap(startFP, advance, kern); 162 + if (gap < minGap) minGap = gap; 163 + if (gap > maxGap) maxGap = gap; 164 + } 165 + } 166 + 167 + ASSERT_TRUE(maxGap - minGap >= 1); 168 + printf(" Old absolute gap range: [%d, %d] -- varies by %d px\n", minGap, maxGap, maxGap - minGap); 169 + 170 + int diffStep = fp4::toPixel(advance + kern); 171 + printf(" Differential step: always %d px\n", diffStep); 172 + PASS(); 173 + } 174 + 175 + void testExhaustiveKernRange() { 176 + printf("testExhaustiveKernRange...\n"); 177 + 178 + const int32_t baseAdvance = 128; 179 + int checked = 0; 180 + 181 + for (int advFrac = 0; advFrac < 16; advFrac++) { 182 + int32_t advance = baseAdvance + advFrac; 183 + for (int kern = -128; kern <= 127; kern++) { 184 + int step = fp4::toPixel(advance + static_cast<int32_t>(kern)); 185 + float idealPx = fp4::toFloat(advance + kern); 186 + if (std::abs(step - idealPx) >= 1.0f) { 187 + fprintf(stderr, " FAIL: advance=%d, kern=%d, step=%d, ideal=%.4f\n", advance, kern, step, idealPx); 188 + testsFailed++; 189 + return; 190 + } 191 + checked++; 192 + } 193 + } 194 + 195 + printf(" Checked %d (advance, kern) combinations -- all within 1px of ideal\n", checked); 196 + PASS(); 197 + } 198 + 199 + // ============================================================================ 200 + // Part 2: Integration tests using real EpdFont::getTextDimensions 201 + // ============================================================================ 202 + 203 + void testKernLookup() { 204 + printf("testKernLookup...\n"); 205 + 206 + ASSERT_EQ(testFont.getKerning('T', 'a'), -5); 207 + ASSERT_EQ(testFont.getKerning('T', 'o'), -7); 208 + ASSERT_EQ(testFont.getKerning('o', 'a'), -2); 209 + ASSERT_EQ(testFont.getKerning('o', 'o'), -3); 210 + ASSERT_EQ(testFont.getKerning('a', 'o'), 0); // 'a' has no left class 211 + ASSERT_EQ(testFont.getKerning('x', 'o'), 0); // 'x' has no left class 212 + ASSERT_EQ(testFont.getKerning('T', 'x'), 0); // 'x' has no right class 213 + ASSERT_EQ(testFont.getKerning('T', 'T'), 0); // 'T' has no right class 214 + 215 + printf(" All kern lookups correct\n"); 216 + PASS(); 217 + } 218 + 219 + void testGlyphLookup() { 220 + printf("testGlyphLookup...\n"); 221 + 222 + ASSERT_TRUE(testFont.getGlyph('T') != nullptr); 223 + ASSERT_TRUE(testFont.getGlyph('a') != nullptr); 224 + ASSERT_TRUE(testFont.getGlyph('o') != nullptr); 225 + ASSERT_TRUE(testFont.getGlyph('x') != nullptr); 226 + ASSERT_EQ(testFont.getGlyph('T')->advanceX, 137); 227 + ASSERT_EQ(testFont.getGlyph('a')->advanceX, 130); 228 + ASSERT_EQ(testFont.getGlyph('o')->advanceX, 145); 229 + ASSERT_EQ(testFont.getGlyph('x')->advanceX, 136); 230 + 231 + // No U+FFFD in font, so unknown codepoints return nullptr 232 + ASSERT_TRUE(testFont.getGlyph('Z') == nullptr); 233 + ASSERT_TRUE(testFont.getGlyph('b') == nullptr); 234 + 235 + printf(" All glyph lookups correct\n"); 236 + PASS(); 237 + } 238 + 239 + // Known-value regression tests. Expected widths are computed by hand using 240 + // differential rounding. If someone reverts to absolute snapping, specific 241 + // test cases will fail. 242 + // 243 + // Layout trace for each string (all glyphs have left=0): 244 + // width = max glyph right edge = lastBaseX + glyph.width 245 + // 246 + // Differential step from glyph A to glyph B: 247 + // step = fp4::toPixel(advanceA + kern(A,B)) 248 + void testKnownWidths() { 249 + printf("testKnownWidths...\n"); 250 + 251 + // "o": single glyph at x=0, width=8 252 + // w = 0 + 8 = 8 253 + ASSERT_EQ(textWidth("o"), 8); 254 + 255 + // "oo": step = toPixel(145 + (-3)) = toPixel(142) = 9 256 + // o1 at 0, o2 at 9. w = 9 + 8 = 17 257 + ASSERT_EQ(textWidth("oo"), 17); 258 + 259 + // "ooo": two steps of 9 260 + // o1 at 0, o2 at 9, o3 at 18. w = 18 + 8 = 26 261 + ASSERT_EQ(textWidth("ooo"), 26); 262 + 263 + // "To": step = toPixel(137 + (-7)) = toPixel(130) = 8 264 + // T at 0, o at 8. w = 8 + 8 = 16 265 + ASSERT_EQ(textWidth("To"), 16); 266 + 267 + // "Ta": step = toPixel(137 + (-5)) = toPixel(132) = 8 268 + // T at 0, a at 8. w = 8 + 7 = 15 269 + ASSERT_EQ(textWidth("Ta"), 15); 270 + 271 + // "oa": step = toPixel(145 + (-2)) = toPixel(143) = 9 272 + // o at 0, a at 9. w = 9 + 7 = 16 273 + ASSERT_EQ(textWidth("oa"), 16); 274 + 275 + // "Too": T at 0. 276 + // step T->o = toPixel(137 + (-7)) = 8. o1 at 8. 277 + // step o->o = toPixel(145 + (-3)) = 9. o2 at 17. 278 + // w = 17 + 8 = 25 279 + ASSERT_EQ(textWidth("Too"), 25); 280 + 281 + // "xo": step = toPixel(136 + 0) = toPixel(136) = 9 (no kern: x has no left class) 282 + // x at 0, o at 9. w = 9 + 8 = 17 283 + ASSERT_EQ(textWidth("xo"), 17); 284 + 285 + printf(" All known widths correct\n"); 286 + PASS(); 287 + } 288 + 289 + // "oo" pair consistency: the pixel gap between two o's must be the same 290 + // regardless of what prefix precedes them. This is THE key property of 291 + // differential rounding. With absolute snapping, "xoo" would produce a 292 + // different oo gap than "oo" because 'x' advance (136 FP) puts the first 293 + // 'o' at fractional phase 8, crossing the rounding boundary differently. 294 + void testPairConsistencyViaFont() { 295 + printf("testPairConsistencyViaFont...\n"); 296 + 297 + // The oo gap = width(prefix + "oo") - width(prefix + "o") 298 + // This isolates the pixel distance contributed by the second 'o'. 299 + const int oo_gap_bare = textWidth("oo") - textWidth("o"); 300 + const int oo_gap_after_x = textWidth("xoo") - textWidth("xo"); 301 + const int oo_gap_after_T = textWidth("Too") - textWidth("To"); 302 + const int oo_gap_after_o = textWidth("ooo") - textWidth("oo"); 303 + 304 + printf(" oo gap (bare): %d\n", oo_gap_bare); 305 + printf(" oo gap (after x): %d\n", oo_gap_after_x); 306 + printf(" oo gap (after T): %d\n", oo_gap_after_T); 307 + printf(" oo gap (after o): %d\n", oo_gap_after_o); 308 + 309 + // All must be identical 310 + ASSERT_EQ(oo_gap_after_x, oo_gap_bare); 311 + ASSERT_EQ(oo_gap_after_T, oo_gap_bare); 312 + ASSERT_EQ(oo_gap_after_o, oo_gap_bare); 313 + 314 + printf(" All oo gaps identical (%d px) regardless of prefix\n", oo_gap_bare); 315 + PASS(); 316 + } 317 + 318 + // Null-glyph handling: when a codepoint has no glyph (and no replacement 319 + // glyph), the pending advance from the previous glyph must still be flushed. 320 + // Without the flush fix, the glyph after the null would overlap the one before. 321 + void testNullGlyphAdvancePreserved() { 322 + printf("testNullGlyphAdvancePreserved...\n"); 323 + 324 + // 'Z' (0x5A) is not in our font and there's no U+FFFD, so getGlyph returns null. 325 + // "oZo" should lay out as: o1 at 0, Z skipped (advance flushed), o2 at 9. 326 + // toPixel(145) = 9 (o's advance, no kern since Z resets prevCp). 327 + // w = 9 + 8 = 17 328 + int w = textWidth("oZo"); 329 + printf(" width(\"oZo\") = %d\n", w); 330 + 331 + // Without the flush fix, o2 would land at 0 (overlapping o1), giving w = 8. 332 + ASSERT_TRUE(w > 8); 333 + ASSERT_EQ(w, 17); 334 + 335 + // Multi-null: "oZZo" -- two consecutive nulls, advance still preserved. 336 + w = textWidth("oZZo"); 337 + printf(" width(\"oZZo\") = %d\n", w); 338 + ASSERT_EQ(w, 17); 339 + 340 + // Null at start: "Zo" -- no pending advance to flush, o renders at 0. 341 + w = textWidth("Zo"); 342 + printf(" width(\"Zo\") = %d\n", w); 343 + ASSERT_EQ(w, 8); 344 + 345 + printf(" Null-glyph advance correctly preserved\n"); 346 + PASS(); 347 + } 348 + 349 + void testHeightCalculation() { 350 + printf("testHeightCalculation...\n"); 351 + 352 + // 'T' is tallest: top=12, height=12 -> extent [0, 12) 353 + // 'o' and 'a': top=8, height=8 -> extent [0, 8) 354 + ASSERT_EQ(textHeight("o"), 8); 355 + ASSERT_EQ(textHeight("T"), 12); 356 + ASSERT_EQ(textHeight("To"), 12); 357 + ASSERT_EQ(textHeight("oo"), 8); 358 + 359 + printf(" All heights correct\n"); 360 + PASS(); 361 + } 362 + 363 + int main() { 364 + printf("=== Differential Rounding Tests ===\n\n"); 365 + 366 + // Part 1: Pure fp4 math 367 + testFp4Basics(); 368 + testOldApproachInconsistency(); 369 + testExhaustiveKernRange(); 370 + 371 + // Part 2: Integration tests against real EpdFont 372 + testKernLookup(); 373 + testGlyphLookup(); 374 + testKnownWidths(); 375 + testPairConsistencyViaFont(); 376 + testNullGlyphAdvancePreserved(); 377 + testHeightCalculation(); 378 + 379 + printf("\n=== Results: %d passed, %d failed ===\n", testsPassed, testsFailed); 380 + return testsFailed > 0 ? 1 : 0; 381 + }
+30
test/run_differential_rounding_test.sh
··· 1 + #!/usr/bin/env bash 2 + set -euo pipefail 3 + 4 + ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" 5 + BUILD_DIR="$ROOT_DIR/build/differential_rounding" 6 + BINARY="$BUILD_DIR/DifferentialRoundingTest" 7 + 8 + mkdir -p "$BUILD_DIR" 9 + 10 + SOURCES=( 11 + "$ROOT_DIR/test/differential_rounding/DifferentialRoundingTest.cpp" 12 + "$ROOT_DIR/lib/EpdFont/EpdFont.cpp" 13 + "$ROOT_DIR/lib/Utf8/Utf8.cpp" 14 + ) 15 + 16 + CXXFLAGS=( 17 + -std=c++20 18 + -O2 19 + -Wall 20 + -Wextra 21 + -pedantic 22 + -I"$ROOT_DIR" 23 + -I"$ROOT_DIR/lib" 24 + -I"$ROOT_DIR/lib/EpdFont" 25 + -I"$ROOT_DIR/lib/Utf8" 26 + ) 27 + 28 + c++ "${CXXFLAGS[@]}" "${SOURCES[@]}" -o "$BINARY" 29 + 30 + "$BINARY" "$@"