Skip to content

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

Merged
znelson merged 8 commits intocrosspoint-reader:masterfrom
znelson:kern-tweak
Mar 1, 2026
Merged

fix: Use fixed-point fractional x-advance and kerning for better text layout#1168
znelson merged 8 commits intocrosspoint-reader:masterfrom
znelson:kern-tweak

Conversation

@znelson
Copy link
Contributor

@znelson znelson commented Feb 24, 2026

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:

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

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 24, 2026

📝 Walkthrough

Walkthrough

Switches horizontal text layout to 12.4 fixed‑point: font conversion, font data, EpdFont core, and GfxRenderer now store/compute advances and kerning in fixed‑point (12.4 advances, 4.4 kerning). Public renderChar wrapper removed. Builtin fonts regenerated to use the new fixed‑point metrics.

Changes

Cohort / File(s) Summary
Fixed‑point types & glyph struct
lib/EpdFont/EpdFontData.h
Adds fp4 helpers (FRAC_BITS, HALF, fromPixel, toPixel, toFloat). Changes EpdGlyph.advanceX from uint8_t to uint16_t to hold 12.4 fixed‑point advances.
EpdFont core
lib/EpdFont/EpdFont.cpp, lib/EpdFont/EpdFont.h
Replaces pixel accumulators with fixed‑point accumulators (cursorXFP, lastBaseAdvanceFP, etc.); moves kerning/advance/glyph base math to fixed‑point; updates getKerning docs to 4.4 fixed‑point.
Font conversion scripts
lib/EpdFont/scripts/fontconvert.py, lib/EpdFont/scripts/convert-builtin-fonts.sh
Adds fp4_from_ft16_16 and fp4_from_design_units; glyph advances extracted as 12.4 FP and kerning as 4.4 FP. Removed --force-autohint flag in conversion script invocation.
Renderer changes
lib/GfxRenderer/GfxRenderer.cpp, lib/GfxRenderer/GfxRenderer.h
Text layout/rendering use fixed‑point accumulators (xPosFP, widthFP, cursorXFP) and snap to pixels only when drawing; renderChar public wrapper removed and internal renderCharImpl used directly.
Regenerated builtin fonts
lib/EpdFont/builtinFonts/opendyslexic_*_{8,10,12,14}_{bold,regular}.h
Large data regen: glyph metric arrays, kerning matrices, intervals and groups rewritten to align with fixed‑point advances/kerning (massive initializer changes across many font headers).

Sequence Diagram(s)

mermaid
sequenceDiagram
participant FontConvert as FontConvert (script)
participant FontData as Builtin Font Files
participant EpdFont as EpdFont (layout)
participant Gfx as GfxRenderer
FontConvert->>FontData: generate advances (12.4 FP) and kerning (4.4 FP)
FontData->>EpdFont: provide fixed‑point glyph metrics & kern tables
Gfx->>EpdFont: request getKerning / glyph advance (FP)
EpdFont-->>Gfx: return FP values
Gfx->>Gfx: accumulate cursorXFP, apply kerning/advance (FP), snap via fp4::toPixel
Gfx->>Gfx: call renderCharImpl to blit glyph bitmap at pixel coords

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • ngxson
  • osteotek
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Use fixed-point fractional x-advance and kerning for better text layout' accurately describes the main changes: replacing integer-only advances and floor-rounded kerning with fixed-point formats to fix spacing issues.
Description check ✅ Passed The description comprehensively explains the problem (uneven spacing in 'drew'), root causes (integer advances and floor-rounded kerning), and the fixed-point solution with concrete examples and implementation details.
Linked Issues check ✅ Passed The PR fully addresses issue #1182 by implementing fixed-point accumulation (12.4 for advances, 4.4 for kerning) to eliminate the uneven character spacing problem observed in words like 'drew', with before/after comparisons and concrete technical solutions.
Out of Scope Changes check ✅ Passed All changes are in-scope: fontconvert.py conversion logic, EpdFont data structures and rendering, GfxRenderer text layout, built-in font data regeneration, and removal of --force-autohint flag are all directly supporting the fixed-point fractional advance and kerning objective.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@osteotek
Copy link
Member

I think letterforms got a "heavier" appearance after #1098, which makes this more noticeable.

Noticed that too

@znelson znelson changed the title fix: Better small negative kerning rounding fix: Use fixed-point fractional x-advance and kerning for better text layout Feb 25, 2026
@znelson znelson marked this pull request as ready for review February 25, 2026 23:59
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/EpdFont/scripts/fontconvert.py (1)

389-395: ⚠️ Potential issue | 🟡 Minor

Stale docstring: return values are now 4.4 fixed-point, not pixel integers.

The function docstring still says pixel_adjust on line 393, but the return values are now 4.4 fixed-point (as correctly noted in the inline comment on line 440). Consider updating the docstring to avoid confusion for future maintainers.

