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

Configure Feed

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

fix: Use font metrics for combining mark positioning (#1310)

## Summary

**What is the goal of this PR?** (e.g., Implements the new feature for
file uploading.)

Combining diacritical marks (U+0300–U+036F) were positioned using a
heuristic that centered them at the midpoint of the base glyph's
**advance width**. This worked acceptably for Bookerly but produced
visibly off-center marks for Noto Sans due to a fundamental difference
in how the two fonts design their combining mark metrics.

Builds on the work of #1037.

## The problem

The two built-in body fonts encode combining mark `left` offsets with
very different conventions:

| Mark | Bookerly `left` | Noto Sans `left` |
|---|---|---|
| U+0301 (acute) | -2 | -10 |
| U+0300 (grave) | -5 | -15 |
| U+0302 (circumflex) | -5 | -5 |
| U+0323 (dot below) | -2 | -11 |

Noto Sans uses large negative `left` values because its marks are
designed for placement at the post-advance cursor position, with `left`
pulling the bitmap back over the base glyph. Bookerly uses small offsets
because its marks sit closer to the glyph origin. The old `advance/2`
centering split the difference poorly — it happened to land close to
correct for Bookerly but placed Noto Sans marks roughly 6px left of
center on a typical lowercase letter.

There was also a bug in the vertical gap heuristic. It unconditionally
computed a `raiseBy` value to prevent above-baseline marks from
colliding with tall base glyphs, but it applied the same logic to
**below-baseline** marks like cedilla (U+0327), dot below (U+0323), and
ogonek (U+0328). For those marks, the math produced a large positive
raise (e.g., 24px for dot-below on 'a'), launching them above the
x-height instead of keeping them below the baseline.

## The fix

**Horizontal positioning**: Instead of centering at `advance/2`, align
the mark bitmap's visual midpoint directly over the base glyph bitmap's
visual midpoint. This uses the base glyph's actual `left` and `width`
rather than its advance width, producing correct results regardless of
how the font encodes its mark offsets.

**Vertical positioning**: The raise heuristic now checks `markTop -
markHeight > 0` and skips below-baseline marks entirely, leaving them at
their font-designed position.

**Consolidation**: The shared math is extracted into two `constexpr`
helpers (`combiningMark::centerOver` and
`combiningMark::raiseAboveBase`) in `EpdFontData.h`, eliminating the
previously triplicated inline calculations across `drawText`,
`drawTextRotated90CW`, and `getTextBounds`. The `MIN_COMBINING_GAP_PX`
constant is also centralized as `combiningMark::MIN_GAP_PX`.

| Before | After |
| -- | -- |
| <img
src="https://github.com/user-attachments/files/25752257/before-noto.bmp"
width="250" /> | <img
src="https://github.com/user-attachments/files/25752258/after-noto.bmp"
width="250" /> |
| <img
src="https://github.com/user-attachments/files/25752259/before-bookerly.bmp"
width="250" /> | <img
src="https://github.com/user-attachments/files/25752260/after-bookerly.bmp"
width="250" /> |

---

### 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 to analyze
differences between Noto Sans and Bookerly font metrics**_

---------

Co-authored-by: Uri Tauber <142022451+Uri-Tauber@users.noreply.github.com>

authored by

Zach Nelson
Uri Tauber
and committed by
GitHub
075ad7d0 243ae8b4

+69 -43
+19 -15
lib/EpdFont/EpdFont.cpp
··· 16 16 } 17 17 18 18 int lastBaseX = startX; 19 - int lastBaseAdvanceFP = 0; // 12.4 fixed-point 19 + int lastBaseLeft = 0; 20 + int lastBaseWidth = 0; 20 21 int lastBaseTop = 0; 21 22 int32_t prevAdvanceFP = 0; // 12.4 fixed-point: prev glyph's advance + next kern for snap 22 - constexpr int MIN_COMBINING_GAP_PX = 1; 23 23 uint32_t cp; 24 24 uint32_t prevCp = 0; 25 25 while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&string)))) { ··· 31 31 32 32 const EpdGlyph* glyph = getGlyph(cp); 33 33 if (!glyph) { 34 - lastBaseX += fp4::toPixel(prevAdvanceFP); // flush pending advance before resetting 35 - prevCp = 0; 36 - prevAdvanceFP = 0; 34 + // Keep cursor movement stable when a base glyph is missing, but don't attach subsequent 35 + // combining marks to stale base metrics. 36 + if (!isCombining) { 37 + lastBaseX += fp4::toPixel(prevAdvanceFP); // flush pending advance before resetting 38 + prevCp = 0; 39 + prevAdvanceFP = 0; 40 + lastBaseLeft = 0; 41 + lastBaseWidth = 0; 42 + lastBaseTop = 0; 43 + } 37 44 continue; 38 45 } 39 46 40 - int raiseBy = 0; 41 - if (isCombining) { 42 - const int currentGap = glyph->top - glyph->height - lastBaseTop; 43 - if (currentGap < MIN_COMBINING_GAP_PX) { 44 - raiseBy = MIN_COMBINING_GAP_PX - currentGap; 45 - } 46 - } 47 + const int raiseBy = isCombining ? combiningMark::raiseAboveBase(glyph->top, glyph->height, lastBaseTop) : 0; 47 48 48 49 if (!isCombining && prevCp != 0) { 49 50 const auto kernFP = getKerning(prevCp, cp); // 4.4 fixed-point kern 50 51 lastBaseX += fp4::toPixel(prevAdvanceFP + kernFP); 51 52 } 52 53 53 - const int glyphBaseX = isCombining ? (lastBaseX + fp4::toPixel(lastBaseAdvanceFP / 2)) : lastBaseX; 54 + const int glyphBaseX = 55 + isCombining ? combiningMark::centerOver(lastBaseX, lastBaseLeft, lastBaseWidth, glyph->left, glyph->width) 56 + : lastBaseX; 54 57 const int glyphBaseY = startY - raiseBy; 55 58 56 59 *minX = std::min(*minX, glyphBaseX + glyph->left); ··· 59 62 *maxY = std::max(*maxY, glyphBaseY + glyph->top); 60 63 61 64 if (!isCombining) { 62 - lastBaseAdvanceFP = glyph->advanceX; // 12.4 fixed-point 65 + lastBaseLeft = glyph->left; 66 + lastBaseWidth = glyph->width; 63 67 lastBaseTop = glyph->top; 64 - prevAdvanceFP = lastBaseAdvanceFP; 68 + prevAdvanceFP = glyph->advanceX; // 12.4 fixed-point 65 69 prevCp = cp; 66 70 } 67 71 }
+31
lib/EpdFont/EpdFontData.h
··· 30 30 constexpr float toFloat(int32_t fp) { return fp / static_cast<float>(1 << FRAC_BITS); } 31 31 } // namespace fp4 32 32 33 + /// Helpers for positioning Unicode combining marks (U+0300 ff.) over a 34 + /// preceding base glyph without GPOS anchor tables. 35 + namespace combiningMark { 36 + 37 + constexpr int MIN_GAP_PX = 1; 38 + 39 + /// Compute the cursor-X at which to render a combining mark so its bitmap 40 + /// is visually centered over the base glyph's bitmap. 41 + constexpr int centerOver(int baseCursorPos, int baseLeft, int baseWidth, int markLeft, int markWidth) { 42 + return baseCursorPos + baseLeft + baseWidth / 2 - markWidth / 2 - markLeft; 43 + } 44 + 45 + /// Rotated-90CW variant of centerOver. In the rotated coordinate system 46 + /// renderCharImpl uses (cursorY - left) instead of (cursorX + left), so 47 + /// every left/width term inverts sign. 48 + constexpr int centerOverRotated90CW(int baseCursorPos, int baseLeft, int baseWidth, int markLeft, int markWidth) { 49 + return baseCursorPos - baseLeft - baseWidth / 2 + markWidth / 2 + markLeft; 50 + } 51 + 52 + /// For combining marks that sit entirely above the baseline, compute how many 53 + /// pixels to raise the mark so there is at least MIN_GAP_PX between its bottom 54 + /// edge and the top of the base glyph. Returns 0 for marks that extend to or 55 + /// below the baseline (e.g. cedilla, dot-below, ogonek). 56 + constexpr int raiseAboveBase(int markTop, int markHeight, int baseTop) { 57 + if (markTop - markHeight <= 0) return 0; 58 + const int gap = markTop - markHeight - baseTop; 59 + return (gap < MIN_GAP_PX) ? (MIN_GAP_PX - gap) : 0; 60 + } 61 + 62 + } // namespace combiningMark 63 + 33 64 /// Fixed-point conventions used by EpdGlyph and EpdFontData: 34 65 /// advanceX: 12.4 unsigned fixed-point in uint16_t (use fp4::toPixel) 35 66 /// kernMatrix: 4.4 signed fixed-point in int8_t (use fp4::toPixel)
+19 -28
lib/GfxRenderer/GfxRenderer.cpp
··· 216 216 const EpdFontFamily::Style style) const { 217 217 const int yPos = y + getFontAscenderSize(fontId); 218 218 int lastBaseX = x; 219 - int lastBaseAdvanceFP = 0; // 12.4 fixed-point 219 + int lastBaseLeft = 0; 220 + int lastBaseWidth = 0; 220 221 int lastBaseTop = 0; 221 222 int32_t prevAdvanceFP = 0; // 12.4 fixed-point: prev glyph's advance + next kern for snap 222 223 ··· 236 237 return; 237 238 } 238 239 const auto& font = fontIt->second; 239 - constexpr int MIN_COMBINING_GAP_PX = 1; 240 240 241 241 uint32_t cp; 242 242 uint32_t prevCp = 0; 243 243 while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) { 244 244 if (utf8IsCombiningMark(cp)) { 245 245 const EpdGlyph* combiningGlyph = font.getGlyph(cp, style); 246 - int raiseBy = 0; 247 - if (combiningGlyph) { 248 - const int currentGap = combiningGlyph->top - combiningGlyph->height - lastBaseTop; 249 - if (currentGap < MIN_COMBINING_GAP_PX) { 250 - raiseBy = MIN_COMBINING_GAP_PX - currentGap; 251 - } 252 - } 253 - 254 - const int combiningX = lastBaseX + fp4::toPixel(lastBaseAdvanceFP / 2); 255 - const int combiningY = yPos - raiseBy; 256 - renderCharImpl<TextRotation::None>(*this, renderMode, font, cp, combiningX, combiningY, black, style); 246 + if (!combiningGlyph) continue; 247 + const int raiseBy = combiningMark::raiseAboveBase(combiningGlyph->top, combiningGlyph->height, lastBaseTop); 248 + const int combiningX = combiningMark::centerOver(lastBaseX, lastBaseLeft, lastBaseWidth, combiningGlyph->left, 249 + combiningGlyph->width); 250 + renderCharImpl<TextRotation::None>(*this, renderMode, font, cp, combiningX, yPos - raiseBy, black, style); 257 251 continue; 258 252 } 259 253 ··· 269 263 270 264 const EpdGlyph* glyph = font.getGlyph(cp, style); 271 265 272 - lastBaseAdvanceFP = glyph ? glyph->advanceX : 0; 266 + lastBaseLeft = glyph ? glyph->left : 0; 267 + lastBaseWidth = glyph ? glyph->width : 0; 273 268 lastBaseTop = glyph ? glyph->top : 0; 274 - prevAdvanceFP = lastBaseAdvanceFP; 269 + prevAdvanceFP = glyph ? glyph->advanceX : 0; // 12.4 fixed-point 275 270 276 271 renderCharImpl<TextRotation::None>(*this, renderMode, font, cp, lastBaseX, yPos, black, style); 277 272 prevCp = cp; ··· 1079 1074 const auto& font = fontIt->second; 1080 1075 1081 1076 int lastBaseY = y; 1082 - int lastBaseAdvanceFP = 0; // 12.4 fixed-point 1077 + int lastBaseLeft = 0; 1078 + int lastBaseWidth = 0; 1083 1079 int lastBaseTop = 0; 1084 1080 int32_t prevAdvanceFP = 0; // 12.4 fixed-point: prev glyph's advance + next kern for snap 1085 - constexpr int MIN_COMBINING_GAP_PX = 1; 1086 1081 1087 1082 uint32_t cp; 1088 1083 uint32_t prevCp = 0; 1089 1084 while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) { 1090 1085 if (utf8IsCombiningMark(cp)) { 1091 1086 const EpdGlyph* combiningGlyph = font.getGlyph(cp, style); 1092 - int raiseBy = 0; 1093 - if (combiningGlyph) { 1094 - const int currentGap = combiningGlyph->top - combiningGlyph->height - lastBaseTop; 1095 - if (currentGap < MIN_COMBINING_GAP_PX) { 1096 - raiseBy = MIN_COMBINING_GAP_PX - currentGap; 1097 - } 1098 - } 1099 - 1087 + if (!combiningGlyph) continue; 1088 + const int raiseBy = combiningMark::raiseAboveBase(combiningGlyph->top, combiningGlyph->height, lastBaseTop); 1100 1089 const int combiningX = x - raiseBy; 1101 - const int combiningY = lastBaseY - fp4::toPixel(lastBaseAdvanceFP / 2); 1090 + const int combiningY = combiningMark::centerOverRotated90CW(lastBaseY, lastBaseLeft, lastBaseWidth, 1091 + combiningGlyph->left, combiningGlyph->width); 1102 1092 renderCharImpl<TextRotation::Rotated90CW>(*this, renderMode, font, cp, combiningX, combiningY, black, style); 1103 1093 continue; 1104 1094 } ··· 1114 1104 1115 1105 const EpdGlyph* glyph = font.getGlyph(cp, style); 1116 1106 1117 - lastBaseAdvanceFP = glyph ? glyph->advanceX : 0; // 12.4 fixed-point 1107 + lastBaseLeft = glyph ? glyph->left : 0; 1108 + lastBaseWidth = glyph ? glyph->width : 0; 1118 1109 lastBaseTop = glyph ? glyph->top : 0; 1119 - prevAdvanceFP = lastBaseAdvanceFP; 1110 + prevAdvanceFP = glyph ? glyph->advanceX : 0; // 12.4 fixed-point 1120 1111 1121 1112 renderCharImpl<TextRotation::Rotated90CW>(*this, renderMode, font, cp, x, lastBaseY, black, style); 1122 1113 prevCp = cp;