Commit Graph
72 Commits
Author SHA1 Message Date
jpirnay a176b3fa6d Merge pull request #162 from spfenwick/fix-oom-crash
fix: Avoid OOM crash when using SD font with many kern pairs
2026-05-02 21:09:43 +02:00
jpirnayandCopilot cc9ff5bae9 More review changes
Co-authored-by: Copilot <copilot@github.com>
2026-05-02 18:42:45 +02:00
jpirnayandCopilot 06dfc6c50b Recover parts of the regression fiasco
Co-authored-by: Copilot <copilot@github.com>
2026-05-02 18:19:23 +02:00
jpirnayandCopilot a5469c0bb9 Review comments
Co-authored-by: Copilot <copilot@github.com>
2026-05-02 17:22:10 +02:00
jpirnay df94ccaa86 Recover from merge fiasco 2026-05-02 16:54:31 +02:00
spfenwick f07724dab4 Address review comment: Ensure ligature metadata is wired if requested but not yet wired 2026-05-02 20:09:52 +12:00
spfenwick acfb9e6cdc Address Review Comment: clear dangling pointers when miniData freed 2026-05-02 20:08:48 +12:00
spfenwick 9def61aed9 Address review comment: Don't leave dangling pointers when loading ligature/kern darta fails 2026-05-02 19:51:14 +12:00
spfenwick 8152204985 Fix formatting
This is particularly important when using SD fonts with many kern pairs
2026-05-02 16:40:23 +12:00
spfenwick 913b7e5f8b Avoid OOM during prewarm by loading only ligature data, not kern pairs
This is particularly important when using SD fonts with many kern pairs
2026-05-02 15:54:40 +12:00
jpirnay c59a893202 Merge pull request #152 from jpirnay/feat-strikethrough
feat: Support strikethrough
2026-04-29 12:33:00 +02:00
jpirnay a2daaa7194 Fix crash 2026-04-29 09:56:34 +02:00
jpirnay b83482e732 Support strikethrough 2026-04-29 09:08:07 +02:00
jpirnay 93f5bdb492 Merge pull request #147 from jpirnay/feat-fonts-sd
feat: Add sd fonts (integrate and improve upstream #1327 by adriancaruana)
2026-04-28 19:09:51 +02:00
jpirnay 0466465d8c Add destructor to unregister fonts from GfxRenderer 2026-04-28 19:03:38 +02:00
jpirnayandCopilot ebb80bf4d9 Make SdCardFont explicitly non-copyable and non-movable
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 18:41:48 +02:00
jpirnay 2694081af9 Use bitmap.pitch instead of assuming tightly packed rows 2026-04-28 18:14:58 +02:00
jpirnay ad2e3765ba Some environment cleaning 2026-04-28 18:09:36 +02:00
jpirnayandCopilot fd35b50ff9 Some minor fixes
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 18:06:56 +02:00
jpirnayandCopilot c84b971455 Fix signature
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 17:46:37 +02:00
jpirnay 2c9a6d7d31 One more 2026-04-28 16:09:59 +02:00
jpirnay 8a2bc9f425 Some review comments 2026-04-28 15:47:51 +02:00
jpirnay 4c8517cf67 Improve pre warm caching 2026-04-28 15:12:11 +02:00
jpirnay aa9fae16d9 Integrate upstream #1392 by adriancaruana 2026-04-28 10:00:22 +02:00
jpirnay 7bca342d0c Propert utf8 encoding 2026-04-26 22:59:02 +02:00
jpirnay 088710378b Add vietnamese (extend UI fonts) 2026-04-26 22:42:50 +02:00
jpirnay 37395f8e0f Final review 2026-04-24 19:42:18 +02:00
jpirnay a73836c594 cppcheck complains 2026-04-24 18:08:37 +02:00
jpirnay 6f31202332 Minor adjustment 2026-04-24 16:37:39 +02:00
jpirnay 7291ccacc8 Further code review 2026-04-24 16:23:14 +02:00
jpirnayandClaude Sonnet 4.6 23a77c6aa5 fontconvert.py: cap group uncompressed size at 64 KB
Script-range boundaries alone don't bound group size — dense Unicode
blocks (CJK, user fonts) can produce groups of hundreds of KB. Add a
64 KB hard cap: when adding the next glyph would exceed it, close the
current group and start a new one with the same script ID.

64 KB is well above the largest current built-in group (~50 KB for
notosans_18 Cyrillic) and a safe transient malloc on the ESP32-C3.
The decompressor side already handles any number of groups per font.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 08:36:36 +02:00
jpirnayandClaude Sonnet 4.6 e76118dff0 FontDecompressor: eliminate persistent hot group buffer
The hot group buffer (formerly up to 50 KB BSS / heap) is removed.
During prewarm, each group is now decompressed into a transient malloc
that is freed immediately after its glyphs are extracted — only one
group buffer and the page buffer coexist at a time.

For getBitmap() cache misses (rare: only hit when a glyph wasn't
covered by prewarm), the group is also decompressed transiently and
freed after the single glyph is compacted into _hotGlyphBuf.

Peak heap during prewarm is now: page buffer + one group buffer.
Outside of prewarm, heap usage is only the page buffer itself.
Works correctly for large future fonts (Vietnamese, CJK) regardless
of group size, with no permanent allocation overhead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 08:31:44 +02:00
jpirnayandClaude Sonnet 4.6 80722118a2 FontDecompressor: heap-allocate hot group buffer sized to active font
Replace the 50 KB static BSS _hotGroupBuf array with a heap pointer
allocated once per font (ensureHotGroupBuf). The buffer is sized to the
largest group in the active font — 6 KB for small fonts, ~50 KB for 18pt
— so no memory is wasted when rendering body text at typical sizes.

The buffer persists across pages (freed only in freeHotGroup/deinit),
so there is exactly one malloc per font session rather than one per page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 08:14:04 +02:00
jpirnayandClaude Sonnet 4.6 e4170e2272 Font rendering performance improvements
- FontDecompressor: replace heap-allocated std::vector hot-group and glyph
  scratch buffers with static BSS arrays, eliminating per-page malloc/free
  and the heap fragmentation it causes during rendering
- FontDecompressor: add bounds checks on group/glyph buffer sizes; sort
  prewarm group list by ascending index for sequential flash reads
- ParsedText: upgrade line-break cost function to Knuth-Plass cubic badness
  (strongly penalises very loose lines, lenient on moderate looseness)
- ParsedText: exclude gaps before closing punctuation (. , ) » etc.) from
  justification distribution so they stay at natural space width

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 07:33:39 +02:00
jpirnay 7a5242ad03 yaclf 2026-04-08 11:53:29 +02:00
Zach Nelson 86d636bf6a fix: Use differential rounding for consistent inter-glyph spacing (#1413)
**What is the goal of this PR?**

A tweak to the fixed-point x-advance and kerning calculations to ensure
that the spacing between any two glyphs is always calculated
consistently.

I noticed that sometimes I'd see common character pairs like "oo" more
than once on a page, and the distance between the two snapped to
different pixels depending on the running accumulated error for the line
of text.

This change uses a differential rounding approach where each glyph's
x-advance plus the kerning relative to the next glyph are combined in
fixed-point precision, then snapped to a pixel to draw the next glyph.
This results in a consistent inter-glyph spacing any time the same two
glyphs show up adjacent to each other, regardless of the accumulated
error across the line.

---

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

Did you use AI tools to help write this code? _**PARTIALLY**_
2026-04-08 10:40:57 +02:00
jpirnay 1398454ad8 Merge PR #1310 into master 2026-04-04 18:35:11 +02:00
Adrian Wilkins-Caruana 1a1770b06b fix: Fix prewarm perf when a page contains many styles (#1451)
## Summary

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

Fix prewarm perf when a page contains many styles.

The prewarm page buffer was a single slot, so each `prewarmCache` call
for a new font style freed the previous style's glyphs. On pages with
multiple styles (regular + bold + italic), only the last style was
prewarmed. The others fell through to the hot-group compaction path at
~2-3ms per glyph.

This was most visible on rich formatting (e.g. this [Czech prayer
book](https://stahuj.kancional.cz/e-kniha/kancional.epub) with bold
headings, italic liturgical text, and regular body), where page renders
took 3-5 seconds instead of ~700ms.

Fix: use up to 4 page buffer slots (one per font style) so all styles
stay prewarmed simultaneously.

Fixes #1450.

---

### AI Usage

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

Did you use AI tools to help write this code? PARTIALLY: to diagnose and
brainstorm solutions.
2026-03-21 12:10:41 -05:00
Adrian Wilkins-Caruana 1409259721 perf: font-compression improvements (#1056)
## Purpose

This PR includes some preparatory changes that are needed for an
upcoming performant CJK font feature. The changes have no impact on
render time and heap allocation for latin text. **Despite this, I think
these changes stand on their own as a better font
compression/decompression implementation.**

## Summary

- Font decompressor rewrite: Replaced the 4-slot LRU group cache with a
two-tier system — a page buffer (glyphs prewarmed before rendering
begins) and a hot-group fallback (last decompressed group retained for
non-prewarmed
  glyphs). 
- Byte-aligned compressed bitmap format: Glyph bitmaps within compressed
groups are now stored row-padded rather than tightly packed before
DEFLATE compression, improving compression ratios by making identical
pixel rows produce
identical byte patterns. Glyphs are compacted back to packed format on
demand at render time. Reduces flash size by 155 KB.
- Page prewarm system: Added `Page::collectText` and
`Page::getDominantStyle` to extract per-style glyph requirements before
rendering, and `GfxRenderer::prewarmFontCache` to pre-decompress only
the groups needed for the dominant style
   — eliminating mid-render decompression for the common case.
- UTF-8 robustness fixes: `utf8NextCodepoint` now validates continuation
bytes and returns a replacement glyph on malformed input;
`ChapterHtmlSlimParser` correctly preserves incomplete multi-byte
sequences across word-buffer flush
  boundaries rather than splitting them.

---

### 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**_ Architecture and
design was done by me, refined a bit by Claude. Code mostly by Claude,
but not entirely.
2026-03-11 21:05:46 +01:00
Zach Nelson 1119030bb0 fix: Use fixed-point fractional x-advance and kerning for better text layout (#1168)
## Summary

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

Hopefully fixes #1182.

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

#### Problem

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

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

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

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

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

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

**How it works:**

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

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

Two storage widths share the same 4 fractional bits:

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

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

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

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

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

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

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

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

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

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

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

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

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

#### Changes

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

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

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

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

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

#### Memory impact

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

#### Test plan

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

---

### AI Usage

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

Did you use AI tools to help write this code? _**YES, Claude Opus 4.6
helped figure out a non-floating point approach for sub-pixel error
accumulation**_
2026-03-01 10:43:37 -06:00
Zach Nelson ed619e5d1e refactor: Use std binary search algorithms for font lookups (#1202)
## Summary

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

Rewrite of font routines to use std binary search algorithms instead of
custom repeated implementations: `lookupKernClass`,
`EpdFont::getLigature`, and `EpdFont::getGlyph`.

---

### 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? _**NO**_
2026-03-01 10:28:15 -06:00
Zach Nelson 6cea2d2179 feat: Latin Extended-B European glyphs (#1167)
## Summary

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

Correction to #1157, which (embarrassingly) failed to actually include
the updated font header files. (Maybe we should generate these at build
time?)

Add Latin Extended-B glyphs for Croatian, Romanian, Pinyin, and European
diacritical variants. Fixes #921.

---

### AI Usage

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

Did you use AI tools to help write this code? _**PARTIALLY, confirmed
codepoint ranges with Claude**_
2026-02-25 12:56:11 +03:00
Zach Nelson 65b6bd536c feat: Latin Extended-B European glyphs (#1157)
## Summary

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

Add Latin Extended-B glyphs for Croatian, Romanian, Pinyin, and European
diacritical variants. Fixes #921.

---

### AI Usage

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

Did you use AI tools to help write this code? _**PARTIALLY, confirmed
codepoint ranges with Claude**_
2026-02-24 16:44:20 -06:00
danoobanddanoooob fecf57cbbc feat: Vietnamese glyphs support (#1147)
## Summary

* **What is the goal of this PR?**
Add Vietnamese glyphs support for the reader's built-in fonts, enabling
proper rendering of Vietnamese text in EPUB content.

* **What changes are included?**
- Added 3 new Unicode intervals to `fontconvert.py` covering Vietnamese
characters:
- **Latin Extended-B** (Vietnamese subset only): `U+01A0–U+01B0` — Ơ/ơ,
Ư/ư
- **Vietnamese Extended**: `U+1EA0–U+1EF9` — All precomposed Vietnamese
characters with tone marks (Ả, Ấ, Ầ, Ẩ, Ẫ, Ậ, Ắ, …, Ỹ)
- Re-generated all 54 built-in font header files (Bookerly, Noto Sans,
OpenDyslexic, Ubuntu across all sizes and styles) to include the new
Vietnamese glyphs.

## Additional Context

* **Scope**: This PR only covers the **reader** fonts. The outer UI
still uses the Ubuntu font which does not fully support Vietnamese — UI
and i18n will be addressed in a follow-up PR (per discussion in PR
#1124).
* **Memory impact**:

  | Metric | Before | After | Delta |
  |---|---|---|---|
| Flash Data (`.rodata`) | 2,971,028 B | 3,290,748 B | **+319,720 B
(+10.8%)** |
| Total image size | 4,663,235 B | 4,982,955 B | **+319,720 B (+6.9%)**
|
  | Flash usage | 69.1% | 74.0% | **+4.9 pp** |
  | RAM usage | 29.0% | 29.0% | **No change** |

* **Risk**: Low — this is a data-only change (font glyph tables in
`.rodata`). No logic changes, no RAM impact. Flash headroom remains
comfortable at 74%.

---

### AI Usage

Did you use AI tools to help write this code? _**PARTIALLY**_

AI was used to identify the minimal set of Unicode ranges needed for
Vietnamese support and to assist with the PR description.

---------

Co-authored-by: danoooob <danoooob@example.com>
2026-02-24 11:21:39 -06:00
Zach NelsonandCursor d391904f86 feat: Support for kerning and ligatures (#873)
## Summary

**What is the goal of this PR?**
Improved typesetting, including
[kerning](https://en.wikipedia.org/wiki/Kerning) and
[ligatures](https://en.wikipedia.org/wiki/Ligature_(writing)#Latin_alphabet).

**What changes are included?**
- The script to convert built-in fonts now adds kerning and ligature
information to the generated font headers.
- Epub page layout calculates proper kerning spaces and makes ligature
substitutions according to the selected font.


![3U1B1808](https://github.com/user-attachments/assets/1accb16f-2f1a-41e5-adca-89f1f1348494)

![3U1B1810](https://github.com/user-attachments/assets/2f6bd007-490e-420f-b774-3380b4add7ea)

![3U1B1815](https://github.com/user-attachments/assets/1986bb77-2db0-46e2-a5d6-8315dae9eb19)

## Additional Context

- I am not a typography expert. 
- The implementation has been reworked from the earlier version, so it
is no longer necessary to omit Open Dyslexic, and kerning data now
covers all fonts, styles, and codepoints for which we include bitmap
data.
- Claude Opus 4.6 helped with a lot of this.
- There's an included test epub document with lots of kerning and
ligature examples, shown in the photos.

**_After some time to mature, I think this change is in decent shape to
merge and get people testing._**

After opening this PR I came across #660, which overlaps in adding
ligature support.

---

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 11:31:43 +03:00
Adrian Wilkins-Caruana 4551cd9cad fix: force auto-hinting for Bookerly to fix inconsistent stem widths (#1098)
## Summary

Bookerly's native TrueType hinting is effectively a no-op at the sizes
used here, causing FreeType to place stems at inconsistent sub-pixel
positions. This results in the 'k' stem (8-bit fringe: 0x38=56) falling
just below the 2-bit quantization threshold while 'l' and 'h' stems
(fringes: 0x4C=76, 0x40=64) land above it --- making 'k' visibly
narrower (2.00px vs 2.33px effective width).

FreeType's auto-hinter snaps all stems to consistent grid positions,
normalizing effective stem width to 2.67px across all glyphs.

Adds --force-autohint flag to fontconvert.py and applies it to Bookerly
only. NotoSans, OpenDyslexic, and Ubuntu fonts are unaffected.

Here is an example of before/after. Take notice of the vertical stems on
characters like `l`, `k`, `n`, `i`, etc. The font is Bookerly 12pt
regular:

**BEFORE**:

![before](https://github.com/user-attachments/assets/65b2acab-ad95-489e-885e-e3a0163cc252)

**AFTER**:


![after](https://github.com/user-attachments/assets/d09a8b5d-40af-4a7d-b622-e1b2cabcce85)

Claude generated this script to quantitatively determine that this
change makes the vertical stems on a variety of characters more
consistent for Bookerly _only_.

<details>
  <summary>Python script</summary>
    
  ```python
#!/usr/bin/env python3
"""Compare stem consistency across all font families with and without
auto-hinting.

Run from repo root:
    python3 compare_all_fonts.py
"""

import freetype

DPI = 150
CHARS = ["k", "l", "h", "i", "b", "d"]
SIZES = [12, 14, 16, 18]

FONTS = {
"Bookerly":
"lib/EpdFont/builtinFonts/source/Bookerly/Bookerly-Regular.ttf",
"NotoSans":
"lib/EpdFont/builtinFonts/source/NotoSans/NotoSans-Regular.ttf",
"OpenDyslexic":
"lib/EpdFont/builtinFonts/source/OpenDyslexic/OpenDyslexic-Regular.otf",
"Ubuntu": "lib/EpdFont/builtinFonts/source/Ubuntu/Ubuntu-Regular.ttf",
}

MODES = {
    "default": freetype.FT_LOAD_RENDER,
"autohint": freetype.FT_LOAD_RENDER | freetype.FT_LOAD_FORCE_AUTOHINT,
}


def q4to2(v):
    if v >= 12:
        return 3
    elif v >= 8:
        return 2
    elif v >= 4:
        return 1
    else:
        return 0


def get_stem_eff(face, char, flags):
    gi = face.get_char_index(ord(char))
    if gi == 0:
        return None
    face.load_glyph(gi, flags)
    bm = face.glyph.bitmap
    w, h = bm.width, bm.rows
    if w == 0 or h == 0:
        return None

    p2 = []
    for y in range(h):
        row = []
        for x in range(w):
            row.append(q4to2(bm.buffer[y * bm.pitch + x] >> 4))
        p2.append(row)

    # Measure leftmost stem in stable middle rows
    mid_start, mid_end = h // 4, h - h // 4
    widths = []
    for y in range(mid_start, mid_end):
        first = next((x for x in range(w) if p2[y][x] > 0), -1)
        if first < 0:
            continue
        last = first
        for x in range(first, w):
            if p2[y][x] > 0:
                last = x
            else:
                break
        eff = sum(p2[y][x] for x in range(first, last + 1)) / 3.0
        widths.append(eff)
    return round(sum(widths) / len(widths), 2) if widths else None


def main():
    for font_name, font_path in FONTS.items():
        try:
            freetype.Face(font_path)
        except Exception:
            print(f"\n  {font_name}: SKIPPED (file not found)")
            continue

        print(f"\n{'=' * 80}")
        print(f"  {font_name}")
        print(f"{'=' * 80}")

        for size in SIZES:
            print(f"\n  {size}pt:")
            print(f"  {'':6s}", end="")
            for c in CHARS:
                print(f"  '{c}'  ", end="")
            print("  | spread")

            for mode_name, flags in MODES.items():
                face = freetype.Face(font_path)
                face.set_char_size(size << 6, size << 6, DPI, DPI)
                vals = []
                print(f"  {mode_name:6s}", end="")
                for c in CHARS:
                    v = get_stem_eff(face, c, flags)
                    vals.append(v)
                    print(f"  {v:5.2f}" if v else "    N/A", end="")

                valid = [v for v in vals if v is not None]
spread = max(valid) - min(valid) if len(valid) >= 2 else 0
                marker = " <-- inconsistent" if spread > 0.5 else ""
                print(f"  | {spread:.2f}{marker}")


if __name__ == "__main__":
    main()

  ```
  
</details>

Here are the results. The table compares how the font-generation
`autohint` flag affects the range of widths of various characters. Lower
`spread` mean that glyph stroke widths should appear more consistent.
```
    Spread = max stem width - min stem width across glyphs (lower = more consistent):                                                          
                                                                                                                                               
    ┌──────────────┬──────┬─────────┬──────────┬──────────┐                                                                                    
    │     Font     │ Size │ Default │ Autohint │  Winner  │                                                                                    
    ├──────────────┼──────┼─────────┼──────────┼──────────┤                                                                                    
    │ Bookerly     │ 12pt │ 1.49    │ 1.12     │ autohint │                                                                                    
    ├──────────────┼──────┼─────────┼──────────┼──────────┤                                                                                    
    │              │ 14pt │ 1.39    │ 1.13     │ autohint │                                                                                    
    ├──────────────┼──────┼─────────┼──────────┼──────────┤                                                                                    
    │              │ 16pt │ 1.38    │ 1.16     │ autohint │                                                                                    
    ├──────────────┼──────┼─────────┼──────────┼──────────┤                                                                                    
    │              │ 18pt │ 1.90    │ 1.58     │ autohint │                                                                                    
    ├──────────────┼──────┼─────────┼──────────┼──────────┤                                                                                    
    │ NotoSans     │ 12pt │ 1.16    │ 0.94     │ mixed    │                                                                                    
    ├──────────────┼──────┼─────────┼──────────┼──────────┤                                                                                    
    │              │ 14pt │ 0.83    │ 1.14     │ default  │                                                                                    
    ├──────────────┼──────┼─────────┼──────────┼──────────┤                                                                                    
    │              │ 16pt │ 1.41    │ 1.51     │ default  │                                                                                    
    ├──────────────┼──────┼─────────┼──────────┼──────────┤                                                                                    
    │              │ 18pt │ 1.74    │ 1.63     │ mixed    │                                                                                    
    ├──────────────┼──────┼─────────┼──────────┼──────────┤                                                                                    
    │ OpenDyslexic │ 12pt │ 2.22    │ 1.44     │ autohint │                                                                                    
    ├──────────────┼──────┼─────────┼──────────┼──────────┤                                                                                    
    │              │ 14pt │ 2.57    │ 3.29     │ default  │                                                                                    
    ├──────────────┼──────┼─────────┼──────────┼──────────┤                                                                                    
    │              │ 16pt │ 3.13    │ 2.60     │ autohint │                                                                                    
    ├──────────────┼──────┼─────────┼──────────┼──────────┤                                                                                    
    │              │ 18pt │ 3.21    │ 3.23     │ ~tied    │                                                                                    
    ├──────────────┼──────┼─────────┼──────────┼──────────┤                                                                                    
    │ Ubuntu       │ 12pt │ 1.25    │ 1.31     │ default  │                                                                                    
    ├──────────────┼──────┼─────────┼──────────┼──────────┤                                                                                    
    │              │ 14pt │ 1.41    │ 1.64     │ default  │                                                                                    
    ├──────────────┼──────┼─────────┼──────────┼──────────┤                                                                                    
    │              │ 16pt │ 2.21    │ 1.71     │ autohint │                                                                                    
    ├──────────────┼──────┼─────────┼──────────┼──────────┤                                                                                    
    │              │ 18pt │ 1.80    │ 1.71     │ autohint │                                                                                    
    └──────────────┴──────┴─────────┴──────────┴──────────┘                                                                                    
```


---

### 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? I used AI to make sure I'm
not doing something stupid, since I'm not a typography expert. I made
the changes though.
2026-02-23 22:13:08 +03:00
Zach Nelson e18f0a3f60 refactor: Simplify REPLACEMENT_GLYPH fallback (#1119)
## Summary

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

Consolidated repeated logic to fall back to REPLACEMENT_GLYPH.

---

### 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? _**NO**_
2026-02-23 13:32:50 +01:00
Dave Allie d14623ac6e chore: Remove miniz and modularise inflation logic (#1073)
## Summary

* Remove miniz and move completely to uzlib
* Move uzlib interfacing to InflateReader to better modularise inflation
code

---

### 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 helped with
the extraction and refactor
2026-02-22 21:38:03 +11:00
jpirnay c0ed60daa6 fix: Fix hyphenation and rendering of decomposed characters (#1037)
## Summary

* This PR fixes decomposed diacritic handling end-to-end:
- Hyphenation: normalize common Latin base+combining sequences to
precomposed codepoints before Liang pattern matching, so decomposed
words hyphenate correctly
- Rendering: correct combining-mark placement logic so non-spacing marks
are attached to the preceding base glyph in normal and rotated text
rendering paths, with corresponding text-bounds consistency updates.
- Hyphenation around non breaking space variants have been fixed (and
extended)
- Hyphenation of terms that already included of hyphens were fixed to
include Liang pattern application (eg "US-Satellitensystem" was
*exclusively* broken at the existing hyphen)

## Additional Context

* Before
<img width="800" height="480" alt="2"
src="https://github.com/user-attachments/assets/b9c515c4-ab75-45cc-8b52-f4d86bce519d"
/>


* After
<img width="480" height="800" alt="fix1"
src="https://github.com/user-attachments/assets/4999f6a8-f51c-4c0a-b144-f153f77ddb57"
/>
<img width="800" height="480" alt="fix2"
src="https://github.com/user-attachments/assets/7355126b-80c7-441f-b390-4e0897ee3fb6"
/>

* Note 1: the hyphenation fix is not a 100% bullet proof implementation.
It adds composition of *common* base+combining sequences (e.g. O +
U+0308 -> Ö) during codepoint collection. A complete solution would
require implementing proper Unicode normalization (at least NFC,
possibly NFKC in specific cases) before hyphenation and rendering,
instead of hand-mapping a few combining marks. That was beyond the scope
of this fix.

* Note 2: the render fix should be universal and not limited to the
constraints outlined above: it properly x-centers the compund glyph over
the previous one, and it uses at least 1pt of visual distance in y.

Before:
<img width="478" height="167" alt="Image"
src="https://github.com/user-attachments/assets/f8db60d5-35b1-4477-96d0-5003b4e4a2a1"
/>

After: 
<img width="479" height="180" alt="Image"
src="https://github.com/user-attachments/assets/1b48ef97-3a77-475a-8522-23f4aca8e904"
/>

* This should resolve the issues described in #998 
---

### AI Usage

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

Did you use AI tools to help write this code? _**PARTIALLY**_
2026-02-22 13:11:07 +11:00
Zach Nelson 0816915575 perf: Remove hasPrintableChars pass (#971)
## Summary

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

`hasPrintableChars` does a pass over text before rendering. It looks up
glyphs in the font and measures dimensions, returning early if the text
results in zero size.

This additional pass doesn't offer any benefit over moving straight to
rendering the text, because the rendering loop already gracefully
handles missing glyphs. This change saves an extra pass over all
rendered text.

Note that both `hasPrintableChars` and `renderChar` replace missing
glyphs with `glyph = getGlyph(REPLACEMENT_GLYPH)`, so there's no
difference for characters which are not present in the font.

---

### 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? _**NO**_
2026-02-19 21:58:09 +11:00