fix: Use fixed-point fractional x-advance and kerning for better text layout (#1168)

## Summary

**What is the goal of this PR?**

Hopefully fixes #1182.

_Note: I think letterforms got a "heavier" appearance after #1098, which
makes this more noticeable. The current version of this PR reverts the
change to add `--force-autohint` for Bookerly, which to me seems to
bring the font back to a more aesthetic and consistent weight._

#### Problem

Character spacing was uneven in certain words. The word "drew" in
Bookerly was the clearest example: a visible gap between `d` and `r`,
while `e` and `w` appeared tightly condensed. The root cause was
twofold:

1. **Integer-only glyph advances.** `advanceX` was stored as a `uint8_t`
of whole pixels, sourced from FreeType's hinted `advance.x` (which
grid-fits to integers). A glyph whose true advance is 15.56px was stored
as 16px -- an error of +0.44px per character that compounds across a
line.

2. **Floor-rounded kerning.** Kern adjustments were converted with
`math.floor()`, which systematically over-tightened negative kerns. A
kern of -0.3px became -1px -- a 0.7px over-correction that visibly
closed gaps.

Combined, these produced the classic symptom: some pairs too wide,
others too tight, with the imbalance varying per word.

#### Solution: fixed-point accumulation with 1/16-pixel resolution, for
sub-pixel precision during text layout

All font metrics now use a "fixed-point 4" format -- 4 fractional bits
giving 1/16-pixel (0.0625px) resolution. This is implemented with plain
integer arithmetic (shifts and adds), requiring no floating-point on the
ESP32.

**How it works:**

A value like 15.56px is stored as the integer `249`:

```
249 = 15 * 16 + 9    (where 9/16 = 0.5625, closest to 0.56)
```

Two storage widths share the same 4 fractional bits:

| Field | Type | Format | Range | Use |
|-------|------|--------|-------|-----|
| `advanceX` | `uint16_t` | 12.4 | 0 -- 4095.9375 px | Glyph advance
width |
| `kernMatrix` | `int8_t` | 4.4 | -8.0 -- +7.9375 px | Kerning
adjustment |

Because both have 4 fractional bits, they add directly into a single
`int32_t` accumulator during layout. The accumulator is only snapped to
the nearest whole pixel at the moment each glyph is rendered:

```cpp
int32_t xFP = fp4::fromPixel(startX);     // pixel to 12.4: startX << 4

for each character:
    xFP += kernFP;                          // add 4.4 kern (sign-extends into int32_t)
    int xPx = fp4::toPixel(xFP);           // snap to nearest pixel: (xFP + 8) >> 4
    render glyph at xPx;
    xFP += glyph->advanceX;                // add 12.4 advance
```

Fractional remainders carry forward indefinitely. Rounding errors stay
below +/- 0.5px and never compound.

#### Concrete example: "drew" in Bookerly

**Before** (integer advances, floor-rounded kerning):

| Char | Advance | Kern | Cursor | Snap | Gap from prev |
|------|---------|------|--------|------|---------------|
| d | 16 px | -- | 33 | 33 | -- |
| r | 12 px | 0 | 49 | 49 | ~2px |
| e | 13 px | -1 | 60 | 60 | ~0px |
| w | 22 px | -1 | 72 | 72 | ~0px |

The d-to-r gap was visibly wider than the tightly packed `rew`.

**After** (12.4 advances, 4.4 kerning, fractional accumulation):

| Char | Advance (FP) | Kern (FP) | Accumulator | Snap | Ink start | Gap
from prev |

|------|-------------|-----------|-------------|------|-----------|---------------|
| d | 249 (15.56px) | -- | 528 | 33 | 34 | -- |
| r | 184 (11.50px) | 0 | 777 | 49 | 49 | 0px |
| e | 208 (13.00px) | -8 (-0.50px) | 953 | 60 | 61 | 1px |
| w | 356 (22.25px) | -4 (-0.25px) | 1157 | 72 | 72 | 0px |

Spacing is now `0, 1, 0` pixels -- nearly uniform. Verified on-device:
all 5 copies of "drew" in the test EPUB produce identical spacing,
confirming zero accumulator drift.

#### Changes

**Font conversion (`fontconvert.py`)**
- Use `linearHoriAdvance` (FreeType 16.16, unhinted) instead of
`advance.x` (26.6, grid-fitted to integers) for glyph advances
- Encode kern values as 4.4 fixed-point with `round()` instead of
`floor()`
- Add `fp4_from_ft16_16()` and `fp4_from_design_units()` helper
functions
- Add module-level documentation of fixed-point conventions

**Font data structures (`EpdFontData.h`)**
- `EpdGlyph::advanceX`: `uint8_t` to `uint16_t` (no memory cost due to
existing struct padding)
- Add `fp4` namespace with `constexpr` helpers: `fromPixel()`,
`toPixel()`, `toFloat()`
- Document fixed-point conventions

**Font API (`EpdFont.h/cpp`, `EpdFontFamily.h/cpp`)**
- `getKerning()` return type: `int8_t` to `int` (to avoid truncation of
the 4.4 value)

