Skip to content

Commit fe3c157

Browse files
committed
Add swash fontdb glyph fallback
1 parent 41ff0c4 commit fe3c157

2 files changed

Lines changed: 169 additions & 17 deletions

File tree

src/renderer.rs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,63 @@ mod tests {
459459
assert_left_cell_edge_has_ink(&image, 1, 0, PALETTE[GREEN], PALETTE[YELLOW]);
460460
}
461461

462+
#[test]
463+
fn swash_falls_back_to_unlisted_font_faces() {
464+
let mut font_db = fontdb::Database::new();
465+
font_db.load_font_data(include_bytes!("../fonts/JetBrainsMono-Regular.ttf").to_vec());
466+
font_db.load_font_data(include_bytes!("../fonts/NotoSansCJKjp-Regular.otf").to_vec());
467+
468+
let settings = Settings {
469+
terminal_size: (COLS, ROWS),
470+
font_db,
471+
font_families: vec![FONT_FAMILY.to_owned()],
472+
text_family: FONT_FAMILY.to_owned(),
473+
font_size: FONT_SIZE,
474+
line_height: LINE_HEIGHT,
475+
theme: theme(),
476+
bold_is_bright: false,
477+
};
478+
479+
let mut renderer = swash(settings);
480+
let image = renderer.render(&lines_for("\x1b[48;5;3m日\x1b[49m"), None);
481+
482+
assert_closer_to(cell_pixel(&image, 1, 0, 0.3, 0.5), FG, PALETTE[YELLOW]);
483+
}
484+
485+
#[test]
486+
fn swash_fontdb_fallback_prefers_matching_style() {
487+
let make_db = || {
488+
let mut font_db = fontdb::Database::new();
489+
font_db.load_font_data(include_bytes!("../fonts/JetBrainsMono-Italic.ttf").to_vec());
490+
font_db.load_font_data(include_bytes!("../fonts/JetBrainsMono-Regular.ttf").to_vec());
491+
492+
font_db
493+
};
494+
495+
let settings = |font_db, font_families| Settings {
496+
terminal_size: (COLS, ROWS),
497+
font_db,
498+
font_families,
499+
text_family: FONT_FAMILY.to_owned(),
500+
font_size: FONT_SIZE,
501+
line_height: LINE_HEIGHT,
502+
theme: theme(),
503+
bold_is_bright: false,
504+
};
505+
506+
let mut fallback_renderer = swash(settings(make_db(), vec![]));
507+
let fallback_image = fallback_renderer.render(&lines_for("M"), None);
508+
509+
let mut regular_renderer = swash(settings(make_db(), vec![FONT_FAMILY.to_owned()]));
510+
let regular_image = regular_renderer.render(&lines_for("M"), None);
511+
512+
let mut italic_renderer = swash(settings(make_db(), vec![FONT_FAMILY.to_owned()]));
513+
let italic_image = italic_renderer.render(&lines_for("\x1b[3mM"), None);
514+
515+
assert_images_equal(&fallback_image, &regular_image);
516+
assert_images_differ(&fallback_image, &italic_image);
517+
}
518+
462519
// The col-2 (ANSI white, n=7) assertions probe the n < 8 boundary —
463520
// they catch off-by-one regressions like `n < 7` that the col-0 (red,
464521
// n=1) assertion alone would miss.
@@ -626,6 +683,37 @@ mod tests {
626683
);
627684
}
628685

