Skip to content

fix: Accumulate glyph positions in fixed-point for smoother text spacing#1662

Closed
ingpaschke wants to merge 2 commits intocrosspoint-reader:masterfrom
ingpaschke:fix/font-spacing-precision
Closed

fix: Accumulate glyph positions in fixed-point for smoother text spacing#1662
ingpaschke wants to merge 2 commits intocrosspoint-reader:masterfrom
ingpaschke:fix/font-spacing-precision

Conversation

@ingpaschke
Copy link
Copy Markdown

@ingpaschke ingpaschke commented Apr 14, 2026

Summary

Background

PR #1168 by @znelson introduced 12.4 fixed-point advances and differential rounding, which was a significant improvement. However, as @jonasdiemer noted in #1182:

On Small (just tried recently), I still feel there's room for improvement, but can't pinpoint it yet

He attributed the remaining unevenness to screen resolution limits, but it was actually residual rounding error from the differential rounding approach, which still discarded the fractional remainder at each step.

How it works

The old code rounded and added each step:
cursor (int) += round(advance + kern) // fraction lost every character
The new code accumulates without rounding:

cursor (fixed-point) += advance + kern  // no precision loss
snapX = round(cursor)                   // snap only for rendering

This ensures each glyph is placed at the rounded version of its true cumulative position, rather than a chain of individually rounded steps. The fractional error never compounds.

Impact

  • 6,030 pixels shifted on a single test page (Bookerly 14pt, justified text)
  • Zero performance cost — same number of operations, same types
  • Binary is 12 bytes smaller (removed a toPixel() call from the advance step)
  • Measurement (getTextBounds) updated to match, so line breaks stay consistent

Tested on X4 hardware.

Side-by-side with highlights (left = old/red, right = new/blue):

font-diff-highlighted

Fixes #1182

AI Usage

Did you use AI tools to help write this code? YES

The fix was developed collaboratively with Claude Code (Opus 4.6). The approach (fixed-point accumulator vs per-step rounding) is a well-known technique in text rendering — Claude identified the rounding pattern in the existing code and implemented the fix.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 14, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 81e8bc07-967e-4495-a3b1-00fd7ade2293

📥 Commits

Reviewing files that changed from the base of the PR and between 2a660c3 and 043395a.

📒 Files selected for processing (1)
  • lib/GfxRenderer/GfxRenderer.cpp
📜 Recent review details
🧰 Additional context used
🧠 Learnings (14)
📓 Common learnings
Learnt from: znelson
Repo: crosspoint-reader/crosspoint-reader PR: 1413
File: lib/EpdFont/EpdFont.cpp:34-36
Timestamp: 2026-03-17T15:27:17.468Z
Learning: In crosspoint-reader/crosspoint-reader, `EpdFont::getGlyph()` falls back to `getGlyph(REPLACEMENT_GLYPH)` (U+FFFD) before returning `nullptr`. All fonts in this project are guaranteed to include a U+FFFD replacement glyph, so the `!glyph` null branch in `EpdFont::getTextBounds()` (and similar rendering paths) is unreachable in practice. Do not flag stale-state issues in that branch (e.g., leftover `lastBaseAdvanceFP`/`lastBaseTop` after a null glyph) as bugs in future reviews.
Learnt from: znelson
Repo: crosspoint-reader/crosspoint-reader PR: 1168
File: lib/EpdFont/builtinFonts/opendyslexic_10_bold.h:2553-2553
Timestamp: 2026-02-26T06:00:13.512Z
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: 1582
File: lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp:497-501
Timestamp: 2026-04-16T06:13:27.052Z
Learning: In `lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp`, when the image consumes the empty block's accumulated vertical spacing, the reset of `currentTextBlock`'s `BlockStyle` to a fresh `BlockStyle` (with only `alignment` set) is intentionally correct. `getCombinedBlockStyle(..., CombineAxis::Vertical)` initializes `BlockStyle result = child`, so horizontal properties on the empty block are never read — the block-style stack provides horizontal context for the next element. Only the vertical margins/padding from the empty block are read during the merge, and the reset zeroes exactly those. Preserving horizontal insets from the empty block would add complexity for zero behavioral difference. Do not flag this reset as dropping inherited horizontal style in future reviews.
📚 Learning: 2026-03-17T15:27:17.468Z
Learnt from: znelson
Repo: crosspoint-reader/crosspoint-reader PR: 1413
File: lib/EpdFont/EpdFont.cpp:34-36
Timestamp: 2026-03-17T15:27:17.468Z
Learning: In crosspoint-reader/crosspoint-reader, `EpdFont::getGlyph()` falls back to `getGlyph(REPLACEMENT_GLYPH)` (U+FFFD) before returning `nullptr`. All fonts in this project are guaranteed to include a U+FFFD replacement glyph, so the `!glyph` null branch in `EpdFont::getTextBounds()` (and similar rendering paths) is unreachable in practice. Do not flag stale-state issues in that branch (e.g., leftover `lastBaseAdvanceFP`/`lastBaseTop` after a null glyph) as bugs in future reviews.

