cranpose-text 0.22.3

Text styles, fonts, shaping and paragraph layout for Cranpose
Documentation
use cranpose_text::{
    FontFamily, FontFamilyResolver, LayoutInput, ParagraphLengths, ParagraphStyle, TextAlign,
    TextLayout, TextLayoutOptions, TextLayouter, TextOverflow,
};

use crate::fonts::{ROBOTO, span, typeface};

const SHORT: &str = "Short text";
const LONG: &str = "A longer text that wraps onto more than one line at this width";

// Lays `text` out between `min_width` and `max_width` on a grid of `grid`
// device pixels a pixel, with `options`.
fn layout(
    resolver: &FontFamilyResolver,
    text: &str,
    (min_width, max_width): (f32, f32),
    grid: f32,
    options: TextLayoutOptions,
) -> TextLayout {
    let family = FontFamily::Default;
    let spans = [span(0..text.len(), &family, 400)];
    TextLayouter::default().layout(
        &LayoutInput {
            text,
            spans: &spans,
            paragraph: &ParagraphStyle::default(),
            lengths: ParagraphLengths {
                grid,
                ..ParagraphLengths::default()
            },
            min_width,
            max_width,
            options,
        },
        resolver,
    )
}

fn natural(laid: &TextLayout) -> f32 {
    laid.line_right(0) - laid.line_left(0)
}

#[test]
fn text_that_fits_takes_its_own_width_rounded_up_to_whole_device_pixels() {
    let resolver = FontFamilyResolver::new([typeface(ROBOTO)]);
    for grid in [1.0, 2.0, 2.75] {
        let laid = layout(
            &resolver,
            SHORT,
            (0.0, 500.0),
            grid,
            TextLayoutOptions::default(),
        );

        let expected = (natural(&laid) * grid).ceil() / grid;
        assert!(
            (laid.width() - expected).abs() < 1e-4,
            "{grid}: {}",
            laid.width()
        );
        assert!(!laid.did_overflow_width());
    }
}

#[test]
fn wrapped_text_takes_its_widest_line_and_a_min_width_widens_short_text() {
    let resolver = FontFamilyResolver::new([typeface(ROBOTO)]);

    let wrapped = layout(
        &resolver,
        LONG,
        (0.0, 150.0),
        1.0,
        TextLayoutOptions::default(),
    );
    let widened = layout(
        &resolver,
        SHORT,
        (300.0, 500.0),
        1.0,
        TextLayoutOptions::default(),
    );
    let fixed = layout(
        &resolver,
        SHORT,
        (200.0, 200.0),
        1.0,
        TextLayoutOptions::default(),
    );

    assert!(wrapped.line_count() > 1);
    let widest = (0..wrapped.line_count())
        .map(|line| wrapped.line_right(line) - wrapped.line_left(line))
        .fold(0.0, f32::max);
    assert!(widest < 150.0);
    assert_eq!(wrapped.width(), widest.ceil());
    assert_eq!(widened.width(), 300.0);
    assert_eq!(fixed.width(), 200.0);
}

#[test]
fn wrapped_text_keeps_its_width_at_the_max_widths_that_keep_its_lines() {
    let resolver = FontFamilyResolver::new([typeface(ROBOTO)]);
    let options = TextLayoutOptions::default();
    // The second text is a word its max cuts between its letters: its width
    // is that max.
    for (text, laid_at) in [(LONG, 150.0), ("three", 4.0)] {
        let mut laid = layout(&resolver, text, (0.0, laid_at), 1.0, options);
        let (width, lines) = (laid.width(), laid.line_count());

        let (low, high) = laid.max_widths_kept(0.0);
        assert!(low <= laid_at && laid_at <= high, "{text}: {low}..{high}");
        for max_width in [low, (low + high) / 2.0, high] {
            assert!(laid.holds(0.0, max_width), "{text} at {max_width}");
            laid.align(0.0, max_width, TextAlign::Start);
            let fresh = layout(&resolver, text, (0.0, max_width), 1.0, options);
            assert_eq!(
                (laid.width(), fresh.width(), fresh.line_count()),
                (width, width, lines),
                "{text} at {max_width}"
            );
        }
        // Past the high end the text takes another width or other lines.
        let past = layout(&resolver, text, (0.0, high + 1.0), 1.0, options);
        assert_ne!(
            (past.line_count(), past.width().to_bits()),
            (lines, width.to_bits()),
            "{text}"
        );
    }
}

#[test]
fn unwrapped_clipped_text_keeps_its_own_width_past_the_max_and_overflows_it() {
    let resolver = FontFamilyResolver::new([typeface(ROBOTO)]);
    let options = TextLayoutOptions {
        soft_wrap: false,
        overflow: TextOverflow::Clip,
        ..TextLayoutOptions::default()
    };

    let laid = layout(&resolver, LONG, (0.0, 150.0), 1.0, options);

    assert_eq!(laid.line_count(), 1);
    assert_eq!(laid.width(), natural(&laid).ceil());
    assert!(laid.did_overflow_width());
}

// How far the lines reach across spans the visible text of every line, as
// the layout places them at their indents and as it aligns them across its
// width.
#[test]
fn the_lines_reach_spans_the_visible_text_of_its_lines_wherever_they_lie() {
    let resolver = FontFamilyResolver::new([typeface(ROBOTO)]);
    let spanned = |laid: &TextLayout| {
        (0..laid.line_count()).fold((f32::INFINITY, f32::NEG_INFINITY), |(left, right), line| {
            (
                left.min(laid.line_left(line)),
                right.max(laid.line_right(line)),
            )
        })
    };
    for (text, width) in [(SHORT, 300.0), (LONG, 150.0)] {
        let mut laid = layout(
            &resolver,
            text,
            (width, width),
            1.0,
            TextLayoutOptions::default(),
        );
        let at_indents = laid.lines_reach();
        assert_eq!(at_indents, spanned(&laid), "{text}");
        for align in [
            TextAlign::Center,
            TextAlign::End,
            TextAlign::Justify,
            TextAlign::Start,
        ] {
            laid.align(width, width, align);
            assert_eq!(laid.lines_reach(), spanned(&laid), "{text} {align:?}");
        }
        assert_eq!(laid.lines_reach(), at_indents, "{text}");
    }
    let mut short = layout(
        &resolver,
        SHORT,
        (300.0, 300.0),
        1.0,
        TextLayoutOptions::default(),
    );
    let at_indents = short.lines_reach();
    short.align(300.0, 300.0, TextAlign::Center);
    assert_ne!(short.lines_reach(), at_indents, "centering moves the line");
}