1use crate::backend::RasterError;
7use ab_glyph::{Font, FontVec, PxScale, ScaleFont};
8use std::collections::HashMap;
9use std::sync::{Arc, Mutex, OnceLock};
10use unicode_linebreak::{linebreaks, BreakOpportunity};
11use unicode_segmentation::UnicodeSegmentation;
12
13const MAX_FONT_BYTES: u64 = 32 * 1024 * 1024;
14
15#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
17pub enum TextHorizontalAlign {
18 #[default]
19 Start,
20 Center,
21 End,
22}
23
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
26pub enum TextVerticalAlign {
27 #[default]
28 Start,
29 Center,
30 End,
31}
32
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
35pub enum TextOverflow {
36 #[default]
37 Clip,
38 Ellipsis,
39}
40
41#[derive(Debug, Clone, PartialEq)]
43pub struct TextBox {
44 pub text: String,
45 pub x: f32,
46 pub y: f32,
47 pub width: f32,
48 pub height: f32,
49 pub font_size: f32,
50 pub min_font_size: f32,
51 pub line_height: f32,
53 pub max_lines: Option<usize>,
54 pub horizontal_align: TextHorizontalAlign,
55 pub vertical_align: TextVerticalAlign,
56 pub overflow: TextOverflow,
57 pub font_sources: Vec<String>,
58}
59
60impl TextBox {
61 pub fn new(
62 text: impl Into<String>,
63 x: f32,
64 y: f32,
65 width: f32,
66 height: f32,
67 font_size: f32,
68 ) -> Self {
69 Self {
70 text: text.into(),
71 x,
72 y,
73 width,
74 height,
75 font_size,
76 min_font_size: font_size,
77 line_height: 1.2,
78 max_lines: None,
79 horizontal_align: TextHorizontalAlign::Start,
80 vertical_align: TextVerticalAlign::Start,
81 overflow: TextOverflow::Clip,
82 font_sources: Vec::new(),
83 }
84 }
85}
86
87#[derive(Debug, Clone, PartialEq)]
89pub struct PositionedTextLine {
90 pub text: String,
91 pub x: f32,
92 pub y: f32,
93}
94
95#[derive(Debug, Clone, PartialEq)]
97pub struct TextBoxLayout {
98 pub font_size: f32,
99 pub line_height: f32,
100 pub lines: Vec<PositionedTextLine>,
101}
102
103#[cfg(target_os = "macos")]
105const FONT_SEARCH_PATHS: &[&str] = &[
106 "/System/Library/Fonts/Supplemental/Arial.ttf",
107 "/System/Library/Fonts/Supplemental/Verdana.ttf",
108 "/System/Library/Fonts/Supplemental/Georgia.ttf",
109 "/System/Library/Fonts/SFNS.ttf",
110];
111
112#[cfg(target_os = "linux")]
113const FONT_SEARCH_PATHS: &[&str] = &[
114 "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
115 "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
116 "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
117 "/usr/share/fonts/TTF/DejaVuSans.ttf",
118];
119
120#[cfg(target_os = "windows")]
121const FONT_SEARCH_PATHS: &[&str] = &[
122 "C:\\Windows\\Fonts\\arial.ttf",
123 "C:\\Windows\\Fonts\\segoeui.ttf",
124 "C:\\Windows\\Fonts\\calibri.ttf",
125];
126
127#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
128const FONT_SEARCH_PATHS: &[&str] = &[];
129
130pub struct FontCache {
132 font: Option<Arc<LoadedFont>>,
133 path: Option<String>,
134 assets: Mutex<HashMap<String, Arc<LoadedFont>>>,
135}
136
137struct LoadedFont {
138 raster: FontVec,
139 data: Arc<Vec<u8>>,
140}
141
142struct ShapedGlyph {
143 font: Arc<LoadedFont>,
144 glyph: ab_glyph::Glyph,
145}
146
147impl LoadedFont {
148 fn from_bytes(bytes: Vec<u8>) -> Result<Self, ab_glyph::InvalidFont> {
149 let raster = FontVec::try_from_vec(bytes.clone())?;
150 Ok(Self {
151 raster,
152 data: Arc::new(bytes),
153 })
154 }
155}
156
157#[derive(Debug)]
158pub(crate) struct FontLoadError {
159 pub path: String,
160 pub reason: String,
161}
162
163impl FontCache {
164 pub fn load() -> Self {
166 for path in FONT_SEARCH_PATHS {
167 if let Ok(bytes) = std::fs::read(path) {
168 if let Ok(font) = LoadedFont::from_bytes(bytes) {
169 return Self {
170 font: Some(Arc::new(font)),
171 path: Some(path.to_string()),
172 assets: Mutex::new(HashMap::new()),
173 };
174 }
175 }
176 }
177 eprintln!("[dioxuscut-rasterizer] Warning: No system font found. Text will be rendered as blocks.");
178 Self {
179 font: None,
180 path: None,
181 assets: Mutex::new(HashMap::new()),
182 }
183 }
184
185 pub fn headless() -> Self {
187 Self {
188 font: None,
189 path: None,
190 assets: Mutex::new(HashMap::new()),
191 }
192 }
193
194 pub fn is_loaded(&self) -> bool {
195 self.font.is_some()
196 }
197
198 pub fn font_path(&self) -> Option<&str> {
199 self.path.as_deref()
200 }
201
202 pub(crate) fn rasterize(
204 &self,
205 text: &str,
206 font_size: f32,
207 sources: &[String],
208 ) -> Result<Option<RenderedText>, FontLoadError> {
209 let fonts = self.font_chain(sources)?;
210 if fonts.is_empty() {
211 return Ok(None);
212 }
213 let scale = PxScale::from(font_size);
214 let metric_ascent = fonts
215 .iter()
216 .map(|font| font.raster.as_scaled(scale).ascent())
217 .fold(0.0_f32, f32::max)
218 .ceil();
219 let metric_descent = fonts
220 .iter()
221 .map(|font| -font.raster.as_scaled(scale).descent())
222 .fold(0.0_f32, f32::max)
223 .ceil();
224 let (mut glyphs, advance) = shape_runs(text, font_size, &fonts)?;
225 let mut left = 0.0_f32;
226 let mut right = advance.max(0.0);
227 let mut top = -metric_ascent;
228 let mut bottom = metric_descent;
229 for glyph in &glyphs {
230 if let Some(outlined) = glyph.font.raster.outline_glyph(glyph.glyph.clone()) {
231 let bounds = outlined.px_bounds();
232 left = left.min(bounds.min.x.floor());
233 right = right.max(bounds.max.x.ceil());
234 top = top.min(bounds.min.y.floor());
235 bottom = bottom.max(bounds.max.y.ceil());
236 }
237 }
238 let horizontal_shift = -left;
239 for glyph in &mut glyphs {
240 glyph.glyph.position.x += horizontal_shift;
241 }
242 let total_width = (right - left).ceil().max(0.0) as u32;
243 let total_height = (bottom - top).ceil().max(0.0) as u32;
244 let baseline = (-top).ceil().max(0.0) as u32;
245
246 if total_width == 0 || total_height == 0 {
247 return Ok(Some(RenderedText {
248 pixels: vec![],
249 width: 0,
250 height: 0,
251 baseline,
252 }));
253 }
254
255 let mut pixels = vec![0u8; (total_width * total_height) as usize];
256
257 for glyph in &glyphs {
258 if let Some(outlined) = glyph.font.raster.outline_glyph(glyph.glyph.clone()) {
259 let bounds = outlined.px_bounds();
260 let gx = bounds.min.x.floor() as i32;
261 let gy = bounds.min.y.floor() as i32 + baseline as i32;
262
263 outlined.draw(|rx, ry, coverage| {
264 let px = gx + rx as i32;
265 let py = gy + ry as i32;
266 if px >= 0 && py >= 0 {
267 let px = px as u32;
268 let py = py as u32;
269 if px < total_width && py < total_height {
270 let idx = (py * total_width + px) as usize;
271 let existing = pixels[idx] as f32 / 255.0;
273 let blended = (existing + coverage * (1.0 - existing)).min(1.0);
274 pixels[idx] = (blended * 255.0) as u8;
275 }
276 }
277 });
278 }
279 }
280
281 Ok(Some(RenderedText {
282 pixels,
283 width: total_width,
284 height: total_height,
285 baseline,
286 }))
287 }
288
289 fn font_chain(&self, sources: &[String]) -> Result<Vec<Arc<LoadedFont>>, FontLoadError> {
290 let mut fonts = Vec::with_capacity(sources.len() + usize::from(self.font.is_some()));
291 for source in sources {
292 fonts.push(self.load_asset(source)?);
293 }
294 if let Some(font) = &self.font {
295 fonts.push(font.clone());
296 }
297 Ok(fonts)
298 }
299
300 fn load_asset(&self, source: &str) -> Result<Arc<LoadedFont>, FontLoadError> {
301 let source = source.trim();
302 let path = source.strip_prefix("file://").unwrap_or(source);
303 if path.is_empty() {
304 return Err(FontLoadError {
305 path: source.into(),
306 reason: "font source path must not be empty".into(),
307 });
308 }
309 if source.contains("://") && !source.starts_with("file://") {
310 return Err(FontLoadError {
311 path: source.into(),
312 reason: "remote font sources are not supported by native rendering".into(),
313 });
314 }
315 if let Some(font) = self
316 .assets
317 .lock()
318 .expect("font cache lock poisoned")
319 .get(path)
320 .cloned()
321 {
322 return Ok(font);
323 }
324
325 let metadata = std::fs::metadata(path).map_err(|error| FontLoadError {
326 path: source.into(),
327 reason: error.to_string(),
328 })?;
329 if !metadata.is_file() {
330 return Err(FontLoadError {
331 path: source.into(),
332 reason: "font source is not a regular file".into(),
333 });
334 }
335 if metadata.len() > MAX_FONT_BYTES {
336 return Err(FontLoadError {
337 path: source.into(),
338 reason: format!("font exceeds the {MAX_FONT_BYTES} byte safety limit"),
339 });
340 }
341 let bytes = std::fs::read(path).map_err(|error| FontLoadError {
342 path: source.into(),
343 reason: error.to_string(),
344 })?;
345 let font = Arc::new(
346 LoadedFont::from_bytes(bytes).map_err(|error| FontLoadError {
347 path: source.into(),
348 reason: format!("unsupported or invalid font data: {error:?}"),
349 })?,
350 );
351 self.assets
352 .lock()
353 .expect("font cache lock poisoned")
354 .insert(path.into(), font.clone());
355 Ok(font)
356 }
357
358 #[cfg(test)]
359 fn asset_count(&self) -> usize {
360 self.assets.lock().expect("font cache lock poisoned").len()
361 }
362}
363
364static TEXT_LAYOUT_FONT_CACHE: OnceLock<FontCache> = OnceLock::new();
365
366pub fn layout_text_box(request: &TextBox) -> Result<TextBoxLayout, RasterError> {
372 validate_text_box(request)?;
373 let cache = TEXT_LAYOUT_FONT_CACHE.get_or_init(FontCache::load);
374 let fonts = cache
375 .font_chain(&request.font_sources)
376 .map_err(font_asset_error)?;
377 if fonts.is_empty() {
378 return Err(RasterError::Scene(
379 "text box layout requires an explicit or system font".into(),
380 ));
381 }
382
383 let mut font_size = request.font_size;
384 let mut lines;
385 loop {
386 lines =
387 wrap_text(&request.text, request.width, font_size, &fonts).map_err(font_asset_error)?;
388 let line_height = font_size * request.line_height;
389 let fits_line_limit = request
390 .max_lines
391 .is_none_or(|maximum| lines.len() <= maximum);
392 let fits_height = line_height * lines.len() as f32 <= request.height + 0.01;
393 if (fits_line_limit && fits_height) || font_size <= request.min_font_size + 0.01 {
394 break;
395 }
396 font_size = (font_size - 0.5).max(request.min_font_size);
397 }
398
399 let line_height = font_size * request.line_height;
400 let height_line_limit = (request.height / line_height).floor().max(1.0) as usize;
401 let allowed_lines = request
402 .max_lines
403 .unwrap_or(usize::MAX)
404 .min(height_line_limit);
405 let truncated = lines.len() > allowed_lines;
406 lines.truncate(allowed_lines);
407 if truncated && request.overflow == TextOverflow::Ellipsis {
408 if let Some(last) = lines.last_mut() {
409 add_ellipsis(last, request.width, font_size, &fonts).map_err(font_asset_error)?;
410 }
411 }
412
413 let content_height = line_height * lines.len() as f32;
414 let vertical_offset = match request.vertical_align {
415 TextVerticalAlign::Start => 0.0,
416 TextVerticalAlign::Center => (request.height - content_height).max(0.0) * 0.5,
417 TextVerticalAlign::End => (request.height - content_height).max(0.0),
418 };
419 let ascent = fonts
420 .iter()
421 .map(|font| font.raster.as_scaled(PxScale::from(font_size)).ascent())
422 .fold(0.0_f32, f32::max);
423 let mut positioned = Vec::with_capacity(lines.len());
424 for (index, line) in lines.into_iter().enumerate() {
425 let width = measure_text(&line, font_size, &fonts).map_err(font_asset_error)?;
426 let horizontal_offset = match request.horizontal_align {
427 TextHorizontalAlign::Start => 0.0,
428 TextHorizontalAlign::Center => (request.width - width).max(0.0) * 0.5,
429 TextHorizontalAlign::End => (request.width - width).max(0.0),
430 };
431 positioned.push(PositionedTextLine {
432 text: line,
433 x: request.x + horizontal_offset,
434 y: request.y + vertical_offset + ascent + index as f32 * line_height,
435 });
436 }
437
438 Ok(TextBoxLayout {
439 font_size,
440 line_height,
441 lines: positioned,
442 })
443}
444
445pub fn measure_text_width(
447 text: &str,
448 font_size: f32,
449 font_sources: &[String],
450) -> Result<f32, RasterError> {
451 if !font_size.is_finite() || font_size <= 0.0 || font_size > 4096.0 {
452 return Err(RasterError::Scene(
453 "text measurement font size must be between 0 and 4096".into(),
454 ));
455 }
456 let cache = TEXT_LAYOUT_FONT_CACHE.get_or_init(FontCache::load);
457 let fonts = cache.font_chain(font_sources).map_err(font_asset_error)?;
458 if fonts.is_empty() {
459 return Err(RasterError::Scene(
460 "text measurement requires an explicit or system font".into(),
461 ));
462 }
463 measure_text(text, font_size, &fonts).map_err(font_asset_error)
464}
465
466fn validate_text_box(request: &TextBox) -> Result<(), RasterError> {
467 let finite = [
468 ("x", request.x),
469 ("y", request.y),
470 ("width", request.width),
471 ("height", request.height),
472 ("font size", request.font_size),
473 ("minimum font size", request.min_font_size),
474 ("line height", request.line_height),
475 ];
476 if let Some((name, value)) = finite.iter().find(|(_, value)| !value.is_finite()) {
477 return Err(RasterError::Scene(format!(
478 "text box {name} must be finite, got {value}"
479 )));
480 }
481 if request.width <= 0.0 || request.height <= 0.0 {
482 return Err(RasterError::Scene(
483 "text box width and height must be positive".into(),
484 ));
485 }
486 if request.font_size <= 0.0 || request.font_size > 4096.0 {
487 return Err(RasterError::Scene(
488 "text box font size must be between 0 and 4096".into(),
489 ));
490 }
491 if request.min_font_size <= 0.0 || request.min_font_size > request.font_size {
492 return Err(RasterError::Scene(
493 "minimum font size must be positive and no larger than font size".into(),
494 ));
495 }
496 if !(0.5..=10.0).contains(&request.line_height) {
497 return Err(RasterError::Scene(
498 "text box line height multiplier must be between 0.5 and 10".into(),
499 ));
500 }
501 if request.max_lines == Some(0) {
502 return Err(RasterError::Scene(
503 "text box max lines must be at least one".into(),
504 ));
505 }
506 Ok(())
507}
508
509fn wrap_text(
510 text: &str,
511 max_width: f32,
512 font_size: f32,
513 fonts: &[Arc<LoadedFont>],
514) -> Result<Vec<String>, FontLoadError> {
515 if text.is_empty() {
516 return Ok(vec![String::new()]);
517 }
518 let mut lines = Vec::new();
519 let mut current = String::new();
520 let mut segment_start = 0;
521 for (break_index, opportunity) in linebreaks(text) {
522 let raw_segment = &text[segment_start..break_index];
523 let segment = if opportunity == BreakOpportunity::Mandatory {
524 raw_segment.trim_end_matches(['\r', '\n'])
525 } else {
526 raw_segment
527 };
528 append_wrapped_segment(
529 &mut lines,
530 &mut current,
531 segment,
532 max_width,
533 font_size,
534 fonts,
535 )?;
536 if opportunity == BreakOpportunity::Mandatory {
537 lines.push(current.trim_end().to_string());
538 current.clear();
539 }
540 segment_start = break_index;
541 }
542 if segment_start < text.len() {
543 append_wrapped_segment(
544 &mut lines,
545 &mut current,
546 &text[segment_start..],
547 max_width,
548 font_size,
549 fonts,
550 )?;
551 }
552 if !current.is_empty() || lines.is_empty() {
553 lines.push(current.trim_end().to_string());
554 }
555 Ok(lines)
556}
557
558fn append_wrapped_segment(
559 lines: &mut Vec<String>,
560 current: &mut String,
561 segment: &str,
562 max_width: f32,
563 font_size: f32,
564 fonts: &[Arc<LoadedFont>],
565) -> Result<(), FontLoadError> {
566 let candidate = format!("{current}{segment}");
567 if measure_text(&candidate, font_size, fonts)? <= max_width || current.is_empty() {
568 *current = candidate;
569 } else {
570 lines.push(current.trim_end().to_string());
571 *current = segment.trim_start().to_string();
572 }
573 if measure_text(current, font_size, fonts)? <= max_width {
574 return Ok(());
575 }
576
577 let oversized = std::mem::take(current);
578 let mut part = String::new();
579 for grapheme in oversized.graphemes(true) {
580 let candidate = format!("{part}{grapheme}");
581 if !part.is_empty() && measure_text(&candidate, font_size, fonts)? > max_width {
582 lines.push(part);
583 part = grapheme.to_string();
584 } else {
585 part = candidate;
586 }
587 }
588 *current = part;
589 Ok(())
590}
591
592fn add_ellipsis(
593 line: &mut String,
594 max_width: f32,
595 font_size: f32,
596 fonts: &[Arc<LoadedFont>],
597) -> Result<(), FontLoadError> {
598 *line = line.trim_end().to_string();
599 loop {
600 let candidate = format!("{line}…");
601 if measure_text(&candidate, font_size, fonts)? <= max_width || line.is_empty() {
602 *line = candidate;
603 return Ok(());
604 }
605 let Some((index, _)) = line.grapheme_indices(true).next_back() else {
606 line.push('…');
607 return Ok(());
608 };
609 line.truncate(index);
610 }
611}
612
613fn measure_text(
614 text: &str,
615 font_size: f32,
616 fonts: &[Arc<LoadedFont>],
617) -> Result<f32, FontLoadError> {
618 let (_, advance) = shape_runs(text, font_size, fonts)?;
619 Ok(advance.abs())
620}
621
622fn font_asset_error(error: FontLoadError) -> RasterError {
623 RasterError::FontAsset {
624 path: error.path,
625 reason: error.reason,
626 }
627}
628
629fn shape_runs(
630 text: &str,
631 font_size: f32,
632 fonts: &[Arc<LoadedFont>],
633) -> Result<(Vec<ShapedGlyph>, f32), FontLoadError> {
634 let mut runs: Vec<(usize, usize, usize)> = Vec::new();
635 for (start, grapheme) in text.grapheme_indices(true) {
636 let font_index = fonts
637 .iter()
638 .position(|font| grapheme_supported(&font.raster, grapheme))
639 .unwrap_or(0);
640 let end = start + grapheme.len();
641 if let Some((last_font, _, last_end)) = runs.last_mut() {
642 if *last_font == font_index && *last_end == start {
643 *last_end = end;
644 continue;
645 }
646 }
647 runs.push((font_index, start, end));
648 }
649
650 let mut output = Vec::new();
651 let mut cursor_x = 0.0_f32;
652 for (font_index, start, end) in runs {
653 let font = fonts[font_index].clone();
654 let face =
655 rustybuzz::Face::from_slice(font.data.as_slice(), 0).ok_or_else(|| FontLoadError {
656 path: "<loaded font>".into(),
657 reason: "font could not be opened by the shaping engine".into(),
658 })?;
659 let units_per_em = (face.units_per_em() as f32).max(1.0);
660 let unit_scale = font_size / units_per_em;
661 let mut buffer = rustybuzz::UnicodeBuffer::new();
662 buffer.push_str(&text[start..end]);
663 buffer.guess_segment_properties();
664 let shaped = rustybuzz::shape(&face, &[], buffer);
665 for (info, position) in shaped.glyph_infos().iter().zip(shaped.glyph_positions()) {
666 let Ok(glyph_id) = u16::try_from(info.glyph_id) else {
667 continue;
668 };
669 let x = cursor_x + position.x_offset as f32 * unit_scale;
670 let y = -(position.y_offset as f32 * unit_scale);
671 output.push(ShapedGlyph {
672 font: font.clone(),
673 glyph: ab_glyph::GlyphId(glyph_id)
674 .with_scale_and_position(PxScale::from(font_size), ab_glyph::point(x, y)),
675 });
676 cursor_x += position.x_advance as f32 * unit_scale;
677 }
678 }
679 Ok((output, cursor_x))
680}
681
682fn grapheme_supported(font: &FontVec, grapheme: &str) -> bool {
683 grapheme.chars().all(|character| {
684 font.glyph_id(character).0 != 0
685 || character.is_control()
686 || character.is_whitespace()
687 || character == '\u{200d}'
688 || ('\u{fe00}'..='\u{fe0f}').contains(&character)
689 || ('\u{e0100}'..='\u{e01ef}').contains(&character)
690 })
691}
692
693#[derive(Debug)]
695pub struct RenderedText {
696 pub pixels: Vec<u8>,
698 pub width: u32,
699 pub height: u32,
700 pub baseline: u32,
702}
703
704#[cfg(test)]
705mod tests {
706 use super::*;
707
708 #[test]
709 fn test_font_cache_loads() {
710 let cache = FontCache::load();
711 if cache.is_loaded() {
713 println!("Loaded font from: {:?}", cache.font_path());
714 } else {
715 println!("No system font found — placeholder mode active");
716 }
717 }
718
719 #[test]
720 fn test_rasterize_hello() {
721 let cache = FontCache::load();
722 if !cache.is_loaded() {
723 return; }
725 let rendered = cache
726 .rasterize("Hello", 32.0, &[])
727 .expect("font load failed")
728 .expect("rasterize failed");
729 assert!(rendered.width > 0, "Width should be > 0");
730 assert!(rendered.height > 0, "Height should be > 0");
731 let has_coverage = rendered.pixels.iter().any(|&p| p > 0);
733 assert!(has_coverage, "At least one pixel should have coverage");
734 }
735
736 #[test]
737 fn test_rasterize_empty() {
738 let cache = FontCache::load();
739 if !cache.is_loaded() {
740 return;
741 }
742 let rendered = cache
743 .rasterize("", 24.0, &[])
744 .expect("font load failed")
745 .expect("rasterize failed");
746 assert_eq!(rendered.width, 0, "Empty string should have 0 width");
747 }
748
749 #[test]
750 fn explicit_font_sources_are_cached() {
751 let Some(path) = FONT_SEARCH_PATHS
752 .iter()
753 .find(|path| std::path::Path::new(path).is_file())
754 else {
755 return;
756 };
757 let cache = FontCache::headless();
758 let sources = vec![path.to_string()];
759 let first = cache.rasterize("Explicit", 24.0, &sources).unwrap();
760 let second = cache.rasterize("Explicit", 24.0, &sources).unwrap();
761
762 assert!(first.is_some());
763 assert!(second.is_some());
764 assert_eq!(cache.asset_count(), 1);
765 }
766
767 #[test]
768 fn missing_explicit_font_is_an_error() {
769 let cache = FontCache::headless();
770 let sources = vec!["/dioxuscut/does-not-exist.ttf".to_string()];
771 let error = cache.rasterize("Missing", 24.0, &sources).unwrap_err();
772
773 assert!(error.path.ends_with("does-not-exist.ttf"));
774 }
775
776 #[test]
777 fn shaping_produces_positioned_glyphs() {
778 let Some(path) = font_fixture() else {
779 return;
780 };
781 let cache = FontCache::headless();
782 let fonts = cache
783 .font_chain(&[path])
784 .expect("system fixture font should load");
785 let (glyphs, advance) = shape_runs("office", 32.0, &fonts).unwrap();
786
787 assert!(!glyphs.is_empty());
788 assert!(glyphs.len() <= "office".chars().count());
789 assert!(advance > 0.0);
790 }
791
792 fn font_fixture() -> Option<String> {
793 FontCache::load().font_path().map(str::to_owned)
794 }
795
796 #[test]
797 fn text_box_wraps_fits_and_adds_ellipsis() {
798 let Some(font) = font_fixture() else {
799 return;
800 };
801 let mut request = TextBox::new(
802 "one two three four five six seven eight",
803 10.0,
804 20.0,
805 120.0,
806 48.0,
807 30.0,
808 );
809 request.min_font_size = 14.0;
810 request.max_lines = Some(2);
811 request.horizontal_align = TextHorizontalAlign::Center;
812 request.vertical_align = TextVerticalAlign::Center;
813 request.overflow = TextOverflow::Ellipsis;
814 request.font_sources = vec![font];
815
816 let layout = layout_text_box(&request).unwrap();
817
818 assert!(layout.font_size < 30.0);
819 assert!(layout.lines.len() <= 2);
820 assert!(layout.lines.iter().all(|line| line.x >= request.x));
821 }
822
823 #[test]
824 fn text_box_preserves_mandatory_line_breaks() {
825 let Some(font) = font_fixture() else {
826 return;
827 };
828 let mut request = TextBox::new("first\nsecond", 0.0, 0.0, 300.0, 100.0, 24.0);
829 request.font_sources = vec![font];
830 let layout = layout_text_box(&request).unwrap();
831
832 assert_eq!(layout.lines.len(), 2);
833 assert_eq!(layout.lines[0].text, "first");
834 assert_eq!(layout.lines[1].text, "second");
835 assert!(layout.lines[1].y > layout.lines[0].y);
836 }
837
838 #[test]
839 fn text_box_ellipsizes_when_minimum_size_still_overflows() {
840 let Some(font) = font_fixture() else {
841 return;
842 };
843 let mut request = TextBox::new("one two three four five six", 0.0, 0.0, 90.0, 30.0, 24.0);
844 request.max_lines = Some(1);
845 request.overflow = TextOverflow::Ellipsis;
846 request.font_sources = vec![font];
847 let layout = layout_text_box(&request).unwrap();
848
849 assert_eq!(layout.lines.len(), 1);
850 assert!(layout.lines[0].text.ends_with('…'));
851 assert!(
852 measure_text_width(
853 &layout.lines[0].text,
854 layout.font_size,
855 &request.font_sources
856 )
857 .unwrap()
858 <= request.width
859 );
860 }
861
862 #[test]
863 fn text_box_rejects_invalid_bounds() {
864 let request = TextBox::new("invalid", 0.0, 0.0, 0.0, 100.0, 24.0);
865 assert!(layout_text_box(&request)
866 .unwrap_err()
867 .to_string()
868 .contains("width and height"));
869 }
870}