📝 Suggested docstring fix
 def extract_kerning_fonttools(font_path, codepoints, ppem):
     """Extract kerning pairs from a font file using fonttools.
 
-    Returns dict of {(leftCp, rightCp): pixel_adjust} for the given
-    codepoints.  Values are scaled from font design units to integer
-    pixels at ppem.
+    Returns dict of {(leftCp, rightCp): fp4_adjust} for the given
+    codepoints.  Values are scaled from font design units to 4.4
+    fixed-point pixels at ppem.
     """
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/EpdFont/scripts/fontconvert.py` around lines 389 - 395, The docstring for
extract_kerning_fonttools is stale: it claims the return dict maps to
"pixel_adjust" integer values but the implementation returns values in 4.4
fixed-point (as noted in the inline comment near the kerning computation).
Update the docstring to describe the return type as dict of {(leftCp, rightCp):
fixed_point_adjust} and explicitly state the values are 4.4 fixed-point (i.e., 4
integer bits + 4 fractional bits) rather than integer pixel adjustments so
future maintainers understand the scaling.
🧹 Nitpick comments (1)
lib/GfxRenderer/GfxRenderer.cpp (1)

201-219: Use style-aware ascender and move early-return before font work.

drawText currently computes yPos from getFontAscenderSize(fontId) (regular style) before the null/empty guard and before style lookup. This can cause unnecessary lookup/log noise and style baseline mismatch.

♻️ Proposed refactor
 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
+  // cannot draw a NULL / empty string
+  if (text == nullptr || *text == '\0') {
+    return;
+  }
+
+  int32_t xPosFP = fp4::fromPixel(x);  // 12.4 fixed-point accumulator
   int lastBaseX = x;
   int lastBaseAdvanceFP = 0;  // 12.4 fixed-point
   int lastBaseTop = 0;
-
-  // cannot draw a NULL / empty string
-  if (text == nullptr || *text == '\0') {
-    return;
-  }

   const auto fontIt = fontMap.find(fontId);
   if (fontIt == fontMap.end()) {
     LOG_ERR("GFX", "Font %d not found", fontId);
     return;
   }
   const auto& font = fontIt->second;
+  const int yPos = y + font.getData(style)->ascender;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/GfxRenderer/GfxRenderer.cpp` around lines 201 - 219, Move the null/empty
string guard and avoid computing style-independent values before validating the
font: in GfxRenderer::drawText, perform the "if (text == nullptr || *text ==
'\0')" early and return immediately, then locate the font via fontMap and
determine the style-aware ascender (use the style-aware API instead of calling
getFontAscenderSize(fontId) too early) and only then compute yPos; also shift
the creation of xPosFP / lastBaseX / lastBaseAdvanceFP initialization to after
the font/style lookup so you don't do unnecessary work or produce spurious
LOG_ERR noise when fontId is invalid (refer to drawText, fontMap, fontIt, font,
and getFontAscenderSize).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/EpdFont/builtinFonts/opendyslexic_10_bold.h`:
- Line 2553: The advance width for glyph U+205F in the builtin font metrics
array is wrong (entry currently shows { 0, 0, 1553, 0, 0, 0, 1963 } in
opendyslexic_10_bold.h) and must be reduced to match neighboring space glyphs;
update the third field (advance) for U+205F to a value consistent with
U+2000–U+200A (e.g., scale down by ~10× into the same fp4 range such as ~155 fp4
or another value matching the font’s space metrics), leaving the other tuple
fields unchanged.

In `@lib/EpdFont/EpdFont.cpp`:
- Line 51: The calculation for glyphBaseX rounds lastBaseAdvanceFP before
halving, causing 1px jitter for combining marks; instead divide the fixed-point
advance by 2 first and then convert to pixels so fractional precision is
preserved. Update the isCombining branch that computes glyphBaseX (the
expression using lastBaseX, lastBaseAdvanceFP, fp4::toPixel, cursorXPixels) to
compute the half-advance in fixed-point (e.g., lastBaseAdvanceFP / 2) and pass
that to fp4::toPixel (or otherwise convert after halving) so centering uses the
true half-advance rather than a rounded half of pixels. Ensure other symbols
(isCombining, lastBaseX, lastBaseAdvanceFP, cursorXPixels, glyphBaseX) remain
unchanged.

---

Outside diff comments:
In `@lib/EpdFont/scripts/fontconvert.py`:
- Around line 389-395: The docstring for extract_kerning_fonttools is stale: it
claims the return dict maps to "pixel_adjust" integer values but the
implementation returns values in 4.4 fixed-point (as noted in the inline comment
near the kerning computation). Update the docstring to describe the return type
as dict of {(leftCp, rightCp): fixed_point_adjust} and explicitly state the
values are 4.4 fixed-point (i.e., 4 integer bits + 4 fractional bits) rather
than integer pixel adjustments so future maintainers understand the scaling.

---

Nitpick comments:
In `@lib/GfxRenderer/GfxRenderer.cpp`:
- Around line 201-219: Move the null/empty string guard and avoid computing
style-independent values before validating the font: in GfxRenderer::drawText,
perform the "if (text == nullptr || *text == '\0')" early and return
immediately, then locate the font via fontMap and determine the style-aware
ascender (use the style-aware API instead of calling getFontAscenderSize(fontId)
too early) and only then compute yPos; also shift the creation of xPosFP /
lastBaseX / lastBaseAdvanceFP initialization to after the font/style lookup so
you don't do unnecessary work or produce spurious LOG_ERR noise when fontId is
invalid (refer to drawText, fontMap, fontIt, font, and getFontAscenderSize).

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b695a48 and a98d734.

📒 Files selected for processing (59)
  • lib/EpdFont/EpdFont.cpp
  • lib/EpdFont/EpdFont.h
  • lib/EpdFont/EpdFontData.h
  • lib/EpdFont/builtinFonts/bookerly_12_bold.h
  • lib/EpdFont/builtinFonts/bookerly_12_bolditalic.h
  • lib/EpdFont/builtinFonts/bookerly_12_italic.h
  • lib/EpdFont/builtinFonts/bookerly_12_regular.h
  • lib/EpdFont/builtinFonts/bookerly_14_bold.h
  • lib/EpdFont/builtinFonts/bookerly_14_bolditalic.h
  • lib/EpdFont/builtinFonts/bookerly_14_italic.h
  • lib/EpdFont/builtinFonts/bookerly_14_regular.h
  • lib/EpdFont/builtinFonts/bookerly_16_bold.h
  • lib/EpdFont/builtinFonts/bookerly_16_bolditalic.h
  • lib/EpdFont/builtinFonts/bookerly_16_italic.h
  • lib/EpdFont/builtinFonts/bookerly_16_regular.h
  • lib/EpdFont/builtinFonts/bookerly_18_bold.h
  • lib/EpdFont/builtinFonts/bookerly_18_bolditalic.h
  • lib/EpdFont/builtinFonts/bookerly_18_italic.h
  • lib/EpdFont/builtinFonts/bookerly_18_regular.h
  • lib/EpdFont/builtinFonts/notosans_12_bold.h
  • lib/EpdFont/builtinFonts/notosans_12_bolditalic.h
  • lib/EpdFont/builtinFonts/notosans_12_italic.h
  • lib/EpdFont/builtinFonts/notosans_12_regular.h
  • lib/EpdFont/builtinFonts/notosans_14_bold.h
  • lib/EpdFont/builtinFonts/notosans_14_bolditalic.h
  • lib/EpdFont/builtinFonts/notosans_14_italic.h
  • lib/EpdFont/builtinFonts/notosans_14_regular.h
  • lib/EpdFont/builtinFonts/notosans_16_bold.h
  • lib/EpdFont/builtinFonts/notosans_16_bolditalic.h
  • lib/EpdFont/builtinFonts/notosans_16_italic.h
  • lib/EpdFont/builtinFonts/notosans_16_regular.h
  • lib/EpdFont/builtinFonts/notosans_18_bold.h
  • lib/EpdFont/builtinFonts/notosans_18_bolditalic.h
  • lib/EpdFont/builtinFonts/notosans_18_italic.h
  • lib/EpdFont/builtinFonts/notosans_18_regular.h
  • lib/EpdFont/builtinFonts/notosans_8_regular.h
  • lib/EpdFont/builtinFonts/opendyslexic_10_bold.h
  • lib/EpdFont/builtinFonts/opendyslexic_10_bolditalic.h
  • lib/EpdFont/builtinFonts/opendyslexic_10_italic.h
  • lib/EpdFont/builtinFonts/opendyslexic_10_regular.h
  • lib/EpdFont/builtinFonts/opendyslexic_12_bold.h
  • lib/EpdFont/builtinFonts/opendyslexic_12_bolditalic.h
  • lib/EpdFont/builtinFonts/opendyslexic_12_italic.h
  • lib/EpdFont/builtinFonts/opendyslexic_12_regular.h
  • lib/EpdFont/builtinFonts/opendyslexic_14_bold.h
  • lib/EpdFont/builtinFonts/opendyslexic_14_bolditalic.h
  • lib/EpdFont/builtinFonts/opendyslexic_14_italic.h
  • lib/EpdFont/builtinFonts/opendyslexic_14_regular.h
  • lib/EpdFont/builtinFonts/opendyslexic_8_bold.h
  • lib/EpdFont/builtinFonts/opendyslexic_8_bolditalic.h
  • lib/EpdFont/builtinFonts/opendyslexic_8_italic.h
  • lib/EpdFont/builtinFonts/opendyslexic_8_regular.h
  • lib/EpdFont/builtinFonts/ubuntu_10_bold.h
  • lib/EpdFont/builtinFonts/ubuntu_10_regular.h
  • lib/EpdFont/builtinFonts/ubuntu_12_bold.h
  • lib/EpdFont/builtinFonts/ubuntu_12_regular.h
  • lib/EpdFont/scripts/fontconvert.py
  • lib/GfxRenderer/GfxRenderer.cpp
  • lib/GfxRenderer/GfxRenderer.h
💤 Files with no reviewable changes (1)
  • lib/GfxRenderer/GfxRenderer.h
👮 Files not reviewed due to content moderation or server errors (2)
  • lib/EpdFont/builtinFonts/opendyslexic_14_regular.h
  • lib/EpdFont/builtinFonts/opendyslexic_14_bold.h
📜 Review details
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2026-02-19T12:17:05.421Z
Learnt from: Uri-Tauber
Repo: crosspoint-reader/crosspoint-reader PR: 988
File: lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp:649-661
Timestamp: 2026-02-19T12:17:05.421Z
Learning: In ChapterHtmlSlimParser.cpp, when computing footnote word indices in endElement() for footnote links, the wordIndex must be cumulative across the 750-word mid-paragraph flush boundary. The correct calculation is: `wordIndex = wordsExtractedInBlock + currentTextBlock->size()`, not just `currentTextBlock->size()`. This ensures footnotes attach to the page containing their actual anchor word, even after layoutAndExtractLines(false) has extracted and removed earlier words from the block.

Applied to files:

  • lib/EpdFont/EpdFont.cpp
📚 Learning: 2026-02-23T19:55:03.054Z
Learnt from: martinbrook
Repo: crosspoint-reader/crosspoint-reader PR: 1136
File: scripts/patch_jpegdec.py:20-23
Timestamp: 2026-02-23T19:55:03.054Z
Learning: In the crosspoint-reader project, enable Python linters (e.g., Ruff or Flake8) in CI for all Python scripts. Run linting as part of the CI workflow and fix reported issues to maintain code quality. Do not skip linting for any Python files.

Applied to files:

  • lib/EpdFont/scripts/fontconvert.py
🧬 Code graph analysis (1)
lib/GfxRenderer/GfxRenderer.cpp (5)
lib/EpdFont/EpdFontData.h (2)
  • fromPixel (22-22)
  • toPixel (25-25)
lib/EpdFont/EpdFont.cpp (2)
  • getKerning (100-109)
  • getKerning (100-100)
lib/EpdFont/EpdFontFamily.cpp (2)
  • getKerning (31-33)
  • getKerning (31-31)
lib/Utf8/Utf8.cpp (2)
  • utf8NextCodepoint (11-31)
  • utf8NextCodepoint (11-11)
lib/Utf8/Utf8.h (1)
  • utf8IsCombiningMark (14-19)
🔇 Additional comments (19)
lib/EpdFont/EpdFontData.h (2)

17-29: Well-structured fp4 namespace with clear documentation.

The fixed-point helpers are clean, constexpr, and the documentation linking 12.4 advances with 4.4 kerns sharing 4 fractional bits is helpful.

One minor note: toPixel uses (fp + HALF) >> FRAC_BITS which performs arithmetic right-shift on negative values. This is implementation-defined in C++ prior to C++20. On ESP32 (and virtually all modern targets) this is arithmetic shift, so it works correctly — but worth being aware of if portability to unusual platforms ever matters.


36-45: advanceX widened to uint16_t — appropriate for 12.4 fixed-point.

This gives a max advance of ~4096 pixels, more than sufficient. The struct field ordering preserves natural alignment (uint16_t after two uint8_t fields fills the same 4-byte word), so there's no size regression.

lib/EpdFont/scripts/fontconvert.py (3)

154-156: Conversion from FreeType 16.16 to 12.4 is correct.

(val + (1 << 11)) >> 12 correctly rounds to nearest when shifting right by 12 bits. The result is unsigned in practice since linearHoriAdvance is always non-negative for glyph advances.


158-165: fp4_from_design_units is clean and correctly clamped.

The clamping to [-128, 127] ensures the result fits in int8_t for the kern matrix. Good.


297-308: linearHoriAdvance is available and correctly used in freetype-py-2.5.1.

The project pins freetype-py==2.5.1 in requirements.txt, which exposes linearHoriAdvance as an attribute of GlyphSlot. The implementation at line 302 correctly accesses this field to retrieve the unhinted 16.16 fixed-point advance, which is the right approach for sub-pixel-accurate layout accumulation.

lib/EpdFont/builtinFonts/opendyslexic_8_bold.h (2)

1351-1446: Regenerated glyph metrics look consistent with 12.4 fixed-point encoding.

Spot-checking a few values:

  • Space (U+0020): advanceX = 232 → 232/16 = 14.5 pixels ✓
  • 'A': advanceX = 260 → 16.25 pixels ✓
  • 'a': advanceX = 228 → 14.25 pixels ✓

The fractional precision is the core improvement of this PR.


2398-2400: Kern matrix value 43 confirmed for punctuation-space pair.

The kern matrix structure maps correctly:

  • Left class 1: punctuation/symbols (U+203A–U+208E and others)
  • Right class 1: space (U+0020)
  • Value: 43 = 2.6875 pixels (43/16) — arithmetic is correct

The conversion math and class mappings are accurate. However, verification against the source OpenDyslexic font's actual kern data is required to confirm this represents the intended kerning behavior.

lib/EpdFont/builtinFonts/opendyslexic_10_bold.h (2)

1747-2490: Bulk glyph metric regeneration looks consistent with fixed-point data.

The updated advances/offsets and regrouped bitmap spans look coherent for the 12.4 migration in this generated font table.


2794-2794: Kerning matrix value update looks valid for fp4 storage.

53 fits int8_t and is aligned with the fixed-point kerning migration.

lib/EpdFont/builtinFonts/opendyslexic_12_regular.h (3)

1-7: Generator provenance is preserved and clear.

Keeping the source command embedded here is great for reproducibility and auditing generated font artifacts.


2016-2899: Regenerated glyph table is structurally consistent for fixed-point metrics.

The updated glyph entries keep a valid, consistent shape and align with the PR’s fixed-point advance migration.


3063-3063: Kerning matrix scaling update looks correct.

Using 64 here matches the expected fixed-point kerning representation introduced by this PR.

lib/EpdFont/builtinFonts/opendyslexic_12_bold.h (1)

2365-3250: Regenerated fixed-point font data looks internally consistent.

The glyph metric table and kerning/class metadata are coherent with the fixed-point transition.

Also applies to: 3412-3433

lib/EpdFont/EpdFont.h (1)

15-16: Kerning return-unit documentation update is correct.

The comment now clearly documents the 4.4 fixed-point contract for kerning values.

lib/EpdFont/EpdFont.cpp (1)

18-20: 12.4 cursor accumulation and kerning application are wired correctly.

The fixed-point cursor flow and carry-forward logic are consistent with the new metric format.

Also applies to: 47-50, 59-64

lib/EpdFont/builtinFonts/opendyslexic_10_regular.h (2)

1669-2424: Generated glyph metric migration looks consistent.

The glyph descriptor updates are structurally consistent and align with the fixed-point metric regeneration in this PR.


2715-2717: Kerning matrix update is coherent with fractional kerning encoding.

The updated matrix entry is type-safe for int8_t and fits the PR’s fixed-point kerning direction.

lib/EpdFont/builtinFonts/opendyslexic_8_regular.h (1)

1248-2319: Generated font data update looks consistent with the fixed-point migration.

This is a data-only regeneration, and the updated glyph/kerning payload remains structurally aligned with the font data schema.

lib/GfxRenderer/GfxRenderer.cpp (1)

242-254: Fixed-point accumulation + deferred pixel snap is implemented well here.

The fp4 accumulator usage for kerning/advance in both draw and measurement paths is consistent and avoids per-glyph drift from repeated integer truncation.

Also applies to: 972-987, 1033-1072

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
lib/EpdFont/scripts/convert-builtin-fonts.sh (1)

12-20: Optional: Quote variables for shell robustness.

While the current variable values are controlled and won't contain spaces or special characters, quoting variables is a shell scripting best practice that prevents subtle bugs if values ever change.

🔧 Suggested improvement
 for size in ${BOOKERLY_FONT_SIZES[@]}; do
   for style in ${READER_FONT_STYLES[@]}; do
     font_name="bookerly_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
     font_path="../builtinFonts/source/Bookerly/Bookerly-${style}.ttf"
     output_path="../builtinFonts/${font_name}.h"
-    python fontconvert.py $font_name $size $font_path --2bit --compress > $output_path
+    python fontconvert.py "$font_name" "$size" "$font_path" --2bit --compress > "$output_path"
     echo "Generated $output_path"
   done
 done

The same pattern could be applied to the other loops (lines 22-30, 32-40, 45-53, 55) for consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/EpdFont/scripts/convert-builtin-fonts.sh` around lines 12 - 20, The loop
uses unquoted shell variables which can break if values contain spaces or
special chars; update the Bookerly generation loop to quote variable expansions
(e.g., "${BOOKERLY_FONT_SIZES[@]}", "${READER_FONT_STYLES[@]}", "$font_name",
"$size", "$font_path", "$output_path") and quote arguments to the python
invocation (fontconvert.py "$font_name" "$size" "$font_path" --2bit --compress >
"$output_path"); apply the same quoting pattern to the other font-generation
loops and any other variable uses in this script to ensure robustness.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@lib/EpdFont/scripts/convert-builtin-fonts.sh`:
- Around line 12-20: The loop uses unquoted shell variables which can break if
values contain spaces or special chars; update the Bookerly generation loop to
quote variable expansions (e.g., "${BOOKERLY_FONT_SIZES[@]}",
"${READER_FONT_STYLES[@]}", "$font_name", "$size", "$font_path", "$output_path")
and quote arguments to the python invocation (fontconvert.py "$font_name"
"$size" "$font_path" --2bit --compress > "$output_path"); apply the same quoting
pattern to the other font-generation loops and any other variable uses in this
script to ensure robustness.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0ecba9c and 3260495.

📒 Files selected for processing (17)
  • lib/EpdFont/builtinFonts/bookerly_12_bold.h
  • lib/EpdFont/builtinFonts/bookerly_12_bolditalic.h
  • lib/EpdFont/builtinFonts/bookerly_12_italic.h
  • lib/EpdFont/builtinFonts/bookerly_12_regular.h
  • lib/EpdFont/builtinFonts/bookerly_14_bold.h
  • lib/EpdFont/builtinFonts/bookerly_14_bolditalic.h
  • lib/EpdFont/builtinFonts/bookerly_14_italic.h
  • lib/EpdFont/builtinFonts/bookerly_14_regular.h
  • lib/EpdFont/builtinFonts/bookerly_16_bold.h
  • lib/EpdFont/builtinFonts/bookerly_16_bolditalic.h
  • lib/EpdFont/builtinFonts/bookerly_16_italic.h
  • lib/EpdFont/builtinFonts/bookerly_16_regular.h
  • lib/EpdFont/builtinFonts/bookerly_18_bold.h
  • lib/EpdFont/builtinFonts/bookerly_18_bolditalic.h
  • lib/EpdFont/builtinFonts/bookerly_18_italic.h
  • lib/EpdFont/builtinFonts/bookerly_18_regular.h
  • lib/EpdFont/scripts/convert-builtin-fonts.sh
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: cppcheck
  • GitHub Check: build
🧰 Additional context used
🧠 Learnings (3)
📓 Common learnings
Learnt from: znelson
Repo: crosspoint-reader/crosspoint-reader PR: 1168
File: lib/EpdFont/builtinFonts/opendyslexic_10_bold.h:2553-2553
Timestamp: 2026-02-26T05:59:54.187Z
Learning: fontconvert.py derives glyph advance from FreeType's linearHoriAdvance (16.16, unhinted). For OpenDyslexic, U+205F (MEDIUM MATHEMATICAL SPACE) returns ~97 px advance across sizes, which is an upstream font issue, not a code bug.
Learnt from: znelson
Repo: crosspoint-reader/crosspoint-reader PR: 1168
File: lib/EpdFont/builtinFonts/opendyslexic_10_bold.h:2553-2553
Timestamp: 2026-02-26T05:59:54.187Z
Learning: If we ever need to mitigate broken space widths from upstream fonts, the correct place is fontconvert.py (e.g., optional clamping or substituting normative em-fractions for specific Unicode spaces like U+205F), not the generated headers.
Learnt from: znelson
Repo: crosspoint-reader/crosspoint-reader PR: 1168
File: lib/EpdFont/builtinFonts/opendyslexic_10_bold.h:2553-2553
Timestamp: 2026-02-26T05:59:54.187Z
Learning: Generated font headers (lib/EpdFont/builtinFonts/*.h) are produced by convert-builtin-fonts.sh and must not be edited by hand; any changes will be overwritten.
📚 Learning: 2026-02-26T05:59:54.187Z
Learnt from: znelson
Repo: crosspoint-reader/crosspoint-reader PR: 1168
File: lib/EpdFont/builtinFonts/opendyslexic_10_bold.h:2553-2553
Timestamp: 2026-02-26T05:59:54.187Z
Learning: Generated font headers (lib/EpdFont/builtinFonts/*.h) are produced by convert-builtin-fonts.sh and must not be edited by hand; any changes will be overwritten.

Applied to files:

  • lib/EpdFont/scripts/convert-builtin-fonts.sh
📚 Learning: 2026-02-26T05:59:54.187Z
Learnt from: znelson
Repo: crosspoint-reader/crosspoint-reader PR: 1168
File: lib/EpdFont/builtinFonts/opendyslexic_10_bold.h:2553-2553
Timestamp: 2026-02-26T05:59:54.187Z
Learning: If we ever need to mitigate broken space widths from upstream fonts, the correct place is fontconvert.py (e.g., optional clamping or substituting normative em-fractions for specific Unicode spaces like U+205F), not the generated headers.

Applied to files:

  • lib/EpdFont/scripts/convert-builtin-fonts.sh
🔇 Additional comments (1)
lib/EpdFont/scripts/convert-builtin-fonts.sh (1)

17-17: Confirm intent: removal of --force-autohint for Bookerly.

The commit message mentions "a/b test: is Bookerly better without --force-autohint?" — please confirm this removal is the intended final state after testing, not a leftover from experimentation. This now aligns Bookerly with the other fonts (NotoSans, OpenDyslexic, Ubuntu) which also don't use --force-autohint.

If this is intentional, consider adding a brief comment in the script or PR description documenting why Bookerly no longer uses forced autohinting (e.g., better spacing metrics with the new fixed-point approach).

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
lib/GfxRenderer/GfxRenderer.cpp (1)

242-243: Minor: Consider adding a brief comment on fixed-point format compatibility.

The code adds 4.4 kerning to a 12.4 accumulator, which works correctly because both share 4 fractional bits. A brief inline note could help future maintainers understand this isn't a mistake:

// 4.4 kern adds directly to 12.4 accumulator (same fractional precision)
xPosFP += kernFP;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/GfxRenderer/GfxRenderer.cpp` around lines 242 - 243, Add a one-line
inline clarifying comment explaining that kernFP (from font.getKerning) is in
4.4 fixed-point and is safe to add directly to xPosFP (a 12.4 accumulator)
because they share 4 fractional bits; update the code near the computation of
kernFP and the line "xPosFP += kernFP;" referencing kernFP, xPosFP,
font.getKerning(prevCp, cp, style) so future maintainers know the fixed-point
formats are compatible.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@lib/GfxRenderer/GfxRenderer.cpp`:
- Around line 242-243: Add a one-line inline clarifying comment explaining that
kernFP (from font.getKerning) is in 4.4 fixed-point and is safe to add directly
to xPosFP (a 12.4 accumulator) because they share 4 fractional bits; update the
code near the computation of kernFP and the line "xPosFP += kernFP;" referencing
kernFP, xPosFP, font.getKerning(prevCp, cp, style) so future maintainers know
the fixed-point formats are compatible.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3260495 and 5b6b58e.

📒 Files selected for processing (1)
  • lib/GfxRenderer/GfxRenderer.cpp
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: cppcheck
  • GitHub Check: build
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: znelson
Repo: crosspoint-reader/crosspoint-reader PR: 1168
File: lib/EpdFont/builtinFonts/opendyslexic_10_bold.h:2553-2553
Timestamp: 2026-02-26T05:59:54.187Z
Learning: fontconvert.py derives glyph advance from FreeType's linearHoriAdvance (16.16, unhinted). For OpenDyslexic, U+205F (MEDIUM MATHEMATICAL SPACE) returns ~97 px advance across sizes, which is an upstream font issue, not a code bug.
Learnt from: znelson
Repo: crosspoint-reader/crosspoint-reader PR: 1168
File: lib/EpdFont/builtinFonts/opendyslexic_10_bold.h:2553-2553
Timestamp: 2026-02-26T05:59:54.187Z
Learning: If we ever need to mitigate broken space widths from upstream fonts, the correct place is fontconvert.py (e.g., optional clamping or substituting normative em-fractions for specific Unicode spaces like U+205F), not the generated headers.
Learnt from: znelson
Repo: crosspoint-reader/crosspoint-reader PR: 1168
File: lib/EpdFont/builtinFonts/opendyslexic_10_bold.h:2553-2553
Timestamp: 2026-02-26T05:59:54.187Z
Learning: Generated font headers (lib/EpdFont/builtinFonts/*.h) are produced by convert-builtin-fonts.sh and must not be edited by hand; any changes will be overwritten.
📚 Learning: 2026-02-26T05:59:54.187Z
Learnt from: znelson
Repo: crosspoint-reader/crosspoint-reader PR: 1168
File: lib/EpdFont/builtinFonts/opendyslexic_10_bold.h:2553-2553
Timestamp: 2026-02-26T05:59:54.187Z
Learning: fontconvert.py derives glyph advance from FreeType's linearHoriAdvance (16.16, unhinted). For OpenDyslexic, U+205F (MEDIUM MATHEMATICAL SPACE) returns ~97 px advance across sizes, which is an upstream font issue, not a code bug.

Applied to files:

  • lib/GfxRenderer/GfxRenderer.cpp
🔇 Additional comments (5)
lib/GfxRenderer/GfxRenderer.cpp (5)

67-69: LGTM!

The refactor to by-value cursor coordinates correctly delegates fixed-point accumulation to the callers while keeping this function focused on pixel-level rendering.


203-256: Solid fixed-point accumulation pattern for eliminating spacing drift.

The implementation correctly:

  • Accumulates kerning and advances in 12.4 fixed-point
  • Snaps to nearest pixel only at render time (line 245, 251)
  • Halves combining mark advance in fixed-point before rounding (line 235), preserving precision

This addresses the root cause of issue #1182 where cumulative rounding errors caused uneven spacing.


943-961: LGTM!

Public APIs correctly convert internal fixed-point values to pixels for external consumers, maintaining the abstraction boundary.


972-986: LGTM!

Text width measurement uses the same fixed-point accumulation pattern as rendering, ensuring consistency between measured and rendered text widths.


1033-1074: LGTM!

Rotated text rendering correctly mirrors the horizontal implementation with appropriate directional adjustments (subtraction instead of addition for Y-axis progression).

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements fixed-point arithmetic (12.4 for glyph advances, 4.4 for kerning) to fix uneven character spacing (issue #1182). The solution replaces integer-only glyph positioning with sub-pixel precision that accumulates fractional remainders across the entire text layout, only rounding to whole pixels at render time. This eliminates the compounding rounding errors that caused visible spacing inconsistencies in words like "drew".

Changes:

  • Introduced fp4 namespace with fixed-point conversion helpers (fromPixel, toPixel, toFloat)
  • Changed EpdGlyph::advanceX from uint8_t to uint16_t to store 12.4 fixed-point values
  • Updated font conversion script to use linearHoriAdvance (unhinted) instead of grid-fitted advance.x, and changed kerning from floor() to round()
  • Modified text rendering to use int32_t accumulators for x-position tracking in both normal and rotated text
  • Regenerated all built-in font data with new fixed-point values
  • Removed --force-autohint flag from Bookerly font generation

Reviewed changes

Copilot reviewed 15 out of 60 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
lib/EpdFont/EpdFontData.h Added fp4 namespace with fixed-point helpers; changed EpdGlyph::advanceX to uint16_t; documented fixed-point conventions
lib/EpdFont/EpdFont.h Updated getKerning() documentation to note 4.4 fixed-point return value
lib/EpdFont/EpdFont.cpp Modified getTextBounds() to use fixed-point accumulator for cursor position
lib/EpdFont/scripts/fontconvert.py Added fp4 conversion functions; switched to linearHoriAdvance; changed kerning from floor() to round()
lib/EpdFont/scripts/convert-builtin-fonts.sh Removed --force-autohint flag from Bookerly font generation
lib/EpdFont/builtinFonts/opendyslexic_8_regular.h Regenerated with new fixed-point advance and kerning values
lib/GfxRenderer/GfxRenderer.h Removed renderChar() method declaration
lib/GfxRenderer/GfxRenderer.cpp Replaced integer cursor with fixed-point accumulator in drawText() and drawTextRotated90CW(); removed renderChar() wrapper; updated all measurement functions to convert from fixed-point at API boundaries

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@osteotek
Copy link
Member

osteotek commented Feb 26, 2026

Haven’t reviewed the code, but kerning has definitely improved.

@znelson znelson requested a review from ngxson February 27, 2026 18:40
@znelson
Copy link
Contributor Author

znelson commented Mar 1, 2026

I would like to merge this as a likely improvement upon the current mainline behavior, and gather feedback in #1182.

If kerning and ligatures are not behaving well, I propose that we add a compile time flag to make EpdFont::getKerning and/or EpdFont::applyLigatures no-op, then apply that flag in platformio.ini to unblock a 1.2.0 release.

This change disables the --force-autohint flag for Bookerly generation, added in #1098. I've done some extensive testing locally which makes me believe that the tradeoff we made in #1098 was counterproductive. Many of the glyph boundaries change, and the 2-bit AA rasterization over the 1-bit version makes much less difference.

@znelson znelson requested review from a team and lukestein March 1, 2026 02:36
Copy link
Contributor

@lukestein lukestein left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been reading with this for a few days and looks great to my eyes

@znelson znelson merged commit 7dc5186 into crosspoint-reader:master Mar 1, 2026
10 checks passed
@znelson znelson deleted the kern-tweak branch March 1, 2026 16:46
trilwu added a commit to trilwu/crosspet that referenced this pull request Mar 2, 2026
…actors)

Merges upstream commits:
- fix: fixed-point x-advance and kerning for text layout (crosspoint-reader#1168)
- chore: add firmware size history script (crosspoint-reader#1235)
- refactor: binary search for font lookups (crosspoint-reader#1202)
- perf: remove unused ConfirmationActivity member (crosspoint-reader#1234)

Resolved conflicts in ubuntu_10/12 bold/regular font headers by accepting
upstream regenerated values (local had no changes to these files).
@adriancaruana
Copy link
Contributor

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.

Was the point of this PR to fix the kerning or the consistency of the font weight? It seems you've changed both, and now, while the kerning might be more consistent (to my eye, I can barely notice this tbh) now the glyph strokes are much more inconsistent, like the BEFORE images in #1098.

@znelson
Copy link
Contributor Author

znelson commented Mar 2, 2026

@adriancaruana I appreciate the feedback -- I responded to your comment in #1182. Let's keep the discussion going over there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Kerning leads to uneven character spacing

5 participants