Skip to main content

dioxuscut_rasterizer/
font.rs

1//! Font loading and text rasterization via `ab_glyph`.
2//!
3//! Discovers a system font at runtime and caches it for the lifetime of the renderer.
4//! Falls back gracefully if no font is found.
5
6use 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/// Horizontal alignment inside a resolved text box.
16#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
17pub enum TextHorizontalAlign {
18    #[default]
19    Start,
20    Center,
21    End,
22}
23
24/// Vertical alignment inside a resolved text box.
25#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
26pub enum TextVerticalAlign {
27    #[default]
28    Start,
29    Center,
30    End,
31}
32
33/// Behavior when text still exceeds its box at the minimum font size.
34#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
35pub enum TextOverflow {
36    #[default]
37    Clip,
38    Ellipsis,
39}
40
41/// Font-aware text box request resolved before nodes are added to a Scene.
42#[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    /// Multiplier applied to the resolved font size.
52    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/// One baseline-positioned line produced by [`layout_text_box`].
88#[derive(Debug, Clone, PartialEq)]
89pub struct PositionedTextLine {
90    pub text: String,
91    pub x: f32,
92    pub y: f32,
93}
94
95/// Resolved size and lines for deterministic native and Player rendering.
96#[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/// Platform-specific font search paths, in preference order.
104#[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
130/// A loaded, ready-to-use font.
131pub 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    /// Discover and load the first available system font.
165    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    /// Create a FontCache with no font loaded (for headless/test environments).
186    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    /// Rasterize text with ordered explicit local fonts followed by the system fallback.
203    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                            // Accumulate coverage (clamp to 255)
272                            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
366/// Resolve Unicode line breaks, font fitting, ellipsis, and box alignment.
367///
368/// The returned lines retain explicit baseline positions and can therefore be
369/// emitted as ordinary [`crate::scene::SceneNode::Text`] nodes for both native
370/// export and Player preview.
371pub 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
445/// Measure a shaped single line using the same font chain as native rendering.
446pub 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/// Rasterized text as a greyscale coverage map.
694#[derive(Debug)]
695pub struct RenderedText {
696    /// Single-channel (alpha coverage) pixel data, row-major.
697    pub pixels: Vec<u8>,
698    pub width: u32,
699    pub height: u32,
700    /// Row index of the baseline within the pixel buffer.
701    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        // On macOS this should always succeed; on other platforms it may not
712        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; // skip if no font available
724        }
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        // At least some pixels should have coverage
732        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}