pretext 0.1.0

Native Unicode text preparation and paragraph layout engine for Pretext.
Documentation
mod support;

use pretext::advanced::LayoutCursor;
use pretext::rich_inline::{
    measure_rich_inline_stats, prepare_rich_inline, RichInlineBreakMode, RichInlineItemSpec,
};
use pretext::{ParagraphDirection, PretextParagraphOptions, WhiteSpaceMode};

const EPSILON: f32 = 0.05;

fn options(letter_spacing: f32) -> PretextParagraphOptions {
    PretextParagraphOptions {
        white_space: WhiteSpaceMode::Normal,
        paragraph_direction: ParagraphDirection::Auto,
        letter_spacing,
        ..PretextParagraphOptions::default()
    }
}

fn pre_wrap_options(letter_spacing: f32) -> PretextParagraphOptions {
    PretextParagraphOptions {
        white_space: WhiteSpaceMode::PreWrap,
        paragraph_direction: ParagraphDirection::Auto,
        letter_spacing,
        ..PretextParagraphOptions::default()
    }
}

#[track_caller]
fn assert_close(left: f32, right: f32) {
    assert!(
        (left - right).abs() <= EPSILON,
        "expected {left} to be close to {right}"
    );
}

#[test]
fn letter_spacing_adds_inter_grapheme_gaps_without_trailing_gap() {
    let engine = support::bundled_engine();
    let style = support::default_style();
    let spacing = 4.0;

    let single = engine.prepare_paragraph("A", &style, &options(spacing));
    let pair = engine.prepare_paragraph("AB", &style, &options(spacing));
    let segmented = engine.prepare_paragraph("A B", &style, &options(spacing));

    assert_close(
        engine.layout_with_lines(&single, 200.0, 20.0).lines[0].width,
        engine.prefix_widths("A", &style)[1],
    );
    assert_close(
        engine.layout_with_lines(&pair, 200.0, 20.0).lines[0].width,
        engine.prefix_widths("AB", &style)[2] + spacing,
    );
    assert_close(
        engine.layout_with_lines(&segmented, 200.0, 20.0).lines[0].width,
        engine.prefix_widths("A B", &style)[3] + spacing * 2.0,
    );
}

#[test]
fn letter_spacing_wraps_inside_words_with_line_local_gaps() {
    let engine = support::bundled_engine();
    let style = support::default_style();
    let spacing = 5.0;
    let prepared = engine.prepare_paragraph("abcd", &style, &options(spacing));
    let two_graphemes_width = engine.prefix_widths("ab", &style)[2] + spacing;
    let wrapped = engine.layout_with_lines(&prepared, two_graphemes_width + spacing + 0.1, 20.0);

    assert_eq!(wrapped.line_count, 2);
    assert_eq!(wrapped.lines[0].text, "ab");
    assert_eq!(wrapped.lines[1].text, "cd");
    assert_close(wrapped.lines[0].width, two_graphemes_width);
    assert_close(
        wrapped.lines[1].width,
        engine.prefix_widths("cd", &style)[2] + spacing,
    );
    assert_eq!(
        engine
            .layout(
                prepared.as_prepared(),
                two_graphemes_width + spacing + 0.1,
                20.0
            )
            .line_count,
        wrapped.line_count
    );
}

#[test]
fn letter_spacing_trims_gap_before_hanging_collapsible_space() {
    let engine = support::bundled_engine();
    let style = support::default_style();
    let spacing = 6.0;
    let line_a_width = engine.prefix_widths("A", &style)[1];
    let prepared = engine.prepare_paragraph("A B", &style, &options(spacing));
    let wrapped = engine.layout_with_lines(&prepared, line_a_width + 0.1, 20.0);

    assert_eq!(wrapped.lines[0].text, "A");
    assert_close(wrapped.lines[0].width, line_a_width);
}

#[test]
fn letter_spacing_applies_across_cjk_emoji_and_punctuation() {
    let engine = support::bundled_engine();
    let style = support::default_style();
    let spacing = 2.0;
    let text = "A😀春?";
    let prepared = engine.prepare_paragraph(text, &style, &options(spacing));
    let line = &engine.layout_with_lines(&prepared, 300.0, 20.0).lines[0];
    let base_width = engine
        .prefix_widths(text, &style)
        .last()
        .copied()
        .unwrap_or(0.0);

    assert_eq!(line.text, text);
    assert_close(line.width, base_width + spacing * 3.0);
}