Applied to files:

  • lib/GfxRenderer/GfxRenderer.cpp
📚 Learning: 2026-04-08T15:24:06.672Z
Learnt from: zgredex
Repo: crosspoint-reader/crosspoint-reader PR: 0
File: :0-0
Timestamp: 2026-04-08T15:24:06.672Z
Learning: In crosspoint-reader/crosspoint-reader (src/activities/reader/XtcReaderActivity.cpp, src/activities/util/BmpViewerActivity.cpp, src/activities/reader/EpubReaderActivity.cpp, and other callsites), renderGrayscale's callback type is `void (*renderFn)(const GfxRenderer&, const void*)`. Lambdas passed as this callback correctly use `const GfxRenderer& r` as the first parameter. Where the renderer must be mutated inside the callback (e.g., drawBitmap, drawButtonHints), `const_cast<GfxRenderer&>(r)` is used. No `cppcheck-suppress constParameterReference` is needed. Do not flag `const GfxRenderer& r` in these lambdas or suggest removing the `const_cast` in future reviews — this is the established pattern.

Applied to files:

  • lib/GfxRenderer/GfxRenderer.cpp
📚 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/GfxRenderer/GfxRenderer.cpp
📚 Learning: 2026-02-26T06:00:13.512Z
Learnt from: znelson
Repo: crosspoint-reader/crosspoint-reader PR: 1168
File: lib/EpdFont/builtinFonts/opendyslexic_10_bold.h:2553-2553
Timestamp: 2026-02-26T06:00:13.512Z
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
📚 Learning: 2026-04-12T02:49:05.293Z
Learnt from: pablohc
Repo: crosspoint-reader/crosspoint-reader PR: 1644
File: src/components/themes/BaseTheme.cpp:835-835
Timestamp: 2026-04-12T02:49:05.293Z
Learning: In crosspoint-reader/crosspoint-reader (src/components/themes/BaseTheme.cpp), the secondary label in `BaseTheme::drawKeyboardKey` is intentionally drawn at `rect.y - 3` (3px above the key rectangle's top edge). This uses part of BaseTheme's `keySpacing = 10` inter-row gap to visually separate the secondary symbol hint from the primary character. In contrast, `LyraTheme::drawKeyboardKey` uses `rect.y` (no offset) because LyraTheme has `keySpacing = 0`. Both placements are correct and deliberate for their respective themes. Do not flag the `-3` offset as a bug or suggest aligning it to `rect.y`.

Applied to files:

  • lib/GfxRenderer/GfxRenderer.cpp
📚 Learning: 2026-03-21T15:30:11.892Z
Learnt from: znelson
Repo: crosspoint-reader/crosspoint-reader PR: 1451
File: lib/EpdFont/FontDecompressor.cpp:248-252
Timestamp: 2026-03-21T15:30:11.892Z
Learning: In crosspoint-reader/crosspoint-reader, `FontDecompressor::prewarmCache()` returns -1 when all `MAX_PAGE_SLOTS` (= 4) page slots are exhausted. This is unreachable under normal use because the `PrewarmScope` architecture always calls `clearCache()` before iterating at most 4 font styles (matching `MAX_PAGE_SLOTS`). The -1 is a defensive guard against future misuse, not a live bug. The caller (`FontCacheManager::prewarmCache`) only checks `missed > 0`, which correctly handles the normal flow. Do not flag the undetected -1 return as a bug in future reviews; a header comment documenting the -1 error case is sufficient.

Applied to files:

  • lib/GfxRenderer/GfxRenderer.cpp
📚 Learning: 2026-04-16T06:13:27.052Z
Learnt from: znelson
Repo: crosspoint-reader/crosspoint-reader PR: 1582
File: lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp:497-501
Timestamp: 2026-04-16T06:13:27.052Z
Learning: In `lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp`, when the image consumes the empty block's accumulated vertical spacing, the reset of `currentTextBlock`'s `BlockStyle` to a fresh `BlockStyle` (with only `alignment` set) is intentionally correct. `getCombinedBlockStyle(..., CombineAxis::Vertical)` initializes `BlockStyle result = child`, so horizontal properties on the empty block are never read — the block-style stack provides horizontal context for the next element. Only the vertical margins/padding from the empty block are read during the merge, and the reset zeroes exactly those. Preserving horizontal insets from the empty block would add complexity for zero behavioral difference. Do not flag this reset as dropping inherited horizontal style in future reviews.

Applied to files:

  • lib/GfxRenderer/GfxRenderer.cpp
📚 Learning: 2026-04-13T22:45:24.939Z
Learnt from: aarons
Repo: crosspoint-reader/crosspoint-reader PR: 1659
File: src/activities/reader/ReaderUtils.h:48-51
Timestamp: 2026-04-13T22:45:24.939Z
Learning: In crosspoint-reader/crosspoint-reader (src/activities/reader/ReaderUtils.h, detectRotation), the rotation direction logic `isBottom ? (orientation - 1 + COUNT) % COUNT : (orientation + 1) % COUNT` is intentionally correct as written. Despite the enum ordering PORTRAIT→LANDSCAPE_CW→INVERTED→LANDSCAPE_CCW suggesting "+1 = clockwise", on the actual device hardware the bottom button (isBottom=true) stepping backward through the enum (-1) produces a clockwise rotation and the top button (+1) produces counter-clockwise. Do not flag this as reversed in future reviews.

Applied to files:

  • lib/GfxRenderer/GfxRenderer.cpp
📚 Learning: 2026-04-12T03:00:48.309Z
Learnt from: pablohc
Repo: crosspoint-reader/crosspoint-reader PR: 1644
File: src/activities/util/KeyboardEntryActivity.cpp:218-225
Timestamp: 2026-04-12T03:00:48.309Z
Learning: In crosspoint-reader/crosspoint-reader (src/activities/util/KeyboardEntryActivity.cpp), the bottom-aligned keyboard start Y uses the simplified formula `getTotalRowCount() * (keyHeight + keySpacing)` intentionally. The small overestimation vs. the actual drawn height is compensated by per-theme `keyboardVerticalOffset` (BaseMetrics: -10, LyraMetrics: -12), manually tuned and verified on-device. Do not flag this approximation as a bug or suggest replacing it with an exact `keyboardHeight` calculation.

Applied to files:

  • lib/GfxRenderer/GfxRenderer.cpp
📚 Learning: 2026-02-27T22:49:59.600Z
Learnt from: ngxson
Repo: crosspoint-reader/crosspoint-reader PR: 1218
File: src/activities/ActivityManager.cpp:254-265
Timestamp: 2026-02-27T22:49:59.600Z
Learning: In this codebase, assertions are always enabled (no NDEBUG). Use assert() to crash on programmer errors and surface logic bugs during development and in production builds. Do not rely on asserts for runtime error handling; they should enforce invariants that must always hold. Keep asserts side-effect free and inexpensive, and avoid relying on them for user-visible failures. Include <cassert> where appropriate and document the invariant being tested.

Applied to files:

  • lib/GfxRenderer/GfxRenderer.cpp
📚 Learning: 2026-03-02T10:14:16.036Z
Learnt from: Uri-Tauber
Repo: crosspoint-reader/crosspoint-reader PR: 1245
File: lib/Epub/Epub/Section.cpp:277-308
Timestamp: 2026-03-02T10:14:16.036Z
Learning: Guideline: Strengthen serialization::readString to defend against unbounded growth when reading from disk data. Implement and enforce a maximum allowed length (e.g., a configured or reasonable constant) and validate the incoming length before resizing or allocating. Audit all call sites (e.g., BookMetadataCache, TextBlock, KOReaderCredentialStore, Section cache readers) to ensure they do not rely on unbounded len-based resizing. If the readString API must remain, add internal safeguards (bounds checks, length validation, and error handling) so per-call-site validations are not required. Ensure Section cache files remain versioned (SECTION_FILE_VERSION) and parameter mismatches invalidate caches, but do not rely on unsafe allocations; prefer safe, bounded reads with explicit errors on invalid data.

Applied to files:

  • lib/GfxRenderer/GfxRenderer.cpp
📚 Learning: 2026-03-28T11:06:29.611Z
Learnt from: pablohc
Repo: crosspoint-reader/crosspoint-reader PR: 1488
File: src/activities/home/HomeActivity.cpp:92-95
Timestamp: 2026-03-28T11:06:29.611Z
Learning: When reviewing crosspoint-reader code, avoid flagging a missing `renderer.displayBuffer()` call immediately after `GUI.drawPopup()` / `BaseTheme::drawPopup()`: `BaseTheme::drawPopup()` already calls `renderer.displayBuffer()` before returning, so the popup is guaranteed to be flushed to the e-ink panel before subsequent blocking work begins. Conversely, do not require a `renderer.displayBuffer()` call after `fillPopupProgress()`; it intentionally does not flush, so intermediate progress-bar updates may not appear unless the update granularity warrants an explicit flush elsewhere.

Applied to files:

  • lib/GfxRenderer/GfxRenderer.cpp
📚 Learning: 2026-04-12T12:28:33.205Z
Learnt from: Uri-Tauber
Repo: crosspoint-reader/crosspoint-reader PR: 1629
File: src/activities/home/HomeActivity.cpp:119-120
Timestamp: 2026-04-12T12:28:33.205Z
Learning: When reviewing code in this repository (C/C++ sources), set review comment severity according to this policy:
- Use **Major** (🟠) only for defects with realistic risk of crash, out-of-memory (OOM), invalid pointer dereference, data corruption, or other severe issues that are unlikely to be caught in casual/manual device testing.
- Use **Minor** or informational for UX gaps, logic edge-cases, style issues, or missing feature completeness that the author can verify (or has verified) through normal device use.
- Do **not** escalate severity to Major based on behavioral/UX observations alone; assume the author has already tested the feature on their own device and only treat issues as Major if they match the high-risk defect categories above.

Applied to files:

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

253-256: Combining mark horizontal centering appears to use wrong sign.

As noted in the previous review, by the time this code runs, cursorFP has already advanced past the base glyph. To center the combining mark over the base glyph:

  • Current cursor: cursorFP (at position P + advance)
  • Base glyph center: approximately cursorFP - lastBaseAdvanceFP/2

The current code adds half the advance instead of subtracting.

Suggested fix
-      const int combiningX = fp4::toPixel(cursorFP + lastBaseAdvanceFP / 2);
+      const int combiningX = fp4::toPixel(cursorFP - lastBaseAdvanceFP / 2);

1097-1098: Combining mark vertical centering in rotated text uses wrong sign.

In the rotated path, the cursor advances in the negative Y direction. After rendering a base glyph, cursorFP has been decremented. To center the combining mark over the base glyph:

  • Current cursor: cursorFP (at position P - advance)
  • Base glyph center: approximately cursorFP + lastBaseAdvanceFP/2

The current code subtracts half the advance instead of adding.

Suggested fix
-      const int combiningY = fp4::toPixel(cursorFP - lastBaseAdvanceFP / 2);
+      const int combiningY = fp4::toPixel(cursorFP + lastBaseAdvanceFP / 2);

218-219: LGTM! Fixed-point cursor initialization is correct.

Converting the starting position to 12.4 fixed-point with fp4::fromPixel() preserves fractional precision throughout the accumulation loop.


261-277: LGTM! Core fixed-point accumulation logic is correct.

The implementation properly:

  1. Accumulates kerning in fixed-point without intermediate snapping
  2. Snaps to pixel only at render time
  3. Preserves fractional precision when advancing the cursor

This eliminates the per-step rounding loss that caused uneven character spacing.


1014-1031: LGTM! Measurement now matches rendering behavior.

getTextAdvanceX uses the same fixed-point accumulation pattern as drawText:

  • Accumulates kerning and advances in 12.4 fixed-point
  • Performs a single final snap to pixel

This ensures measurement and rendering produce consistent results, fixing justification gaps mentioned in the commit message.


1104-1120: LGTM! Rotated text accumulation logic is correct.

The rotated path correctly mirrors the non-rotated implementation:

  • Uses subtraction for kerning and advance (matching the negative Y direction)
  • Snaps only at render time
  • Preserves fractional precision in the accumulator

📝 Walkthrough

Walkthrough

Refactors text layout to use a single 12.4 fixed-point cursor accumulator (cursorFP) across EpdFont::getTextBounds and GfxRenderer text functions; kerning is applied directly to the cursor, glyph positions are snapped from the accumulator, advances preserve fractional parts, and combining marks center using half the previous base advance.

Changes

Cohort / File(s) Summary
Fixed-point text cursor & rendering
lib/EpdFont/EpdFont.cpp, lib/GfxRenderer/GfxRenderer.cpp
Replaced differential rounding (lastBaseX / prevAdvanceFP) with a single 12.4 fixed-point cursorFP accumulator. Kerning is added directly to cursorFP; per-glyph render positions use fp4::toPixel(cursorFP); advances update cursorFP in fixed-point; combining-mark centering uses lastBaseAdvanceFP/2; missing glyph handling no longer flushes pending advance.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • jonasdiemer
  • osteotek
  • jdk2pq
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: accumulating glyph positions in fixed-point for smoother text spacing, which is the core objective of this PR.
Description check ✅ Passed The description is well-detailed and directly related to the changeset, explaining the problem, solution approach, and measurable impact of the fixed-point accumulator changes.
Linked Issues check ✅ Passed The PR directly addresses #1182 (uneven character spacing from kerning) by replacing per-step rounding with fixed-point accumulation to eliminate fractional precision loss and provide more consistent glyph positioning.
Out of Scope Changes check ✅ Passed All changes in EpdFont.cpp and GfxRenderer.cpp are scoped to implementing the fixed-point accumulator approach for glyph positioning; no unrelated modifications are present.

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

✨ Finishing Touches
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch fix/font-spacing-precision

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.

@ingpaschke ingpaschke changed the title Accumulate glyph positions in fixed-point for smoother text spacing fix: Accumulate glyph positions in fixed-point for smoother text spacing Apr 14, 2026
Previously each glyph advance was snapped to the nearest integer pixel
before adding to the cursor, discarding fractional precision at every
step. This caused visible spacing unevenness — letter pairs alternating
between N and N+1 pixel gaps in a pattern unrelated to the font design.

Now the cursor accumulates in 12.4 fixed-point and only snaps to pixels
at render time. The same fix is applied to drawText, drawTextRotated90CW,
and getTextBounds to keep rendering and measurement consistent.

Zero performance cost — same number of operations, same types. The binary
is actually 12 bytes smaller since the toPixel() call was removed from the
advance step.

Tested on X4 hardware with Bookerly 14pt justified text. Before/after
screenshots confirm 6,030 pixels shifted across a single page, with
visibly more even character spacing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@ingpaschke ingpaschke force-pushed the fix/font-spacing-precision branch from cf14d43 to 2a660c3 Compare April 14, 2026 19:47
Copy link
Copy Markdown
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: 1

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

261-277: Keep getTextAdvanceX() on the same rounding model.

These paths now snap only the cumulative cursor, but GfxRenderer::getTextAdvanceX() at Line 1023 still sums rounded segments and says it “matches drawText.” That is no longer true, so any caller that uses the helper to place a follow-on run can drift by a pixel relative to what drawText() now renders.

Suggested follow-up
 int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFamily::Style style) const {
   const auto fontIt = fontMap.find(fontId);
   if (fontIt == fontMap.end()) {
     LOG_ERR("GFX", "Font %d not found", fontId);
     return 0;
   }

   uint32_t cp;
   uint32_t prevCp = 0;
-  int widthPx = 0;
-  int32_t prevAdvanceFP = 0;  // 12.4 fixed-point: prev glyph's advance + next kern for snap
+  int32_t cursorFP = 0;  // accumulate advance in 12.4 fixed-point
   const auto& font = fontIt->second;
   while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&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) {
-      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
+      cursorFP += font.getKerning(prevCp, cp, style);
     }

     const EpdGlyph* glyph = font.getGlyph(cp, style);
