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>
This commit is contained in:
Zach Nelson
2026-02-24 11:31:43 +03:00
committed by GitHub
co-authored by Cursor
parent 13592db50f
commit 0eb8a9346b
67 changed files with 80729 additions and 36388 deletions
+92 -5
View File
@@ -20,26 +20,36 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
int lastBaseX = startX;
int lastBaseAdvance = 0;
int lastBaseTop = 0;
bool hasBaseGlyph = false;
constexpr int MIN_COMBINING_GAP_PX = 1;
uint32_t cp;
uint32_t prevCp = 0;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&string)))) {
const bool isCombining = utf8IsCombiningMark(cp);
if (!isCombining) {
cp = applyLigatures(cp, string);
}
const EpdGlyph* glyph = getGlyph(cp);
if (!glyph) {
// TODO: Better handle this?
prevCp = 0;
continue;
}
const bool isCombining = utf8IsCombiningMark(cp);
int raiseBy = 0;
if (isCombining && hasBaseGlyph) {
if (isCombining) {
const int currentGap = glyph->top - glyph->height - lastBaseTop;
if (currentGap < MIN_COMBINING_GAP_PX) {
raiseBy = MIN_COMBINING_GAP_PX - currentGap;
}
}
const int glyphBaseX = (isCombining && hasBaseGlyph) ? (lastBaseX + lastBaseAdvance / 2) : cursorX;
if (!isCombining && prevCp != 0) {
cursorX += getKerning(prevCp, cp);
}
const int glyphBaseX = isCombining ? (lastBaseX + lastBaseAdvance / 2) : cursorX;
const int glyphBaseY = cursorY - raiseBy;
*minX = std::min(*minX, glyphBaseX + glyph->left);
@@ -51,8 +61,8 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
lastBaseX = cursorX;
lastBaseAdvance = glyph->advanceX;
lastBaseTop = glyph->top;
hasBaseGlyph = true;
cursorX += glyph->advanceX;
prevCp = cp;
}
}
}
@@ -66,6 +76,83 @@ void EpdFont::getTextDimensions(const char* string, int* w, int* h) const {
*h = maxY - minY;
}
static uint8_t lookupKernClass(const EpdKernClassEntry* entries, const uint16_t count, const uint32_t cp) {
if (!entries || count == 0 || cp > 0xFFFF) {
return 0;
}
const auto target = static_cast<uint16_t>(cp);
int left = 0;
int right = static_cast<int>(count) - 1;
while (left <= right) {
const int mid = left + (right - left) / 2;
const uint16_t midCp = entries[mid].codepoint;
if (midCp == target) {
return entries[mid].classId;
}
if (midCp < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return 0;
}
int8_t EpdFont::getKerning(const uint32_t leftCp, const uint32_t rightCp) const {
if (!data->kernMatrix) {
return 0;
}
const uint8_t lc = lookupKernClass(data->kernLeftClasses, data->kernLeftEntryCount, leftCp);
if (lc == 0) return 0;
const uint8_t rc = lookupKernClass(data->kernRightClasses, data->kernRightEntryCount, rightCp);
if (rc == 0) return 0;
return data->kernMatrix[(lc - 1) * data->kernRightClassCount + (rc - 1)];
}
uint32_t EpdFont::getLigature(const uint32_t leftCp, const uint32_t rightCp) const {
const auto* pairs = data->ligaturePairs;
const auto count = data->ligaturePairCount;
if (!pairs || count == 0 || leftCp > 0xFFFF || rightCp > 0xFFFF) {
return 0;
}
const uint32_t key = (leftCp << 16) | rightCp;
int left = 0;
int right = static_cast<int>(count) - 1;
while (left <= right) {
const int mid = left + (right - left) / 2;
const uint32_t midKey = pairs[mid].pair;
if (midKey == key) {
return pairs[mid].ligatureCp;
}
if (midKey < key) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return 0;
}
uint32_t EpdFont::applyLigatures(uint32_t cp, const char*& text) const {
if (!data->ligaturePairs || data->ligaturePairCount == 0) {
return cp;
}
while (true) {
const auto saved = reinterpret_cast<const uint8_t*>(text);
const uint32_t nextCp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text));
if (nextCp == 0) break;
const uint32_t lig = getLigature(cp, nextCp);
if (lig == 0) {
text = reinterpret_cast<const char*>(saved);
break;
}
cp = lig;
}
return cp;
}
const EpdGlyph* EpdFont::getGlyph(const uint32_t cp) const {
const EpdUnicodeInterval* intervals = data->intervals;
const int count = data->intervalCount;
+12
View File
@@ -11,4 +11,16 @@ class EpdFont {
void getTextDimensions(const char* string, int* w, int* h) const;
const EpdGlyph* getGlyph(uint32_t cp) const;
/// Returns the kerning adjustment (in pixels) between two codepoints.
/// Returns 0 if no kerning data exists for the pair.
int8_t getKerning(uint32_t leftCp, uint32_t rightCp) const;
/// Returns the ligature codepoint for a pair, or 0 if no ligature exists.
uint32_t getLigature(uint32_t leftCp, uint32_t rightCp) const;
/// Greedily applies ligature substitutions starting from cp, consuming
/// as many following codepoints from text as possible. Returns the
/// (possibly substituted) codepoint; advances text past consumed chars.
uint32_t applyLigatures(uint32_t cp, const char*& text) const;
};
+25 -2
View File
@@ -31,6 +31,20 @@ typedef struct {
uint32_t offset; ///< Index of the first code point into the glyph array
} EpdUnicodeInterval;
/// Maps a codepoint to a kerning class ID, sorted by codepoint for binary search.
/// Class IDs are 1-based; codepoints not in the table have implicit class 0 (no kerning).
typedef struct {
uint16_t codepoint; ///< Unicode codepoint
uint8_t classId; ///< 1-based kerning class ID
} __attribute__((packed)) EpdKernClassEntry;
/// Ligature substitution for a specific glyph pair, sorted by `pair` for binary search.
/// `pair` encodes (leftCodepoint << 16 | rightCodepoint) for single-key lookup.
typedef struct {
uint32_t pair; ///< Packed codepoint pair (left << 16 | right)
uint32_t ligatureCp; ///< Codepoint of the replacement ligature glyph
} __attribute__((packed)) EpdLigaturePair;
/// Data stored for FONT AS A WHOLE
typedef struct {
const uint8_t* bitmap; ///< Glyph bitmaps, concatenated
@@ -41,6 +55,15 @@ typedef struct {
int ascender; ///< Maximal height of a glyph above the base line
int descender; ///< Maximal height of a glyph below the base line
bool is2Bit;
const EpdFontGroup* groups; ///< NULL for uncompressed fonts
uint16_t groupCount; ///< 0 for uncompressed fonts
const EpdFontGroup* groups; ///< NULL for uncompressed fonts
uint16_t groupCount; ///< 0 for uncompressed fonts
const EpdKernClassEntry* kernLeftClasses; ///< Sorted left-side class map (nullptr if none)
const EpdKernClassEntry* kernRightClasses; ///< Sorted right-side class map (nullptr if none)
const int8_t* kernMatrix; ///< Flat leftClassCount x rightClassCount matrix
uint16_t kernLeftEntryCount; ///< Entries in kernLeftClasses
uint16_t kernRightEntryCount; ///< Entries in kernRightClasses
uint8_t kernLeftClassCount; ///< Number of distinct left classes (matrix rows)
uint8_t kernRightClassCount; ///< Number of distinct right classes (matrix cols)
const EpdLigaturePair* ligaturePairs; ///< Sorted ligature pair table (nullptr if none)
uint32_t ligaturePairCount; ///< Number of entries in ligaturePairs
} EpdFontData;
+9 -1
View File
@@ -26,4 +26,12 @@ const EpdFontData* EpdFontFamily::getData(const Style style) const { return getF
const EpdGlyph* EpdFontFamily::getGlyph(const uint32_t cp, const Style style) const {
return getFont(style)->getGlyph(cp);
};
}
int8_t EpdFontFamily::getKerning(const uint32_t leftCp, const uint32_t rightCp, const Style style) const {
return getFont(style)->getKerning(leftCp, rightCp);
}
uint32_t EpdFontFamily::applyLigatures(const uint32_t cp, const char*& text, const Style style) const {
return getFont(style)->applyLigatures(cp, text);
}
+2
View File
@@ -12,6 +12,8 @@ class EpdFontFamily {
void getTextDimensions(const char* string, int* w, int* h, Style style = REGULAR) const;
const EpdFontData* getData(Style style = REGULAR) const;
const EpdGlyph* getGlyph(uint32_t cp, Style style = REGULAR) const;
int8_t getKerning(uint32_t leftCp, uint32_t rightCp, Style style = REGULAR) const;
uint32_t applyLigatures(uint32_t cp, const char*& text, Style style = REGULAR) const;
private:
const EpdFont* regular;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More