**Rendering (`GfxRenderer.cpp`)**
- `drawText()`: replace integer cursor with `int32_t` fixed-point
accumulator
- `drawTextRotated90CW()`: same accumulator treatment for vertical
layout
- `getTextAdvanceX()`, `getSpaceWidth()`, `getSpaceKernAdjust()`,
`getKerning()`: convert from fixed-point to pixel at API boundary

**Regenerated all built-in font headers** with new 12.4 advances and 4.4
kern values.

#### Memory impact

Zero additional RAM. The `advanceX` field grew from `uint8_t` to
`uint16_t`, but the `EpdGlyph` struct already had 1 byte of padding at
that position, so the struct size is unchanged. The fixed-point
accumulator is a single `int32_t` on the stack.

#### Test plan

- [ ] Verify "drew" spacing in Bookerly at small, medium, and large
sizes
- [ ] Verify uppercase kerning pairs: AVERY, WAVE, VALUE
- [ ] Verify ligature words: coffee, waffle, office
- [ ] Verify all built-in fonts render correctly at each size
- [ ] Verify rotated text (progress bar percentage) renders correctly
- [ ] Verify combining marks (accented characters) still position
correctly
- [ ] Spot-check a full-length book for any layout regressions

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**YES, Claude Opus 4.6
helped figure out a non-floating point approach for sub-pixel error
accumulation**_
This commit is contained in:
Zach Nelson
2026-03-01 10:43:37 -06:00
committed by GitHub
parent 620835a6a1
commit 7dc518624c
60 changed files with 125042 additions and 123978 deletions

View File

