feat(core): improve kerning data structure
What changed, and why it matters
This commit refactors how font kerning (fine-tuning space between letter pairs) is stored and looked up in the Trezor firmware. It changes the data structure from a flat list of triplets to a two-level index/pairs table, adds a Rust feature flag to enable or disable kerning, and updates the font generation tools to extract kerning from modern OpenType GPOS tables. The changes are primarily a data-format and build-tooling improvement, not a fix for a known vulnerability. The commit does not describe itself as security-related.
Treat as a routine feature/refactor commit. Review the new `KerningTable::new` parser for any edge cases in `align_to` usage and ensure the Python serializer and Rust parser agree on endianness, padding, and maximum counts. No urgent security action is indicated by the diff itself.
Security signals we found
New binary parsing code in `blob.rs` uses `unsafe { align_to::<...>() }` with `#[repr(C, packed)]` structs and validates prefix/suffix alignment and byte counts before accepting the table.
Validation now rejects V0/V1 blobs that contain unexpected trailing kerning payload, and only accepts kerning for V2 blobs.
Input bounds are checked against explicit `index_count`, `pair_count`, and `data_bytes` fields; mismatches return `INVALID_TRANSLATIONS_BLOB`.
Kerning values are constrained to signed 8-bit range during Python serialization.
Codepoint values are constrained to 16-bit range during Python serialization.
The commit is tagged `[no changelog]` and the changelog fragment describes it as a feature ('Introduced font kerning'), not a security fix.
Evidence from the diff
The patch introduces a new ui_font_kerning Cargo feature and gated code paths. In blob.rs, KerningTable is restructured from a flat slice of (u16, u16, i8, u8) triplets to a sorted two-level table: an index of (left_cp, count) entries and a flat pairs array of (right_cp, value, pad). Parsing now validates an outer data_bytes prefix against computed index/pair sizes. Translations::new only parses/validates kerning data for BlobMagic::V2 blobs and rejects trailing payload for V0/V1. FontInfo.kernings becomes an Option<&'static KerningTable> behind the feature flag. The Python tooling (translations.py, gen_font.py) was updated to group kerning pairs by left codepoint, serialize the new binary format, and prefer GPOS kerning over the legacy FreeType kern table. ASCII-only pairs are still handled in generated Rust code; non-ASCII pairs go through the translations blob.
Changed components
core/embed/rust/src/translations/blob.rscore/embed/rust/src/ui/display/font.rscore/embed/rust/src/ui/component/text/layout.rscore/embed/rust/src/ui/shape/text.rscore/tools/codegen/gen_font.pycore/tools/codegen/gen_font.makopython/src/trezorlib/_internal/translations.pycore/embed/rust/Cargo.tomlcore/site_scons/ui/ui_delizia.pycore/site_scons/ui/ui_eckhart.pyInspect captured patch +540 / −138
diff --git a/core/.changelog.d/6620.added b/core/.changelog.d/6620.added
new file mode 100644
index 00000000..20b3d467
--- /dev/null
+++ b/core/.changelog.d/6620.added
@@ -0,0 +1 @@
+[T3T1,T3W1] Introduced font kerning.
diff --git a/core/embed/rust/Cargo.toml b/core/embed/rust/Cargo.toml
index 31170e5c..fa78610a 100644
--- a/core/embed/rust/Cargo.toml
+++ b/core/embed/rust/Cargo.toml
@@ -30,6 +30,7 @@ ui_color_32bit = []
ui_overlay = []
ui_empty_lock = []
ui_jpeg = []
+ui_font_kerning = []
hw_jpeg_decoder = []
boot_ucb = []
bootloader = []
@@ -79,6 +80,7 @@ test = [
"translations",
"ui",
"ui_jpeg",
+ "ui_font_kerning",
"ui_blurring",
"ui_image_buffer",
"ui_overlay",
diff --git a/core/embed/rust/src/translations/blob.rs b/core/embed/rust/src/translations/blob.rs
index 554c90c0..b9ed0cd1 100644
--- a/core/embed/rust/src/translations/blob.rs
+++ b/core/embed/rust/src/translations/blob.rs
@@ -60,25 +60,92 @@ fn validate_offset_table(
Ok(())
}
+#[cfg(feature = "ui_font_kerning")]
+#[derive(Clone, Copy)]
+#[repr(C, packed)]
+struct KernIndexEntry {
+ left_cp: u16,
+ count: u16,
+}
+
+#[cfg(feature = "ui_font_kerning")]
+#[derive(Clone, Copy)]
+#[repr(C, packed)]
+struct KernPair {
+ right_cp: u16,
+ value: i8,
+ _pad: u8,
+}
+
+#[cfg(feature = "ui_font_kerning")]
+/// Two-level kerning table stored in the translations blob.
+/// Binary layout (after the outer BlobTable slice):
+/// [u16 data_bytes] (outer KerningList length prefix)
+/// [u16 index_count]
+/// [KernIndexEntry] × index_count (4 bytes each, sorted by left_cp)
+/// [u16 pair_count]
+/// [KernPair] × pair_count (4 bytes each)
+///
+/// Start offset in pairs for entry i is the sum of counts for all preceding
+/// entries.
pub struct KerningTable<'a> {
- triplets: &'a [(u16, u16, i8, u8)],
+ index: &'a [KernIndexEntry],
+ pairs: &'a [KernPair],
}
+#[cfg(feature = "ui_font_kerning")]
impl<'a> KerningTable<'a> {
pub fn new(mut reader: InputStream<'a>) -> Result<Self, Error> {
- let count = reader.read_u16_le()?;
- let triplets_len: usize = count.into();
- let triplets_data =
- reader.read((triplets_len / 6) * mem::size_of::<(u16, u16, i8, u8)>())?;
-
- // SAFETY: (u16, u16, i8, u8) is repr(packed) of 6 bytes, so any 6 bytes are
- // a valid (u16, u16, i8, u8) value.
- let (_prefix, triplets, _suffix) =
- unsafe { triplets_data.align_to::<(u16, u16, i8, u8)>() };
+ // First u16 is the outer byte-length prefix written by KerningList.SUBCON.
+ // Used to cross-validate the explicit index_count and pair_count fields.
+ let data_bytes: usize = reader.read_u16_le()?.into();
+ let index_count: usize = reader.read_u16_le()?.into();
+
+ let index_size = index_count * mem::size_of::<KernIndexEntry>();
+
+ let index_data = reader.read(index_size)?;
+ // SAFETY: KernIndexEntry is #[repr(C, packed)] with size 4, align 1.
+ let (_prefix, index, _suffix) = unsafe { index_data.align_to::<KernIndexEntry>() };
if !_prefix.is_empty() || !_suffix.is_empty() {
return Err(INVALID_TRANSLATIONS_BLOB);
}
- Ok(Self { triplets })
+
+ let pair_count: usize = reader.read_u16_le()?.into();
+ let pairs_size = pair_count * mem::size_of::<KernPair>();
+
+ // Validate that data_bytes is consistent with the explicit counts.
+ let expected_data_bytes = 2 + index_size + 2 + pairs_size;
+ if data_bytes != expected_data_bytes {
+ return Err(INVALID_TRANSLATIONS_BLOB);
+ }
+
+ let pairs_data = reader.read(pairs_size)?;
+ // SAFETY: KernPair is #[repr(C, packed)] with size 4, align 1.
+ let (_prefix, pairs, _suffix) = unsafe { pairs_data.align_to::<KernPair>() };
+ if !_prefix.is_empty() || !_suffix.is_empty() {
+ return Err(INVALID_TRANSLATIONS_BLOB);
+ }
+
+ Ok(Self { index, pairs })
+ }
+
+ pub fn get(&self, left_cp: u16, right_cp: u16) -> Option<i8> {
+ let mut offset = 0usize;
+ for &entry in self.index {
+ if entry.left_cp == left_cp {
+ for &pair in &self.pairs[offset..offset + entry.count as usize] {
+ if pair.right_cp == right_cp {
+ return Some(pair.value);
+ }
+ }
+ return None;
+ }
+ if entry.left_cp > left_cp {
+ break; // index is sorted
+ }
+ offset += entry.count as usize;
+ }
+ None
}
}
@@ -188,6 +255,7 @@ pub struct Translations<'a> {
header: TranslationsHeader<'a>,
chunks: Vec<TranslationStringsChunk<'a>, MAX_TRANSLATION_CHUNKS>,
fonts: Table<'a>,
+ #[cfg(feature = "ui_font_kerning")]
kernings: Table<'a>,
}
@@ -231,33 +299,44 @@ impl<'a> Translations<'a> {
font_table.validate()?;
}
- let kernings = if payload_reader.remaining() > 0 {
- let kerning_reader = read_u16_prefixed_block(&mut payload_reader)?;
+ let _kernings = match header.blob_magic {
+ BlobMagic::V2 if payload_reader.remaining() > 0 => {
+ let kerning_reader = read_u16_prefixed_block(&mut payload_reader)?;
- // construct and validate kerning table
- let kernings = Table::new(kerning_reader)?;
- kernings.validate()?;
+ // construct and validate kerning table
+ let kernings = Table::new(kerning_reader)?;
+ kernings.validate()?;
- // Validate by parsing the kernings table
- for (_, kern_data) in kernings.iter() {
- let reader = InputStream::new(kern_data);
- KerningTable::new(reader)?;
- }
+ // Validate by parsing the kernings table
+ #[cfg(feature = "ui_font_kerning")]
+ for (_, kern_data) in kernings.iter() {
+ let reader = InputStream::new(kern_data);
+ KerningTable::new(reader)?;
+ }
- kernings
- } else {
- // Create empty kerning table when no kerning data is present
- Table {
+ kernings
+ }
+ BlobMagic::V0 | BlobMagic::V1 => {
+ if payload_reader.remaining() > 0 {
+ return Err(INVALID_TRANSLATIONS_BLOB);
+ }
+ Table {
+ offsets: &[],
+ data: &[],
+ }
+ }
+ _ => Table {
offsets: &[],
data: &[],
- }
+ },
};
Ok(Self {
header,
chunks,
fonts,
- kernings,
+ #[cfg(feature = "ui_font_kerning")]
+ kernings: _kernings,
})
}
@@ -343,6 +422,7 @@ impl<'a> Translations<'a> {
/// string not to the underlying data, but to the _reference_ to the
/// translations object. This is to facilitate safe interface to
/// flash-based translations. See docs for `flash::get` for details.
+ #[cfg(feature = "ui_font_kerning")]
#[allow(clippy::needless_lifetimes)]
pub fn get_utf8_kernings<'b>(
&'b self,
@@ -353,13 +433,7 @@ impl<'a> Translations<'a> {
self.kernings.get(font_index).and_then(|data: &'a [u8]| {
let reader = InputStream::new(data);
let kern_table = KerningTable::new(reader).ok()?;
-
- for triplet in kern_table.triplets {
- if triplet.0 == left_codepoint && triplet.1 == right_codepoint {
- return Some(triplet.2);
- }
- }
- None
+ kern_table.get(left_codepoint, right_codepoint)
})
}
}
@@ -547,8 +621,7 @@ impl<'a> TranslationsHeader<'a> {
) -> Result<Vec<TranslationStringsChunk<'a>, MAX_TRANSLATION_CHUNKS>, Error> {
let chunks_count = match self.blob_magic {
BlobMagic::V0 => 1,
- BlobMagic::V1 => reader.read_u16_le()?.into(),
- BlobMagic::V2 => reader.read_u16_le()?.into(),
+ BlobMagic::V1 | BlobMagic::V2 => reader.read_u16_le()?.into(),
};
if chunks_count > MAX_TRANSLATION_CHUNKS {
return Err(Error::OutOfRange);
diff --git a/core/embed/rust/src/ui/component/text/layout.rs b/core/embed/rust/src/ui/component/text/layout.rs
index 24bfcc22..abd7db80 100644
--- a/core/embed/rust/src/ui/component/text/layout.rs
+++ b/core/embed/rust/src/ui/component/text/layout.rs
@@ -830,6 +830,10 @@ mod tests {
fn text_width(&self, text: &str) -> i16 {
self.width * text.len() as i16
}
+
+ fn get_kerning(&self, _left: char, _right: char) -> i16 {
+ 0
+ }
}
const FIXED_FONT: Fixed = Fixed {
diff --git a/core/embed/rust/src/ui/display/font.rs b/core/embed/rust/src/ui/display/font.rs
index a284646a..13d1d3b9 100644
--- a/core/embed/rust/src/ui/display/font.rs
+++ b/core/embed/rust/src/ui/display/font.rs
@@ -12,6 +12,41 @@ use crate::translations::flash;
#[cfg(feature = "translations")]
use crate::translations::Translations;
+#[cfg(feature = "ui_font_kerning")]
+/// Two-level kerning lookup table.
+/// `index` is sorted by left character, enabling binary search.
+/// Each entry stores `(left_char, count)` — the number of right-char pairs that
+/// follow. The start offset in `pairs` is the sum of counts for all preceding
+/// index entries.
+#[derive(PartialEq, Eq)]
+pub struct KerningTable {
+ pub index: &'static [(u8, u8)], // (left_char, count), sorted by left_char
+ pub pairs: &'static [(u8, i8)], // (right_char, kern_value)
+}
+
+#[cfg(feature = "ui_font_kerning")]
+impl KerningTable {
+ pub fn get(&self, left: u8, right: u8) -> i8 {
+ let mut offset = 0usize;
+ for &(l, count) in self.index {
+ let next_offset = offset + usize::from(count);
+ if l == left {
+ for &(r, v) in &self.pairs[offset..next_offset] {
+ if r == right {
+ return v;
+ }
+ }
+ return 0;
+ }
+ if l > left {
+ break; // index is sorted
+ }
+ offset = next_offset;
+ }
+ 0
+ }
+}
+
/// Font information structure containing metadata and pointers to font data
#[derive(PartialEq, Eq)]
pub struct FontInfo {
@@ -21,7 +56,8 @@ pub struct FontInfo {
pub baseline: i16,
pub glyph_data: &'static [&'static [u8]],
pub glyph_nonprintable: &'static [u8],
- pub kernings: Option<&'static [(u8, u8, i8)]>,
+ #[cfg(feature = "ui_font_kerning")]
+ pub kernings: Option<&'static KerningTable>,
}
/// Convenience type for font references defined in the `fonts` module.
pub type Font = &'static FontInfo;
@@ -210,9 +246,7 @@ impl FontInfo {
let mut prev_char: Option<char> = None;
for c in text.chars() {
- if let Some(left) = prev_char {
- width += self.get_kerning(left, c) as i16;
- }
+ width += prev_char.map_or(0, |left| i16::from(self.get_kerning(left, c)));
width += self.char_width(c);
prev_char = Some(c);
}
@@ -259,12 +293,16 @@ impl FontInfo {
});
ascent + descent
}
-
+ #[cfg(feature = "ui_font_kerning")]
pub fn get_kerning(&'static self, left_ch: char, right_ch: char) -> i8 {
- let left: u16 = left_ch as u16;
- let right: u16 = right_ch as u16;
+ let Some(left) = u16::try_from(left_ch).ok() else {
+ return 0;
+ };
+ let Some(right) = u16::try_from(right_ch).ok() else {
+ return 0;
+ };
- if left >= 0x7F || right >= 0x7F {
+ if !left_ch.is_ascii() || !right_ch.is_ascii() {
#[cfg(feature = "translations")]
{
return self.with_glyph_data(|data| {
@@ -281,17 +319,18 @@ impl FontInfo {
let left = left as u8;
let right = right as u8;
- if let Some(kernings) = self.kernings {
- for &(l, r, v) in kernings {
- if l == left && r == right {
- return v;
- }
- }
+ if let Some(table) = self.kernings {
+ return table.get(left, right);
}
}
0
}
+ #[cfg(not(feature = "ui_font_kerning"))]
+ pub fn get_kerning(&'static self, _left_ch: char, _right_ch: char) -> i8 {
+ 0
+ }
+
/// Calculates the height of text containing both uppercase
/// and lowercase characters.
///
@@ -442,6 +481,7 @@ pub trait GlyphMetrics {
fn char_width(&self, ch: char) -> i16;
fn text_width(&self, text: &str) -> i16;
fn line_height(&self) -> i16;
+ fn get_kerning(&self, _left: char, _right: char) -> i16;
}
impl GlyphMetrics for Font {
@@ -456,11 +496,78 @@ impl GlyphMetrics for Font {
fn line_height(&self) -> i16 {
FontInfo::line_height(self)
}
+
+ fn get_kerning(&self, left: char, right: char) -> i16 {
+ FontInfo::get_kerning(self, left, right).into()
+ }
}
#[cfg(test)]
mod tests {
+ #[cfg(feature = "ui_font_kerning")]
+ mod kerning_table_tests {
+ use super::super::KerningTable;
+
+ // index: (left_char, count), pairs: (right_char, kern_value)
+ // 'A'=65, 'V'=86, 'T'=84, 'W'=87
+ static INDEX: &[(u8, u8)] = &[
+ (b'A', 2), // pairs[0..2]
+ (b'T', 1), // pairs[2..3]
+ ];
+ static PAIRS: &[(u8, i8)] = &[
+ (b'V', -3), // A + V
+ (b'W', -2), // A + W
+ (b'A', -4), // T + A
+ ];
+
+ fn table() -> KerningTable {
+ KerningTable {
+ index: INDEX,
+ pairs: PAIRS,
+ }
+ }
+
+ #[test]
+ fn found_returns_kern_value() {
+ let t = table();
+ assert_eq!(t.get(b'A', b'V'), -3);
+ assert_eq!(t.get(b'A', b'W'), -2);
+ assert_eq!(t.get(b'T', b'A'), -4);
+ }
+
+ #[test]
+ fn left_found_right_missing_returns_zero() {
+ let t = table();
+ assert_eq!(t.get(b'A', b'X'), 0);
+ assert_eq!(t.get(b'T', b'V'), 0);
+ }
+
+ #[test]
+ fn left_missing_returns_zero() {
+ let t = table();
+ assert_eq!(t.get(b'B', b'V'), 0);
+ assert_eq!(t.get(b'Z', b'A'), 0);
+ }
+
+ #[test]
+ fn early_exit_on_sorted_index() {
+ // 'A' < 'T', so querying 'B' (between them) must still return 0
+ // and not match 'T'
+ let t = table();
+ assert_eq!(t.get(b'B', b'A'), 0);
+ }
+
+ #[test]
+ fn empty_table_returns_zero() {
+ let t = KerningTable {
+ index: &[],
+ pairs: &[],
+ };
+ assert_eq!(t.get(b'A', b'V'), 0);
+ }
+ }
+
cfg_if::cfg_if! {
if #[cfg(feature = "layout_bolt")] {
use crate::ui::layout_bolt::fonts::FONT_NORMAL as FONT;
diff --git a/core/embed/rust/src/ui/shape/text.rs b/core/embed/rust/src/ui/shape/text.rs
index 4638603b..90b7948b 100644
--- a/core/embed/rust/src/ui/shape/text.rs
+++ b/core/embed/rust/src/ui/shape/text.rs
@@ -101,9 +101,7 @@ impl<'a> Shape<'_> for Text<'a> {
break;
}
- if let Some(left) = prev_char {
- r.x0 += self.font.get_kerning(left, ch) as i16;
- }
+ r.x0 += prev_char.map_or(0, |p| self.font.get_kerning(p, ch).into());
let glyph = glyph_data.get_glyph(ch);
let glyph_bitmap = glyph.bitmap();
diff --git a/core/site_scons/ui/ui_delizia.py b/core/site_scons/ui/ui_delizia.py
index dfb79a37..d4994b25 100644
--- a/core/site_scons/ui/ui_delizia.py
+++ b/core/site_scons/ui/ui_delizia.py
@@ -14,6 +14,7 @@ def init_ui(
rust_features.append("ui_jpeg")
rust_features.append("ui_image_buffer")
rust_features.append("ui_overlay")
+ rust_features.append("ui_font_kerning")
def get_ui_layout() -> str:
diff --git a/core/site_scons/ui/ui_eckhart.py b/core/site_scons/ui/ui_eckhart.py
index 5f752854..d2861f5d 100644
--- a/core/site_scons/ui/ui_eckhart.py
+++ b/core/site_scons/ui/ui_eckhart.py
@@ -14,6 +14,7 @@ def init_ui(
rust_features.append("ui_jpeg")
rust_features.append("ui_image_buffer")
rust_features.append("ui_overlay")
+ rust_features.append("ui_font_kerning")
def get_ui_layout() -> str:
diff --git a/core/tools/codegen/gen_font.mako b/core/tools/codegen/gen_font.mako
index 0ba5603e..2eba9a99 100644
--- a/core/tools/codegen/gen_font.mako
+++ b/core/tools/codegen/gen_font.mako
@@ -7,6 +7,10 @@
// - rest is packed ${bpp}-bit glyph data
use crate::ui::display::font::FontInfo;
+% if gen_kernings:
+#[cfg(feature = "ui_font_kerning")]
+use crate::ui::display::font::KerningTable;
+% endif
% for g in glyphs:
/// '${g["char"]}' (ASCII ${g["ascii"]})
@@ -35,12 +39,31 @@ const Font_${name}_upper: [&[u8]; ${len(glyph_array_upper)}] = [
% endif
% if gen_kernings:
-/// Array of kerning tripples
-const Font_${name}_kernings: [(u8, u8, i8); ${len(kernings)}] = [
-% for ref in kernings:
- ${ref}, // ${chr(ref[0])} + ${chr(ref[1])}
-%endfor
+/// Kerning index: (left_char, count), sorted by left_char.
+/// Start offset in kern_pairs is the sum of counts for all preceding entries.
+#[cfg(feature = "ui_font_kerning")]
+const Font_${name}_kern_index: [(u8, u8); ${len(kern_index)}] = [
+% for left, count in kern_index:
+ (${left}, ${count}), // '${chr(left)}'
+% endfor
];
+
+/// Kerning pairs: (right_char, kern_value), grouped by left_char
+#[cfg(feature = "ui_font_kerning")]
+const Font_${name}_kern_pairs: [(u8, i8); ${len(kern_pairs)}] = [
+% for left, pairs in kern_groups:
+ // '${chr(left)}' (${left}) +
+% for right, val in pairs:
+ (${right}, ${val}), // + '${chr(right)}'
+% endfor
+% endfor
+];
+
+#[cfg(feature = "ui_font_kerning")]
+const Font_${name}_kernings: KerningTable = KerningTable {
+ index: &Font_${name}_kern_index,
+ pairs: &Font_${name}_kern_pairs,
+};
% endif
% if gen_normal:
@@ -52,8 +75,9 @@ pub const Font_${name}_info: FontInfo = FontInfo {
baseline: ${font_info["baseline"]},
glyph_data: &${font_info["glyph_array"]},
glyph_nonprintable: &${font_info["nonprintable"]},
+ #[cfg(feature = "ui_font_kerning")]
%if gen_kernings:
- kernings: &${font_info_upper["kernings"]},
+ kernings: Some(&${font_info["kernings"]}),
%else:
kernings: None,
%endif
@@ -69,8 +93,9 @@ pub const Font_${name}_upper_info: FontInfo = FontInfo {
baseline: ${font_info_upper["baseline"]},
glyph_data: &${font_info_upper["glyph_array"]},
glyph_nonprintable: &${font_info_upper["nonprintable"]},
+ #[cfg(feature = "ui_font_kerning")]
%if gen_kernings:
- kernings: &${font_info_upper["kernings"]},
+ kernings: Some(&${font_info_upper["kernings"]}),
%else:
kernings: None,
%endif
diff --git a/core/tools/codegen/gen_font.py b/core/tools/codegen/gen_font.py
index 37428c46..813702f7 100755
--- a/core/tools/codegen/gen_font.py
+++ b/core/tools/codegen/gen_font.py
@@ -6,13 +6,18 @@ from __future__ import annotations
import json
import unicodedata
+from collections import defaultdict
from dataclasses import dataclass
+from itertools import groupby
from pathlib import Path
import click
# pip install freetype-py
import freetype
+
+# pip install fonttools
+from fontTools.ttLib import TTFont
from foreign_chars import all_languages
from mako.template import Template
@@ -21,6 +26,93 @@ def _normalize(s: str) -> str:
return unicodedata.normalize("NFC", s)
+def _extract_gpos_kerning(font_path: str) -> dict[tuple[int, int], int]:
+ """Extract kerning data from GPOS table using fontTools.
+
+ Returns a dict mapping (left_codepoint, right_codepoint) -> kerning_value in font units.
+ """
+ kerning: dict[tuple[int, int], int] = {}
+ try:
+ tt = TTFont(font_path)
+ except Exception as e:
+ print(f"Warning: failed to open font {font_path}: {e}")
+ return kerning
+
+ if "GPOS" not in tt:
+ tt.close()
+ return kerning
+
+ cmap = tt.getBestCmap()
+ if cmap is None:
+ tt.close()
+ return kerning
+
+ # Reverse map: glyph name -> list of codepoints
+ glyph_to_codepoints: defaultdict[str, list[int]] = defaultdict(list)
+ for cp, glyph_name in cmap.items():
+ glyph_to_codepoints[glyph_name].append(cp)
+
+ gpos = tt["GPOS"].table
+ for lookup in gpos.LookupList.Lookup:
+ for subtable in lookup.SubTable:
+ if subtable.Format == 1 and hasattr(subtable, "PairSet"):
+ # PairPos Format 1
+ for i, pair_set in enumerate(subtable.PairSet):
+ left_glyph = subtable.Coverage.glyphs[i]
+ left_cps = glyph_to_codepoints[left_glyph]
+ for pvr in pair_set.PairValueRecord:
+ right_glyph = pvr.SecondGlyph
+ right_cps = glyph_to_codepoints[right_glyph]
+ val = 0
+ if pvr.Value1 and hasattr(pvr.Value1, "XAdvance"):
+ val = pvr.Value1.XAdvance
+ if val != 0:
+ for left_cp in left_cps:
+ for right_cp in right_cps:
+ kerning[(left_cp, right_cp)] = val
+ elif subtable.Format == 2 and hasattr(subtable, "ClassDef1"):
+ # PairPos Format 2
+ class1 = subtable.ClassDef1.classDefs
+ class2 = subtable.ClassDef2.classDefs
+ coverage_glyphs = set(subtable.Coverage.glyphs)
+ for c1_idx, class1_record in enumerate(subtable.Class1Record):
+ for c2_idx, class2_record in enumerate(class1_record.Class2Record):
+ val = 0
+ if class2_record.Value1 and hasattr(
+ class2_record.Value1, "XAdvance"
+ ):
+ val = class2_record.Value1.XAdvance
+ if val == 0:
+ continue
+ # Find glyphs in class1 with index c1_idx
+ if c1_idx == 0:
+ left_glyphs = [
+ g for g in coverage_glyphs if class1.get(g, 0) == 0
+ ]
+ else:
+ left_glyphs = [
+ g
+ for g, c in class1.items()
+ if c == c1_idx and g in coverage_glyphs
+ ]
+ # Find glyphs in class2 with index c2_idx
+ if c2_idx == 0:
+ # Class 0 = all glyphs not explicitly assigned
+ right_glyphs = [
+ g for g in glyph_to_codepoints if class2.get(g, 0) == 0
+ ]
+ else:
+ right_glyphs = [g for g, c in class2.items() if c == c2_idx]
+ for left_glyph in left_glyphs:
+ for right_glyph in right_glyphs:
+ for left_cp in glyph_to_codepoints[left_glyph]:
+ for right_cp in glyph_to_codepoints[right_glyph]:
+ kerning[(left_cp, right_cp)] = val
+
+ tt.close()
+ return kerning
+
+
HERE = Path(__file__).parent
CORE_ROOT = HERE.parent.parent
FONTS_DIR = HERE / "fonts"
@@ -269,12 +361,26 @@ class FaceProcessor:
self.gen_upper = gen_upper
self.gen_kernings = gen_kernings
- self.face = freetype.Face(str(FONTS_DIR / f"{name}-{style}.{ext}"))
+ self.font_path = str(FONTS_DIR / f"{name}-{style}.{ext}")
+ self.face = freetype.Face(self.font_path)
self.face.set_pixel_sizes(0, size)
self.fontname = f"{name.lower()}_{style.lower()}_{size}"
self.font_ymin = 0
self.font_ymax = 0
+ # Extract GPOS kerning data (preferred over legacy kern table).
+ # Values are in font units; convert to pixels using ppem/units_per_em.
+ units_per_em = self.face.units_per_EM
+ self._gpos_kerning: dict[tuple[int, int], int] = {}
+ if units_per_em:
+ raw_gpos = _extract_gpos_kerning(self.font_path)
+ for (lcp, rcp), val in raw_gpos.items():
+ px = round(val * size / units_per_em)
+ if px != 0:
+ self._gpos_kerning[(lcp, rcp)] = px
+ if self._gpos_kerning:
+ print(f" Loaded {len(self._gpos_kerning)} GPOS kerning pairs")
+
@property
def _name_style_size(self) -> str:
return f"{self.name}_{self.style}_{self.size}"
@@ -330,54 +436,53 @@ class FaceProcessor:
glyph.print_metrics()
fontdata["glyphs"][map_from] = glyph.to_bytes(self.bpp).hex()
- # Find kernings across all language characters and ASCII
- all_lang_chars = list(language_chars) + [
- chr(i) for i in range(MIN_GLYPH, MAX_GLYPH + 1)
- ]
+ if self.gen_kernings:
+ # Find kernings across all language characters and ASCII
+ all_lang_chars = list(language_chars) + [
+ chr(i) for i in range(MIN_GLYPH, MAX_GLYPH + 1)
+ ]
- for left_char in all_lang_chars:
+ for left_char in all_lang_chars:
- left_c = _normalize(left_char)
+ left_c = _normalize(left_char)
- if not self._char_supported(left_c):
- continue
+ if not self._char_supported(left_c):
+ continue
- if left_c.islower() and upper_cased and left_c != "ß":
- left_c = left_c.upper()
- if not self._char_supported(left_c):
- continue
- left_idx = self.face.get_char_index(ord(left_c))
+ if left_c.islower() and upper_cased and left_c != "ß":
+ left_c = left_c.upper()
+ if not self._char_supported(left_c):
+ continue
- for right_char in all_lang_chars:
+ for right_char in all_lang_chars:
- right_c = _normalize(right_char)
- if right_c.islower() and upper_cased and right_c != "ß":
- right_c = right_c.upper()
- if not self._char_supported(right_c):
- continue
- right_idx = self.face.get_char_index(ord(right_c))
+ right_c = _normalize(right_char)
+ if right_c.islower() and upper_cased and right_c != "ß":
+ right_c = right_c.upper()
+ if not self._char_supported(right_c):
+ continue
- # skip if both are ASCII
- if left_idx in range(
- MIN_GLYPH, MAX_GLYPH + 1
- ) and right_idx in range(MIN_GLYPH, MAX_GLYPH + 1):
- continue
+ # skip if both are ASCII (handled in .rs file)
+ if ord(left_c) in range(MIN_GLYPH, MAX_GLYPH + 1) and ord(
+ right_c
+ ) in range(MIN_GLYPH, MAX_GLYPH + 1):
+ continue
- kerning = self.face.get_kerning(
- left_idx, right_idx, freetype.FT_KERNING_DEFAULT
- )
- if kerning.x != 0:
+ kern_val = self._get_kerning(ord(left_c), ord(right_c))
+ if kern_val != 0:
- fontdata["kernings"].append(
- (left_char, right_char, kerning.x // 64)
- )
- key = f"{left_char}{right_char}"
- print(
- f"Special Lang character kerning for '{key}' : {kerning.x // 64} pixels"
- )
+ fontdata["kernings"].append(
+ (left_char, right_char, kern_val)
+ )
+ key = f"{left_char}{right_char}"
+ print(
+ f"Special {lang} character kerning for '{key}' : {kern_val} pixels"
+ )
file_name = self._foreign_json_name(upper_cased, lang)
- file = JSON_FONTS_DEST / file_name
+ layout_fonts_dir = JSON_FONTS_DEST / LAYOUT_NAME.lower()
+ layout_fonts_dir.mkdir(parents=True, exist_ok=True)
+ file = layout_fonts_dir / file_name
json_content = json.dumps(fontdata, indent=2, ensure_ascii=False)
file.write_text(json_content + "\n")
@@ -412,6 +517,22 @@ class FaceProcessor:
def _load_char(self, c: str) -> None:
self.face.load_char(c, freetype.FT_LOAD_RENDER | freetype.FT_LOAD_TARGET_NORMAL)
+ def _get_kerning(self, left_cp: int, right_cp: int) -> int:
+ """Get kerning value in pixels for a pair of codepoints.
+
+ GPOS data is preferred; falls back to legacy kern table.
+ """
+ if self._gpos_kerning:
+ val = self._gpos_kerning.get((left_cp, right_cp))
+ if val is not None:
+ return val
+ # If GPOS table exists but has no entry for this pair, return 0
+ # (don't fall back to kern table which may have stale/different data)
+ return 0
+ # Fallback: legacy kern table via freetype
+ kerning = self.face.get_kerning(left_cp, right_cp, freetype.FT_KERNING_DEFAULT)
+ return kerning.x // 64
+
# --------------------------------------------------------------------
# Rust code generation
# --------------------------------------------------------------------
@@ -483,21 +604,43 @@ class FaceProcessor:
self.font_ymax = max(self.font_ymax, yMax)
kernings = []
- for left in range(MIN_GLYPH, MAX_GLYPH + 1):
- for right in range(MIN_GLYPH, MAX_GLYPH + 1):
- kerning = self.face.get_kerning(
- left, right, freetype.FT_KERNING_DEFAULT
- )
- if kerning.x != 0:
- kernings.append((left, right, kerning.x // 64))
- print(
- f"left glyph: {chr(left)} right glyph:{chr(right)} kerning {kerning.x // 64}"
- )
+ if self.gen_kernings:
+ for left in range(MIN_GLYPH, MAX_GLYPH + 1):
+ for right in range(MIN_GLYPH, MAX_GLYPH + 1):
+ kern_val = self._get_kerning(left, right)
+ if kern_val != 0:
+ kernings.append((left, right, kern_val))
+ print(
+ f"left glyph: {chr(left)} right glyph:{chr(right)} kerning {kern_val}"
+ )
print(
f"Font: {self._name_style_size} {self.style} {self.size} : Num of kernirngs {len(kernings)}"
)
+ # Build two-level kerning structure: index (per left char) + flat pairs.
+ # Index stores (left_char, count); start offset is derived as sum of preceding counts.
+ kern_index: list[tuple[int, int]] = [] # (left_char, count)
+ kern_pairs: list[tuple[int, int]] = []
+ for left, group in groupby(
+ sorted(kernings, key=lambda x: x[0]), key=lambda x: x[0]
+ ):
+ pairs = list(group)
+ assert len(pairs) <= 255, (
+ f"Too many right-char partners ({len(pairs)}) for '{chr(left)}' "
+ f"in {self._name_style_size} — exceeds u8 count limit"
+ )
+ kern_index.append((left, len(pairs)))
+ for _, right, val in pairs:
+ kern_pairs.append((right, val))
+
+ # Groups for template comments: [(left_char, [(right_char, val), ...]), ...]
+ kern_groups = []
+ offset = 0
+ for left, count in kern_index:
+ kern_groups.append((left, kern_pairs[offset : offset + count]))
+ offset += count
+
# 5) Build FontInfo definitions.
font_info = None
font_info_upper = None
@@ -540,6 +683,9 @@ class FaceProcessor:
"glyph_array": glyph_array,
"glyph_array_upper": glyph_array_upper,
"kernings": kernings,
+ "kern_index": kern_index,
+ "kern_pairs": kern_pairs,
+ "kern_groups": kern_groups,
"gen_normal": self.gen_normal,
"gen_upper": self.gen_upper,
"gen_kernings": self.gen_kernings,
@@ -616,9 +762,15 @@ def gen_layout_delizia() -> None:
global LAYOUT_NAME
LAYOUT_NAME = "Delizia"
# FIXME: BIG font idx not needed
- FaceProcessor("TTSatoshi", "DemiBold", 42, ext="otf", font_idx=1).write_files()
- FaceProcessor("TTSatoshi", "DemiBold", 21, ext="otf", font_idx=1).write_files()
- FaceProcessor("TTSatoshi", "DemiBold", 18, ext="otf", font_idx=8).write_files()
+ FaceProcessor(
+ "TTSatoshi", "DemiBold", 42, ext="otf", font_idx=1, gen_kernings=True
+ ).write_files()
+ FaceProcessor(
+ "TTSatoshi", "DemiBold", 21, ext="otf", font_idx=1, gen_kernings=True
+ ).write_files()
+ FaceProcessor(
+ "TTSatoshi", "DemiBold", 18, ext="otf", font_idx=8, gen_kernings=True
+ ).write_files()
FaceProcessor("RobotoMono", "Medium", 21, font_idx=3).write_files()
FaceProcessor(
"TTHoves",
@@ -628,6 +780,7 @@ def gen_layout_delizia() -> None:
gen_normal=False,
gen_upper=True,
font_idx_upper=7,
+ gen_kernings=True,
).write_files()
@@ -635,11 +788,21 @@ def gen_layout_eckhart() -> None:
global LAYOUT_NAME
LAYOUT_NAME = "eckhart"
# FIXME: BIG font idx not needed
- FaceProcessor("TTSatoshi", "ExtraLight", 72, ext="otf", font_idx=1).write_files()
- FaceProcessor("TTSatoshi", "ExtraLight", 46, ext="otf", font_idx=1).write_files()
- FaceProcessor("TTSatoshi", "Regular", 38, ext="otf", font_idx=2).write_files()
- FaceProcessor("TTSatoshi", "Medium", 26, ext="otf", font_idx=3).write_files()
- FaceProcessor("TTSatoshi", "Regular", 22, ext="otf", font_idx=4).write_files()
+ FaceProcessor(
+ "TTSatoshi", "ExtraLight", 72, ext="otf", font_idx=1, gen_kernings=True
+ ).write_files()
+ FaceProcessor(
+ "TTSatoshi", "ExtraLight", 46, ext="otf", font_idx=1, gen_kernings=True
+ ).write_files()
+ FaceProcessor(
+ "TTSatoshi", "Regular", 38, ext="otf", font_idx=2, gen_kernings=True
+ ).write_files()
+ FaceProcessor(
+ "TTSatoshi", "Medium", 26, ext="otf", font_idx=3, gen_kernings=True
+ ).write_files()
+ FaceProcessor(
+ "TTSatoshi", "Regular", 22, ext="otf", font_idx=4, gen_kernings=True
+ ).write_files()
FaceProcessor("RobotoMono", "Medium", 38, font_idx=5).write_files()
FaceProcessor("RobotoMono", "Light", 30, font_idx=6).write_files()
diff --git a/python/src/trezorlib/_internal/translations.py b/python/src/trezorlib/_internal/translations.py
index 01c78434..c897f4cd 100644
--- a/python/src/trezorlib/_internal/translations.py
+++ b/python/src/trezorlib/_internal/translations.py
@@ -17,6 +17,7 @@
from __future__ import annotations
import json
+import struct
import typing as t
import unicodedata
from hashlib import sha256
@@ -250,6 +251,17 @@ class FontsTable(BlobTable):
class KerningList(Struct):
+ """Serializes font kerning pairs into the two-level binary format consumed by
+ the Rust ``KerningTable`` in ``blob.rs``.
+
+ Binary layout of ``kerning_data``:
+ [u16 index_count]
+ [u16 left_cp, u16 count] × index_count (4 bytes, sorted by left_cp)
+ [u16 pair_count]
+ [u16 right_cp, i8 kern_val, u8 pad] × pair_count (4 bytes)
+
+ Start offset for entry i is the sum of counts for all preceding entries.
+ """
kerning_data: bytes
@@ -263,36 +275,50 @@ class KerningList(Struct):
@classmethod
def from_file(cls, file_path: Path) -> Self:
json_content = json.loads(file_path.read_text())
+ triplets = json_content.get("kernings", [])
+
+ # Group pairs by left codepoint (sorted for binary search).
+ by_left: dict[int, list[tuple[int, int]]] = {}
+ for left_char, right_char, kern_val in triplets:
+ if not (-128 <= kern_val <= 127):
+ raise ValueError(f"Invalid kerning adjustment: {kern_val}")
+ left_cp, right_cp = ord(left_char), ord(right_char)
+ if left_cp > 0xFFFF or right_cp > 0xFFFF:
+ raise ValueError(
+ f"Invalid kerning codepoint: {left_char!r}, {right_char!r}"
+ )
+ by_left.setdefault(left_cp, []).append((right_cp, kern_val))
- if len(json_content.get("kernings", [])) > 255:
- raise ValueError("Too many kerning pairs (max 255 allowed)")
-
- kerning_bytes = [
- cls.triplet_to_bytes(k) for k in json_content.get("kernings", [])
- ]
-
- return cls(kerning_data=b"".join(kerning_bytes))
+ kern_index: list[tuple[int, int]] = [] # (left_cp, count)
+ kern_pairs: list[tuple[int, int]] = []
+ for left_cp in sorted(by_left):
+ pairs = by_left[left_cp]
+ kern_index.append((left_cp, len(pairs)))
+ kern_pairs.extend(pairs)
- @classmethod
- def triplet_to_bytes(cls, triplet) -> bytes:
+ if len(kern_index) > 0xFFFF:
+ raise ValueError(f"Too many unique left kerning chars: {len(kern_index)}")
- if not (-128 <= triplet[2] <= 127):
- raise ValueError(f"Invalid kerning adjustment: {triplet[2]}")
+ if len(kern_pairs) > 0xFFFF:
+ raise ValueError(f"Too many kerning pairs: {len(kern_pairs)}")
- if ord(triplet[0]) > 0xFFFF or ord(triplet[1]) > 0xFFFF:
- raise ValueError(f"Invalid kerning codepoint: {triplet[0]}, {triplet[1]}")
+ # [u16 index_count] + [4-byte index entries (left_cp, count)]
+ # + [u16 pair_count] + [4-byte pair entries]
+ # Start offset for entry i is the sum of counts for all preceding entries.
+ data = struct.pack("<H", len(kern_index))
+ for left_cp, count in kern_index:
+ data += struct.pack("<HH", left_cp, count)
+ data += struct.pack("<H", len(kern_pairs))
+ for right_cp, kern_val in kern_pairs:
+ data += struct.pack("<Hb", right_cp, kern_val) + b"\x00"
- left = c.Int16ul.build(ord(triplet[0]))
- right = c.Int16ul.build(ord(triplet[1]))
- kerning = c.Int8sl.build(triplet[2])
- padding = b"\x00" # pad to 6 bytes
- return left + right + kerning + padding
+ return cls(kerning_data=data)
class KerningTable(BlobTable):
@classmethod
- def from_dir(cls, model_fonts: dict[str, str], font_dir: Path):
+ def from_dir(cls, model_fonts: dict[str, str], font_dir: Path) -> "KerningTable":
"""Example structure of the font dict:
(The key number corresponds to the index representation of each font set in `gen_font.py`)
{
@@ -479,8 +505,9 @@ def blob_from_defs(
)
model_fonts = lang_data["fonts"][layout_type.name]
- fonts = FontsTable.from_dir(model_fonts, fonts_dir)
- kernings = KerningTable.from_dir(model_fonts, fonts_dir)
+ layout_fonts_dir = fonts_dir / layout_type.name.lower()
+ fonts = FontsTable.from_dir(model_fonts, layout_fonts_dir)
+ kernings = KerningTable.from_dir(model_fonts, layout_fonts_dir)
for chunk_bytes in translations_chunks_bytes:
assert len(chunk_bytes) % ALIGNMENT == 0
Why this scored 21/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.