686+
fn assert_images_equal(actual: &ImgVec<RGBA8>, expected: &ImgVec<RGBA8>) {
687+
assert_eq!(actual.width(), expected.width());
688+
assert_eq!(actual.height(), expected.height());
689+
690+
let mismatched = actual
691+
.buf()
692+
.iter()
693+
.zip(expected.buf())
694+
.filter(|(actual, expected)| actual != expected)
695+
.count();
696+
697+
assert_eq!(mismatched, 0, "expected images to match exactly");
698+
}
699+
700+
fn assert_images_differ(actual: &ImgVec<RGBA8>, expected: &ImgVec<RGBA8>) {
701+
assert_eq!(actual.width(), expected.width());
702+
assert_eq!(actual.height(), expected.height());
703+
704+
let mismatched = actual
705+
.buf()
706+
.iter()
707+
.zip(expected.buf())
708+
.filter(|(actual, expected)| actual != expected)
709+
.count();
710+
711+
assert!(
712+
mismatched > 10,
713+
"expected images to differ, but only {mismatched} pixels changed"
714+
);
715+
}
716+
629717
fn blend_rgb(fg: RGB8, bg: RGB8, ratio: u8) -> RGB8 {
630718
let ratio = ratio as u16;
631719

src/renderer/swash.rs

Lines changed: 81 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use std::collections::hash_map::DefaultHasher;
2-
use std::collections::HashMap;
2+
use std::collections::{HashMap, HashSet};
33
use std::hash::{Hash, Hasher};
44

55
use imgref::ImgVec;
@@ -124,6 +124,22 @@ fn glyph_image_is_visible(img: &Image) -> bool {
124124
img.placement.width > 0 && img.placement.height > 0 && !img.data.is_empty()
125125
}
126126

127+
fn font_weight(bold: bool) -> fontdb::Weight {
128+
if bold {
129+
fontdb::Weight::BOLD
130+
} else {
131+
fontdb::Weight::NORMAL
132+
}
133+
}
134+
135+
fn font_style(italic: bool) -> fontdb::Style {
136+
if italic {
137+
fontdb::Style::Italic
138+
} else {
139+
fontdb::Style::Normal
140+
}
141+
}
142+
127143
impl SwashRenderer {
128144
pub fn new(settings: Settings) -> Self {
129145
let col_width = col_width(&settings.font_db, &settings.text_family, settings.font_size)
@@ -155,21 +171,16 @@ impl SwashRenderer {
155171
}
156172

157173
fn get_font_id(&mut self, name: &str, bold: bool, italic: bool) -> &Option<fontdb::ID> {
158-
let weight = if bold {
159-
fontdb::Weight::BOLD
160-
} else {
161-
fontdb::Weight::NORMAL
162-
};
163-
164-
let style = if italic {
165-
fontdb::Style::Italic
166-
} else {
167-
fontdb::Style::Normal
168-
};
169-
170174
self.font_id_cache
171175
.entry((name.to_owned(), bold, italic))
172-
.or_insert_with(|| get_font_id(&self.font_db, &[name], weight, style))
176+
.or_insert_with(|| {
177+
get_font_id(
178+
&self.font_db,
179+
&[name],
180+
font_weight(bold),
181+
font_style(italic),
182+
)
183+
})
173184
}
174185

175186
fn ensure_glyph(&mut self, ch: char, bold: bool, italic: bool) {
@@ -179,13 +190,28 @@ impl SwashRenderer {
179190
return;
180191
}
181192

182-
if let Some(glyph) = self.rasterize_glyph(ch, bold, italic) {
193+
let mut tried_font_ids = HashSet::new();
194+
195+
if let Some(glyph) = self.rasterize_family_glyph(ch, bold, italic, &mut tried_font_ids) {
183196
self.glyph_cache.insert(key, Some(glyph));
184197
return;
185198
}
186199

187200
if bold || italic {
188-
if let Some(glyph) = self.rasterize_glyph(ch, false, false) {
201+
if let Some(glyph) = self.rasterize_family_glyph(ch, false, false, &mut tried_font_ids)
202+
{
203+
self.glyph_cache.insert(key, Some(glyph));
204+
return;
205+
}
206+
}
207+
208+
if let Some(glyph) = self.rasterize_fallback_glyph(ch, bold, italic, &tried_font_ids) {
209+
self.glyph_cache.insert(key, Some(glyph));
210+
return;
211+
}
212+
213+
if bold || italic {
214+
if let Some(glyph) = self.rasterize_fallback_glyph(ch, false, false, &tried_font_ids) {
189215
self.glyph_cache.insert(key, Some(glyph));
190216
return;
191217
}
@@ -200,14 +226,52 @@ impl SwashRenderer {
200226
.expect("caller must invoke ensure_glyph first")
201227
}
202228

203-
fn rasterize_glyph(&mut self, ch: char, bold: bool, italic: bool) -> Option<Image> {
229+
fn rasterize_family_glyph(
230+
&mut self,
231+
ch: char,
232+
bold: bool,
233+
italic: bool,
234+
tried_font_ids: &mut HashSet<fontdb::ID>,
235+
) -> Option<Image> {
204236
let families = self.font_families.clone();
205237

206238
for name in &families {
207239
let Some(font_id) = *self.get_font_id(name, bold, italic) else {
208240
continue;
209241
};
210242

243+
tried_font_ids.insert(font_id);
244+
245+
if let Some(glyph) = self.rasterize_font_glyph(font_id, ch) {
246+
return Some(glyph);
247+
}
248+
}
249+
250+
None
251+
}
252+
253+
fn rasterize_fallback_glyph(
254+
&mut self,
255+
ch: char,
256+
bold: bool,
257+
italic: bool,
258+
tried_font_ids: &HashSet<fontdb::ID>,
259+
) -> Option<Image> {
260+
let weight = font_weight(bold);
261+
let style = font_style(italic);
262+
263+
// Match resvg/usvg behavior: if configured families miss, try any
264+
// loaded face so system fonts and --font-dir fonts can cover unlisted scripts.
265+
let fallback_font_ids: Vec<_> = self
266+
.font_db
267+
.faces()
268+
.filter(|face| {
269+
!tried_font_ids.contains(&face.id) && face.weight == weight && face.style == style
270+
})
271+
.map(|face| face.id)
272+
.collect();
273+
274+
for font_id in fallback_font_ids {
211275
if let Some(glyph) = self.rasterize_font_glyph(font_id, ch) {
212276
return Some(glyph);
213277
}

0 commit comments

Comments
 (0)