@@ -65,7 +65,7 @@ enum class TextRotation { None, Rotated90CW };
// Coordinate mapping and cursor advance direction are selected at compile time via the template parameter.
template <TextRotation rotation>
static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode renderMode,
const EpdFontFamily& fontFamily, const uint32_t cp, int* cursorX, int* cursorY,
const EpdFontFamily& fontFamily, const uint32_t cp, int cursorX, int cursorY,
const bool pixelState, const EpdFontFamily::Style style) {
const EpdGlyph* glyph = fontFamily.getGlyph(cp, style);
if (!glyph) {
@@ -87,11 +87,11 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode
// For Rotated: outer loop advances screenX, inner loop advances screenY (in reverse)
int outerBase, innerBase;
if constexpr (rotation == TextRotation::Rotated90CW) {
outerBase = *cursorX + fontData->ascender - top; // screenX = outerBase + glyphY
innerBase = *cursorY - left; // screenY = innerBase - glyphX
outerBase = cursorX + fontData->ascender - top; // screenX = outerBase + glyphY
innerBase = cursorY - left; // screenY = innerBase - glyphX
} else {
outerBase = *cursorY - top; // screenY = outerBase + glyphY
innerBase = *cursorX + left; // screenX = innerBase + glyphX
outerBase = cursorY - top; // screenY = outerBase + glyphY
innerBase = cursorX + left; // screenX = innerBase + glyphX
}
if (is2Bit) {
@@ -152,12 +152,6 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode
}
}
}
if constexpr (rotation == TextRotation::Rotated90CW) {
*cursorY -= glyph->advanceX;
} else {
*cursorX += glyph->advanceX;
}
}
// IMPORTANT: This function is in critical rendering path and is called for every pixel. Please keep it as simple and
@@ -206,11 +200,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 {
int yPos = y + getFontAscenderSize(fontId);
int xPos = x;
const int yPos = y + getFontAscenderSize(fontId);
int32_t xPosFP = fp4::fromPixel(x); // 12.4 fixed-point accumulator
int lastBaseX = x;
int lastBaseY = yPos;
int lastBaseAdvance = 0;
int lastBaseAdvanceFP = 0; // 12.4 fixed-point
int lastBaseTop = 0;
// cannot draw a NULL / empty string
@@ -239,25 +232,26 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha
}
}
int combiningX = lastBaseX + lastBaseAdvance / 2;
int combiningY = lastBaseY - raiseBy;
renderChar(font, cp, &combiningX, &combiningY, black, style);
const int combiningX = lastBaseX + fp4::toPixel(lastBaseAdvanceFP / 2);
const int combiningY = yPos - raiseBy;
renderCharImpl<TextRotation::None>(*this, renderMode, font, cp, combiningX, combiningY, black, style);
continue;
}
cp = font.applyLigatures(cp, text, style);
if (prevCp != 0) {
xPos += font.getKerning(prevCp, cp, 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
const EpdGlyph* glyph = font.getGlyph(cp, style);
lastBaseX = xPos;
lastBaseY = yPos;
lastBaseAdvance = glyph ? glyph->advanceX : 0;
lastBaseAdvanceFP = glyph ? glyph->advanceX : 0;
lastBaseTop = glyph ? glyph->top : 0;
renderChar(font, cp, &xPos, &yPos, black, style);
renderCharImpl<TextRotation::None>(*this, renderMode, font, cp, lastBaseX, yPos, black, style);
if (glyph) {
xPosFP += glyph->advanceX; // 12.4 fixed-point advance
}
prevCp = cp;
}
}
@@ -946,7 +940,7 @@ int GfxRenderer::getSpaceWidth(const int fontId, const EpdFontFamily::Style styl
}
const EpdGlyph* spaceGlyph = fontIt->second.getGlyph(' ', style);
return spaceGlyph ? spaceGlyph->advanceX : 0;
return spaceGlyph ? fp4::toPixel(spaceGlyph->advanceX) : 0; // snap 12.4 fixed-point to nearest pixel
}
int GfxRenderer::getSpaceKernAdjust(const int fontId, const uint32_t leftCp, const uint32_t rightCp,
@@ -954,14 +948,16 @@ int GfxRenderer::getSpaceKernAdjust(const int fontId, const uint32_t leftCp, con
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) return 0;
const auto& font = fontIt->second;
return font.getKerning(leftCp, ' ', style) + font.getKerning(' ', rightCp, style);
const int kernFP = font.getKerning(leftCp, ' ', style) + font.getKerning(' ', rightCp, style); // 4.4 fixed-point
return fp4::toPixel(kernFP); // snap 4.4 fixed-point to nearest pixel
}
int GfxRenderer::getKerning(const int fontId, const uint32_t leftCp, const uint32_t rightCp,
const EpdFontFamily::Style style) const {
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) return 0;
return fontIt->second.getKerning(leftCp, rightCp, style);
const int kernFP = fontIt->second.getKerning(leftCp, rightCp, style); // 4.4 fixed-point
return fp4::toPixel(kernFP); // snap 4.4 fixed-point to nearest pixel
}
int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFamily::Style style) const {
@@ -973,7 +969,7 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFami
uint32_t cp;
uint32_t prevCp = 0;
int width = 0;
int32_t widthFP = 0; // 12.4 fixed-point accumulator
const auto& font = fontIt->second;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
if (utf8IsCombiningMark(cp)) {
@@ -981,13 +977,13 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFami
}
cp = font.applyLigatures(cp, text, style);
if (prevCp != 0) {
width += font.getKerning(prevCp, cp, style);
widthFP += font.getKerning(prevCp, cp, style); // 4.4 fixed-point kern
}
const EpdGlyph* glyph = font.getGlyph(cp, style);
if (glyph) width += glyph->advanceX;
if (glyph) widthFP += glyph->advanceX; // 12.4 fixed-point advance
prevCp = cp;
}
return width;
return fp4::toPixel(widthFP); // snap 12.4 fixed-point to nearest pixel
}
int GfxRenderer::getFontAscenderSize(const int fontId) const {
@@ -1034,11 +1030,9 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
const auto& font = fontIt->second;
int xPos = x;
int yPos = y;
int lastBaseX = x;
int32_t yPosFP = fp4::fromPixel(y); // 12.4 fixed-point accumulator
int lastBaseY = y;
int lastBaseAdvance = 0;
int lastBaseAdvanceFP = 0; // 12.4 fixed-point
int lastBaseTop = 0;
constexpr int MIN_COMBINING_GAP_PX = 1;
@@ -1055,25 +1049,27 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
}
}
int combiningX = lastBaseX - raiseBy;
int combiningY = lastBaseY - lastBaseAdvance / 2;
renderCharImpl<TextRotation::Rotated90CW>(*this, renderMode, font, cp, &combiningX, &combiningY, black, style);
const int combiningX = x - raiseBy;
const int combiningY = lastBaseY - fp4::toPixel(lastBaseAdvanceFP / 2);
renderCharImpl<TextRotation::Rotated90CW>(*this, renderMode, font, cp, combiningX, combiningY, black, style);
continue;
}
cp = font.applyLigatures(cp, text, style);
if (prevCp != 0) {
yPos -= font.getKerning(prevCp, cp, style);
yPosFP -= font.getKerning(prevCp, cp, style); // 4.4 fixed-point kern (subtract for rotated)
}
lastBaseY = fp4::toPixel(yPosFP); // snap 12.4 fixed-point to nearest pixel
const EpdGlyph* glyph = font.getGlyph(cp, style);
lastBaseX = xPos;
lastBaseY = yPos;
lastBaseAdvance = glyph ? glyph->advanceX : 0;
lastBaseAdvanceFP = glyph ? glyph->advanceX : 0; // 12.4 fixed-point
lastBaseTop = glyph ? glyph->top : 0;
renderCharImpl<TextRotation::Rotated90CW>(*this, renderMode, font, cp, &xPos, &yPos, black, style);
renderCharImpl<TextRotation::Rotated90CW>(*this, renderMode, font, cp, x, lastBaseY, black, style);
if (glyph) {
yPosFP -= glyph->advanceX; // 12.4 fixed-point advance (subtract for rotated)
}
prevCp = cp;
}
}
@@ -1174,11 +1170,6 @@ void GfxRenderer::cleanupGrayscaleWithFrameBuffer() const {
}
}
void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, int* y, bool pixelState,
EpdFontFamily::Style style) const {
renderCharImpl<TextRotation::None>(*this, renderMode, fontFamily, cp, x, y, pixelState, style);
}
void GfxRenderer::getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const {
switch (orientation) {
case Portrait: