1use crate::Color;
14use crate::scene::Scene;
15use core::fmt;
16use oxideav_core::{Group, Node, Paint, Transform2D};
17use oxideav_scribe::{Face, FaceChain, Shaper};
18use stipple_geometry::{Point, Size};
19
20pub struct Font {
22 chain: FaceChain,
23}
24
25impl fmt::Debug for Font {
26 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27 f.debug_struct("Font")
28 .field("units_per_em", &self.chain.primary().units_per_em())
29 .finish()
30 }
31}
32
33#[derive(Debug)]
35pub struct FontError(String);
36
37impl fmt::Display for FontError {
38 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39 write!(f, "font load error: {}", self.0)
40 }
41}
42
43impl std::error::Error for FontError {}
44
45impl Font {
46 pub fn from_bytes(bytes: Vec<u8>) -> Result<Self, FontError> {
49 let face = parse_face(bytes)?;
50 Ok(Self {
51 chain: FaceChain::new(face),
52 })
53 }
54
55 pub fn system_default() -> Option<Self> {
61 const CANDIDATES: &[&str] = &[
62 "/usr/share/fonts/liberation-fonts/LiberationSans-Regular.ttf",
64 "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
65 "/usr/share/fonts/dejavu/DejaVuSans.ttf",
66 "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
67 "/usr/share/fonts/TTF/DejaVuSans.ttf",
68 "/usr/share/fonts/noto/NotoSans-Regular.ttf",
69 "/System/Library/Fonts/Helvetica.ttc",
71 "/Library/Fonts/Arial.ttf",
72 "C:\\Windows\\Fonts\\segoeui.ttf",
74 "C:\\Windows\\Fonts\\arial.ttf",
75 ];
76 for path in CANDIDATES {
77 if let Ok(bytes) = std::fs::read(path)
78 && let Ok(font) = Font::from_bytes(bytes)
79 {
80 return Some(font);
81 }
82 }
83 None
84 }
85
86 pub fn ascent(&self, size_px: f64) -> f64 {
89 self.chain.primary().ascent_px(size_px as f32) as f64
90 }
91
92 pub fn line_height(&self, size_px: f64) -> f64 {
94 self.chain.primary().line_height_px(size_px as f32) as f64
95 }
96
97 fn line_width(&self, line: &str, size_px: f64) -> f64 {
99 match self.chain.shape(line, size_px as f32) {
100 Ok(glyphs) => glyphs.iter().map(|g| g.x_advance).sum::<f32>() as f64,
101 Err(_) => 0.0,
102 }
103 }
104
105 pub fn measure(&self, text: &str, size_px: f64) -> Size {
109 let mut max_w: f64 = 0.0;
110 let mut lines = 0usize;
111 for line in text.split('\n') {
112 lines += 1;
113 max_w = max_w.max(self.line_width(line, size_px));
114 }
115 Size::new(max_w, self.line_height(size_px) * lines as f64)
116 }
117
118 pub fn wrap(&self, text: &str, size_px: f64, max_width: f64) -> Vec<String> {
123 let space = self.line_width(" ", size_px);
126 let mut out = Vec::new();
127 for hard in text.split('\n') {
128 let mut line = String::new();
129 let mut width = 0.0;
130 for word in hard.split(' ') {
131 let ww = self.line_width(word, size_px);
132 if line.is_empty() {
133 line.push_str(word);
134 width = ww;
135 } else if width + space + ww <= max_width {
136 line.push(' ');
137 line.push_str(word);
138 width += space + ww;
139 } else {
140 out.push(std::mem::take(&mut line));
141 line.push_str(word);
142 width = ww;
143 }
144 }
145 out.push(line);
146 }
147 if out.is_empty() {
148 out.push(String::new());
149 }
150 out
151 }
152
153 pub(crate) fn chain(&self) -> &FaceChain {
154 &self.chain
155 }
156}
157
158fn parse_face(bytes: Vec<u8>) -> Result<Face, FontError> {
159 let result = match bytes.first_chunk::<4>() {
160 Some(b"OTTO") => Face::from_otf_bytes(bytes),
161 Some(b"ttcf") => Face::from_ttc_bytes(bytes, 0),
162 _ => Face::from_ttf_bytes(bytes),
163 };
164 result.map_err(|e| FontError(format!("{e:?}")))
165}
166
167fn recolor(node: Node, color: Color) -> Node {
171 match node {
172 Node::Path(mut path) => {
173 path.fill = Some(Paint::Solid(color.to_oxideav()));
174 Node::Path(path)
175 }
176 Node::Group(mut group) => {
177 group.children = group
178 .children
179 .into_iter()
180 .map(|c| recolor(c, color))
181 .collect();
182 Node::Group(group)
183 }
184 other => other,
185 }
186}
187
188impl Scene {
189 pub fn fill_text(
196 &mut self,
197 font: &Font,
198 text: &str,
199 origin: Point,
200 size_px: f64,
201 color: Color,
202 ) {
203 if text.is_empty() || size_px <= 0.0 {
204 return;
205 }
206 self.record_text(text, origin, size_px, color);
209 let line_height = font.line_height(size_px);
210 let ascent = font.ascent(size_px);
211 for (i, line) in text.split('\n').enumerate() {
212 if line.is_empty() {
213 continue;
214 }
215 let glyphs = Shaper::shape_to_paths(font.chain(), line, size_px as f32);
216 if glyphs.is_empty() {
217 continue;
218 }
219 let mut run = Vec::with_capacity(glyphs.len());
220 for (_face_idx, node, transform) in glyphs {
221 let glyph = Group::new()
222 .with_transform(transform)
223 .with_child(recolor(node, color));
224 run.push(Node::Group(glyph));
225 }
226 let baseline = (origin.y + i as f64 * line_height + ascent) as f32;
229 let placed = Group::new()
230 .with_transform(Transform2D::translate(origin.x as f32, baseline))
231 .with_children(run);
232 self.push_node(Node::Group(placed));
233 }
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use crate::SoftwareRenderer;
241 use stipple_geometry::{Rect, ScaleFactor};
242
243 #[test]
244 fn measure_is_monotonic_in_length() {
245 let Some(font) = Font::system_default() else {
246 eprintln!("skipping: no system font found");
247 return;
248 };
249 let short = font.measure("i", 16.0);
250 let long = font.measure("internationalization", 16.0);
251 assert!(long.width > short.width);
252 assert!(short.height > 0.0);
253 }
254
255 #[test]
256 fn measure_counts_newline_separated_lines() {
257 let Some(font) = Font::system_default() else {
258 eprintln!("skipping: no system font found");
259 return;
260 };
261 let one = font.measure("Hello", 16.0);
262 let two = font.measure("Hello\nWorld!", 16.0);
263 assert!((two.height - 2.0 * one.height).abs() < 1.0);
265 assert!(two.width >= one.width);
267 let trailing = font.measure("Hello\nWorld!\n", 16.0);
269 assert!((trailing.height - 3.0 * one.height).abs() < 1.0);
270 }
271
272 #[test]
273 fn wrap_breaks_at_spaces_within_width() {
274 let Some(font) = Font::system_default() else {
275 eprintln!("skipping: no system font found");
276 return;
277 };
278 let text = "the quick brown fox jumps over the lazy dog";
279 let full = font.measure(text, 16.0).width;
280 let lines = font.wrap(text, 16.0, full / 2.0);
283 assert!(lines.len() > 1);
284 for line in &lines {
285 assert!(font.measure(line, 16.0).width <= full / 2.0 + 0.5);
286 }
287 assert_eq!(font.wrap("a\nb", 16.0, 10_000.0), vec!["a", "b"]);
289 }
290
291 #[test]
292 fn text_paints_visible_pixels() {
293 let Some(font) = Font::system_default() else {
294 eprintln!("skipping: no system font found");
295 return;
296 };
297 let mut scene = Scene::new(Size::new(200.0, 50.0));
298 scene.fill_rect(Rect::from_xywh(0.0, 0.0, 200.0, 50.0), Color::WHITE);
300 scene.fill_text(&font, "Hello", Point::new(8.0, 8.0), 28.0, Color::BLACK);
301
302 let pm = SoftwareRenderer::new().render(scene, ScaleFactor::IDENTITY);
303 let mut darkened = 0;
305 for y in 0..pm.size().height {
306 for x in 0..pm.size().width {
307 if let Some([r, _, _, _]) = pm.pixel(x, y)
308 && r < 128
309 {
310 darkened += 1;
311 }
312 }
313 }
314 assert!(
315 darkened > 20,
316 "expected glyph coverage, got {darkened} dark pixels"
317 );
318 }
319
320 #[test]
321 fn text_uses_requested_color() {
322 let Some(font) = Font::system_default() else {
323 eprintln!("skipping: no system font found");
324 return;
325 };
326 let mut scene = Scene::new(Size::new(160.0, 50.0));
327 scene.fill_rect(Rect::from_xywh(0.0, 0.0, 160.0, 50.0), Color::WHITE);
328 scene.fill_text(
330 &font,
331 "RED",
332 Point::new(8.0, 8.0),
333 32.0,
334 Color::rgb(255, 0, 0),
335 );
336
337 let pm = SoftwareRenderer::new().render(scene, ScaleFactor::IDENTITY);
338 let mut reddish = 0;
339 for y in 0..pm.size().height {
340 for x in 0..pm.size().width {
341 if let Some([r, g, b, _]) = pm.pixel(x, y)
342 && r > 180
343 && g < 80
344 && b < 80
345 {
346 reddish += 1;
347 }
348 }
349 }
350 assert!(
351 reddish > 20,
352 "expected red glyph coverage, got {reddish} red pixels"
353 );
354 }
355}