-    prevAdvanceFP = glyph ? glyph->advanceX : 0;
+    cursorFP += glyph ? glyph->advanceX : 0;
     prevCp = cp;
   }
-  widthPx += fp4::toPixel(prevAdvanceFP);  // final glyph's advance
-  return widthPx;
+  return fp4::toPixel(cursorFP);
 }

Also applies to: 1108-1124

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

In `@lib/GfxRenderer/GfxRenderer.cpp` around lines 261 - 277, getTextAdvanceX()
currently sums rounded segment advances and will diverge from drawText() which
keeps a fractional accumulator (cursorFP) and only snaps to pixels for
rendering; update getTextAdvanceX() to mirror drawText() by accumulating
advances and kerning in the same fixed-point representation (use the same units
as cursorFP / lastBaseAdvanceFP and include font.getKerning(...) values) and
only convert to pixels once using fp4::toPixel at the end so placement matches
drawText(); apply the same change to the other helper path mentioned (the code
region corresponding to lines 1108-1124) so both helpers use the identical
fixed-point accumulation and final snapping strategy as
drawText()/renderCharImpl.
🤖 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/GfxRenderer/GfxRenderer.cpp`:
- Around line 253-255: The combining-mark positioning is using cursorFP (which
is already after the base glyph) and adding lastBaseAdvanceFP/2, so flip the
signs: change the computation of combiningX/combiningY to use cursorFP -
lastBaseAdvanceFP/2 for the horizontal branch
(renderCharImpl<TextRotation::None>) and the opposite sign in the rotated branch
(use + lastBaseAdvanceFP/2 where it previously used -), and then apply the
mirrored change to the corresponding math in EpdFont (the code that computes
combining mark bounds) so bounds stay aligned with the new centering logic;
update every matching occurrence (including the other occurrence noted in the
review) so combining marks are centered over the base glyph.

---

Nitpick comments:
In `@lib/GfxRenderer/GfxRenderer.cpp`:
- Around line 261-277: getTextAdvanceX() currently sums rounded segment advances
and will diverge from drawText() which keeps a fractional accumulator (cursorFP)
and only snaps to pixels for rendering; update getTextAdvanceX() to mirror
drawText() by accumulating advances and kerning in the same fixed-point
representation (use the same units as cursorFP / lastBaseAdvanceFP and include
font.getKerning(...) values) and only convert to pixels once using fp4::toPixel
at the end so placement matches drawText(); apply the same change to the other
helper path mentioned (the code region corresponding to lines 1108-1124) so both
helpers use the identical fixed-point accumulation and final snapping strategy
as drawText()/renderCharImpl.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 97043913-71f2-42ee-a4ca-a5afcb5432fc

📥 Commits

Reviewing files that changed from the base of the PR and between cf14d43 and 2a660c3.

📒 Files selected for processing (2)
  • lib/EpdFont/EpdFont.cpp
  • lib/GfxRenderer/GfxRenderer.cpp
🚧 Files skipped from review as they are similar to previous changes (1)
  • lib/EpdFont/EpdFont.cpp
📜 Review details
🧰 Additional context used
🧠 Learnings (9)
📓 Common learnings
Learnt from: znelson
Repo: crosspoint-reader/crosspoint-reader PR: 1413
File: lib/EpdFont/EpdFont.cpp:34-36
Timestamp: 2026-03-17T15:27:17.468Z
Learning: In crosspoint-reader/crosspoint-reader, `EpdFont::getGlyph()` falls back to `getGlyph(REPLACEMENT_GLYPH)` (U+FFFD) before returning `nullptr`. All fonts in this project are guaranteed to include a U+FFFD replacement glyph, so the `!glyph` null branch in `EpdFont::getTextBounds()` (and similar rendering paths) is unreachable in practice. Do not flag stale-state issues in that branch (e.g., leftover `lastBaseAdvanceFP`/`lastBaseTop` after a null glyph) as bugs in future reviews.
Learnt from: znelson
Repo: crosspoint-reader/crosspoint-reader PR: 1168
File: lib/EpdFont/builtinFonts/opendyslexic_10_bold.h:2553-2553
Timestamp: 2026-02-26T06:00:13.512Z
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: 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.
📚 Learning: 2026-03-17T15:27:17.468Z
Learnt from: znelson
Repo: crosspoint-reader/crosspoint-reader PR: 1413
File: lib/EpdFont/EpdFont.cpp:34-36
Timestamp: 2026-03-17T15:27:17.468Z
Learning: In crosspoint-reader/crosspoint-reader, `EpdFont::getGlyph()` falls back to `getGlyph(REPLACEMENT_GLYPH)` (U+FFFD) before returning `nullptr`. All fonts in this project are guaranteed to include a U+FFFD replacement glyph, so the `!glyph` null branch in `EpdFont::getTextBounds()` (and similar rendering paths) is unreachable in practice. Do not flag stale-state issues in that branch (e.g., leftover `lastBaseAdvanceFP`/`lastBaseTop` after a null glyph) as bugs in future reviews.

Applied to files:

  • lib/GfxRenderer/GfxRenderer.cpp
📚 Learning: 2026-04-08T15:24:06.651Z
Learnt from: zgredex
Repo: crosspoint-reader/crosspoint-reader PR: 0
File: :0-0
Timestamp: 2026-04-08T15:24:06.651Z
Learning: In crosspoint-reader/crosspoint-reader (src/activities/reader/XtcReaderActivity.cpp, src/activities/util/BmpViewerActivity.cpp, src/activities/reader/EpubReaderActivity.cpp, and other callsites), renderGrayscale's callback type is `void (*renderFn)(const GfxRenderer&, const void*)`. Lambdas passed as this callback correctly use `const GfxRenderer& r` as the first parameter. Where the renderer must be mutated inside the callback (e.g., drawBitmap, drawButtonHints), `const_cast<GfxRenderer&>(r)` is used. No `cppcheck-suppress constParameterReference` is needed. Do not flag `const GfxRenderer& r` in these lambdas or suggest removing the `const_cast` in future reviews — this is the established pattern.

Applied to files:

  • lib/GfxRenderer/GfxRenderer.cpp
📚 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/GfxRenderer/GfxRenderer.cpp
📚 Learning: 2026-02-26T06:00:13.512Z
Learnt from: znelson
Repo: crosspoint-reader/crosspoint-reader PR: 1168
File: lib/EpdFont/builtinFonts/opendyslexic_10_bold.h:2553-2553
Timestamp: 2026-02-26T06:00:13.512Z
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
📚 Learning: 2026-02-27T22:49:59.600Z
Learnt from: ngxson
Repo: crosspoint-reader/crosspoint-reader PR: 1218
File: src/activities/ActivityManager.cpp:254-265
Timestamp: 2026-02-27T22:49:59.600Z
Learning: In this codebase, assertions are always enabled (no NDEBUG). Use assert() to crash on programmer errors and surface logic bugs during development and in production builds. Do not rely on asserts for runtime error handling; they should enforce invariants that must always hold. Keep asserts side-effect free and inexpensive, and avoid relying on them for user-visible failures. Include <cassert> where appropriate and document the invariant being tested.

Applied to files:

  • lib/GfxRenderer/GfxRenderer.cpp
📚 Learning: 2026-03-02T10:14:16.036Z
Learnt from: Uri-Tauber
Repo: crosspoint-reader/crosspoint-reader PR: 1245
File: lib/Epub/Epub/Section.cpp:277-308
Timestamp: 2026-03-02T10:14:16.036Z
Learning: Guideline: Strengthen serialization::readString to defend against unbounded growth when reading from disk data. Implement and enforce a maximum allowed length (e.g., a configured or reasonable constant) and validate the incoming length before resizing or allocating. Audit all call sites (e.g., BookMetadataCache, TextBlock, KOReaderCredentialStore, Section cache readers) to ensure they do not rely on unbounded len-based resizing. If the readString API must remain, add internal safeguards (bounds checks, length validation, and error handling) so per-call-site validations are not required. Ensure Section cache files remain versioned (SECTION_FILE_VERSION) and parameter mismatches invalidate caches, but do not rely on unsafe allocations; prefer safe, bounded reads with explicit errors on invalid data.

Applied to files:

  • lib/GfxRenderer/GfxRenderer.cpp
📚 Learning: 2026-03-28T11:06:29.611Z
Learnt from: pablohc
Repo: crosspoint-reader/crosspoint-reader PR: 1488
File: src/activities/home/HomeActivity.cpp:92-95
Timestamp: 2026-03-28T11:06:29.611Z
Learning: When reviewing crosspoint-reader code, avoid flagging a missing `renderer.displayBuffer()` call immediately after `GUI.drawPopup()` / `BaseTheme::drawPopup()`: `BaseTheme::drawPopup()` already calls `renderer.displayBuffer()` before returning, so the popup is guaranteed to be flushed to the e-ink panel before subsequent blocking work begins. Conversely, do not require a `renderer.displayBuffer()` call after `fillPopupProgress()`; it intentionally does not flush, so intermediate progress-bar updates may not appear unless the update granularity warrants an explicit flush elsewhere.

Applied to files:

  • lib/GfxRenderer/GfxRenderer.cpp
📚 Learning: 2026-04-12T12:28:33.205Z
Learnt from: Uri-Tauber
Repo: crosspoint-reader/crosspoint-reader PR: 1629
File: src/activities/home/HomeActivity.cpp:119-120
Timestamp: 2026-04-12T12:28:33.205Z
Learning: When reviewing code in this repository (C/C++ sources), set review comment severity according to this policy:
- Use **Major** (🟠) only for defects with realistic risk of crash, out-of-memory (OOM), invalid pointer dereference, data corruption, or other severe issues that are unlikely to be caught in casual/manual device testing.
- Use **Minor** or informational for UX gaps, logic edge-cases, style issues, or missing feature completeness that the author can verify (or has verified) through normal device use.
- Do **not** escalate severity to Major based on behavioral/UX observations alone; assume the author has already tested the feature on their own device and only treat issues as Major if they match the high-risk defect categories above.

Applied to files:

  • lib/GfxRenderer/GfxRenderer.cpp

Comment on lines +253 to 255
const int combiningX = fp4::toPixel(cursorFP + lastBaseAdvanceFP / 2);
const int combiningY = yPos - raiseBy;
renderCharImpl<TextRotation::None>(*this, renderMode, font, cp, combiningX, combiningY, black, style);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Combining marks are centered from the wrong side of the pen.

By the time these branches run, cursorFP already points after the previous base glyph. Using + lastBaseAdvanceFP / 2 in horizontal text and - lastBaseAdvanceFP / 2 in the rotated path shifts the mark by a full advance instead of centering it over the base. Please flip the signs here, and mirror the same change in the matching lib/EpdFont/EpdFont.cpp math so bounds stay aligned.

Suggested fix
-      const int combiningX = fp4::toPixel(cursorFP + lastBaseAdvanceFP / 2);
+      const int combiningX = fp4::toPixel(cursorFP - lastBaseAdvanceFP / 2);
...
-      const int combiningY = fp4::toPixel(cursorFP - lastBaseAdvanceFP / 2);
+      const int combiningY = fp4::toPixel(cursorFP + lastBaseAdvanceFP / 2);

Also applies to: 1100-1102

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

In `@lib/GfxRenderer/GfxRenderer.cpp` around lines 253 - 255, The combining-mark
positioning is using cursorFP (which is already after the base glyph) and adding
lastBaseAdvanceFP/2, so flip the signs: change the computation of
combiningX/combiningY to use cursorFP - lastBaseAdvanceFP/2 for the horizontal
branch (renderCharImpl<TextRotation::None>) and the opposite sign in the rotated
branch (use + lastBaseAdvanceFP/2 where it previously used -), and then apply
the mirrored change to the corresponding math in EpdFont (the code that computes
combining mark bounds) so bounds stay aligned with the new centering logic;
update every matching occurrence (including the other occurrence noted in the
review) so combining marks are centered over the base glyph.

@znelson
Copy link
Copy Markdown
Contributor

znelson commented Apr 14, 2026

Hey @ingpaschke, I appreciate having another set of eyes on the subtle spacing tweaks to get great typographic layout!

I see the PR notes only call out #1168, not the tweaks for differential rounding in #1413. Can you help me compare this to 1398aeb?

I was trying to fix the case where I'd see common pairs of characters, e.g. "oo", with slightly different spacing across a page. My analysis was that it was happening because each character snaps to a position based on accumulated fixed-point error, and so spacing between specific glyph pairs can vary. For example, say the spacing for "oo" is 8.875 px in fixed-point. Depending on the cumulative error, sometimes that can snap to 8px, other times to 9px. On a relatively low density display like this, and with small font, the pixel snapping difference starts to become obvious, and felt off to me.

The "typographic consensus" is largely shaped by systems that have sub-pixel rendering, anti-aliasing, and high DPI displays, luxuries the X4 doesn't have. My math is that a 1px difference on "oo", for example, works out to a 100% variance in layout: either 1px or 2px space.

Maybe the "non-smooth" cases you observe primarily show up with justified line layout, because the right edge becomes slightly ragged due to the accumulated error of the current differential rounding algorithm? I admit I'm primarily testing with left-aligned text which intentionally leaves a ragged-right.

If #1413 gets us "even" rendering for words, in the sense that pairs of characters get consistent spacing (by using fixed-point differential rounding from the x-advance and kerning space), but leaves us with line-length accumulated error (with ragged edges when justified as a remaining pain point), perhaps we should distribute accumulated line error into inter-word spacing for justified layout?

I'd love to hear from @jonasdiemer too, with his observations regarding #1182 after 1398aeb (which is currently in main but not in 1.2.0 release).

@ingpaschke
Copy link
Copy Markdown
Author

Hi @znelson, thanks for the detailed analysis! You're right that I should have referenced #1413 — the differential rounding was the foundation this builds on.

To answer your question about "oo" consistency: yes, with the accumulator approach, identical pairs can get different pixel spacing depending on accumulated fractional position. I understand why differential rounding was designed to prevent this. I like the accumulator approach of the PR better (at least with justified text). But I agree that's probably personal preference. It would be easy to make this a config option.

The fundamental problem is that we calculate sub-pixel positions, but with our pre-rendered pixel font that's antialiased for whole pixels, we can only render at whole pixel positions.

I've spent the last two days experimenting extensively with sub-pixel positioning approaches to find the best trade-off for this hardware.

Here's what I tested:

  1. 2x horizontal oversampled fonts — Render at 300 DPI horizontal, 1-bit, downsample at runtime. Half-pixel positioning with runtime AA. Horizontal spacing was great, but vertical edges lost AA quality. Flash dropped to 85% (1-bit compresses better), but the rendering felt less sharp than the pre-baked 2-bit fonts.

  2. Per-glyph sub-pixel blending — Use the existing 2-bit data, blend each pixel with its neighbor weighted by fractional cursor offset. But the linear blend doesn't capture how FreeType reshapes glyphs at different sub-pixel positions. Only 14.6% of pixels in a shifted-original matched FreeType's actual 0.5px render. That didn't look good.

  3. Line-buffer compositing — Render all glyphs into a 2x-wide temp buffer (~4KB), downsample the composited line. Spacing was spot-on and glyph interactions handled correctly, but there's a slight softening vs native hinting.

  4. Dual-position glyphs — Store each glyph twice: rendered by FreeType at 0.0 and 0.5px offset. Pick the closer variant at render time. This gives native hinting sharpness at both positions — best visual result by far. Storage cost is ~25KB compressed per font for the alternates.

With the dual-position approach identical pairs always render with the same variant (since the threshold is deterministic) AND each variant is properly hinted for its grid position. No blending, no softening.

I think there's a path where the accumulator fix (this PR) lands first as a standalone simple option for those who prefer it, and dual-position glyphs come as a follow-up PR once the storage budget is worked out (possibly combined with #589's compression improvements).

Screenshot of the dual position glyph rendering:

image

@znelson
Copy link
Copy Markdown
Contributor

znelson commented Apr 16, 2026

@ingpaschke Thanks for the details! Let me give this a few days on device to see if anything stands out 🙂

getTextAdvanceX used per-step differential rounding while drawText
uses a fixed-point accumulator. This mismatch caused the layout
engine to compute word widths slightly differently from how they
actually render, leading to justification gaps being computed from
incorrect measurements.

Now both use the same accumulator approach: accumulate advances and
kerning in 12.4 fixed-point, snap to pixels once at the end.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@znelson
Copy link
Copy Markdown
Contributor

znelson commented Apr 18, 2026

After using this for a few days, I think it's a regression from what's in main.

The fundamental problem is that we calculate sub-pixel positions, but with our pre-rendered pixel font that's antialiased for whole pixels, we can only render at whole pixel positions.

Yes, that's true. And we need to work within those constraints.

The fundamental problem with this PR is that the kerning choice for any given pair of letters depends not only on the letters themselves, but also where those letters happen to fall in the accumulated X position across the line.

This reverts exactly what #1413 was fixing. Glyphs must snap to a pixel, yes. But if we pick the kerning snap including accumulated fixed-point X position error across the line, we get inconsistencies in common kerning pairs.

Here are a number of examples to compare:

Pair master This PR
'oo' 12px
'rr' 12px
'gg' 12px
'dd' 12px
'oo' 14px
'oo' 14px
'rr' 14px
'ea' 'he' 14px
'dd' 'bb' 14px

@ingpaschke Can you please provide specific examples of what looks smoother in rendering with this PR versus master?

If it's about the right-edge alignment with justified text, maybe we'd be better with something like this patch:
justified-spacing.patch

@ingpaschke
Copy link
Copy Markdown
Author

Based on your extensive analysis, I have to agree that this PR on its own is really a regression. That's why I went further and added the dual glyph rendering and tracking, which I still think produced superior results — not just consistent within pairs, but consistent across the whole line. The eye sees a uniform rhythm.

You can't really see the difference in screenshots from the device, because they don't show the greyscale antialising, so here are two photos.

Stock firmware:

image

With FP4 accumulation, dual glyph and tracking:

image

But I understand that these changes are a larger restructuring than fits CrossPoint's current constraints. The dual-glyph approach naturally fits an SD-card-delivered model once user-font support lands — happy to contribute in that direction when the time comes.

@znelson
Copy link
Copy Markdown
Contributor

znelson commented Apr 18, 2026

@ingpaschke Would you be willing to join the group discussing font changes in #1693? We definitely need people passionate about fonts and font rendering to drive the next wave of improvements.

@znelson
Copy link
Copy Markdown
Contributor

znelson commented Apr 22, 2026

Closing in favor of other font discussions.

@znelson znelson closed this Apr 22, 2026
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

2 participants