#[test]
fn negative_letter_spacing_tightens_inter_grapheme_gaps() {
    let engine = support::bundled_engine();
    let style = support::default_style();
    let spacing = -1.5;
    let prepared = engine.prepare_paragraph("AB", &style, &options(spacing));
    let line = &engine.layout_with_lines(&prepared, 200.0, 20.0).lines[0];

    assert_close(line.width, engine.prefix_widths("AB", &style)[2] + spacing);
}

#[test]
fn letter_spacing_stays_line_local_across_hard_breaks() {
    let engine = support::bundled_engine();
    let style = support::default_style();
    let prepared = engine.prepare_paragraph("A\nB", &style, &pre_wrap_options(4.0));
    let lines = engine.layout_with_lines(&prepared, 200.0, 20.0).lines;

    assert_eq!(lines.len(), 2);
    assert_close(lines[0].width, engine.prefix_widths("A", &style)[1]);
    assert_close(lines[1].width, engine.prefix_widths("B", &style)[1]);
}

#[test]
fn letter_spacing_participates_in_pre_wrap_tab_positioning() {
    let engine = support::bundled_engine();
    let style = support::default_style();
    let spacing = 4.0;
    let prepared = engine.prepare_paragraph("A\tB", &style, &pre_wrap_options(spacing));
    let line = &engine.layout_with_lines(&prepared, 200.0, 20.0).lines[0];
    let a_width = engine.prefix_widths("A", &style)[1];
    let b_width = engine.prefix_widths("B", &style)[1];
    let space_width = engine.prefix_widths(" ", &style)[1];
    let tab_stop = space_width * 8.0;
    let tab_input_width = a_width + spacing;
    let remainder = tab_input_width % tab_stop;
    let tab_advance = if remainder.abs() <= 0.005 {
        tab_stop
    } else {
        tab_stop - remainder
    };

    assert_eq!(line.text, "A\tB");
    assert_close(
        line.width,
        a_width + spacing + tab_advance + spacing + b_width,
    );
}

#[test]
fn letter_spacing_is_reflected_in_glyph_run_positions() {
    let engine = support::bundled_engine();
    let style = support::default_style();
    let spacing = 3.0;
    let prepared = engine.prepare_paragraph("AB", &style, &options(spacing));
    let line = engine.layout_with_lines(&prepared, 200.0, 20.0).lines[0].clone();
    let glyph_runs = engine.line_glyph_runs(&prepared, &line);
    let glyphs = &glyph_runs[0].glyphs;

    assert!(glyphs.len() >= 2);
    assert_close(glyphs[1].x, glyphs[0].advance + spacing);
    assert_close(glyph_runs[0].width, line.width);
}

#[test]
fn rich_inline_letter_spacing_applies_inside_items() {
    let engine = support::bundled_engine();
    let style = support::default_style();
    let spacing = 3.0;
    let prepared = prepare_rich_inline(
        &engine,
        &[RichInlineItemSpec {
            text: "AB",
            style: &style,
            break_mode: RichInlineBreakMode::Normal,
            extra_width: 0.0,
            letter_spacing: spacing,
        }],
    );
    let stats = measure_rich_inline_stats(&engine, &prepared, 200.0);

    assert_eq!(stats.line_count, 1);
    assert_close(
        stats.max_line_width,
        engine.prefix_widths("AB", &style)[2] + spacing,
    );
}

#[test]
fn letter_spacing_streaming_matches_batched_layout() {
    let engine = support::bundled_engine();
    let style = support::default_style();
    let prepared =
        engine.prepare_paragraph("Hello 世界 مرحبا emoji 😀 tracking", &style, &options(2.5));
    let batched = engine.layout_with_lines(&prepared, 120.0, 20.0);
    let mut cursor = LayoutCursor::default();
    let mut streamed = Vec::new();

    while let Some(line) = engine.layout_next_line(&prepared, &mut cursor, 120.0) {
        streamed.push(line);
    }

    assert_eq!(streamed, batched.lines);
}