pretext 0.1.0

Native Unicode text preparation and paragraph layout engine for Pretext.
Documentation
use std::ops::Range;

use unicode_bidi::{BidiInfo, Level};

#[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq)]
pub enum ParagraphDirection {
    #[default]
    Auto,
    Ltr,
    Rtl,
}

#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
pub enum BidiDirection {
    Ltr,
    Rtl,
}

#[derive(Clone, Debug)]
pub struct BidiRun {
    pub byte_range: Range<usize>,
    pub level: Level,
    pub direction: BidiDirection,
}

pub fn paragraph_to_bidi_runs(text: &str, base_direction: ParagraphDirection) -> Vec<BidiRun> {
    if text.is_empty() {
        return Vec::new();
    }

    let default_para_level = match base_direction {
        ParagraphDirection::Auto => None,
        ParagraphDirection::Ltr => Some(Level::ltr()),
        ParagraphDirection::Rtl => Some(Level::rtl()),
    };
    let bidi = BidiInfo::new(text, default_para_level);
    if bidi.levels.is_empty() {
        return vec![BidiRun {
            byte_range: 0..text.len(),
            level: Level::ltr(),
            direction: paragraph_direction(&bidi),
        }];
    }

    let mut runs = Vec::new();
    for para in &bidi.paragraphs {
        runs.extend(coalesce_byte_levels(&bidi.levels, text, para.range.clone()));
    }

    if runs.is_empty() {
        runs.push(BidiRun {
            byte_range: 0..text.len(),
            level: Level::ltr(),
            direction: paragraph_direction(&bidi),
        });
    }

    runs
}

fn coalesce_byte_levels(levels: &[Level], text: &str, byte_range: Range<usize>) -> Vec<BidiRun> {
    if byte_range.start >= byte_range.end || byte_range.end > text.len() {
        return Vec::new();
    }

    debug_assert_eq!(levels.len(), text.len());
    debug_assert!(text.is_char_boundary(byte_range.start));
    debug_assert!(text.is_char_boundary(byte_range.end));

    let mut starts = text[byte_range.clone()]
        .char_indices()
        .map(|(offset, _)| byte_range.start + offset);
    let Some(mut run_start) = starts.next() else {
        return Vec::new();
    };
    let mut current = levels[run_start];
    let mut runs = Vec::new();

    for byte_start in starts {
        let level = levels[byte_start];
        if level == current {
            continue;
        }

        runs.push(BidiRun {
            byte_range: run_start..byte_start,
            level: current,
            direction: direction_from_level(current),
        });
        run_start = byte_start;
        current = level;
    }

    runs.push(BidiRun {
        byte_range: run_start..byte_range.end,
        level: current,
        direction: direction_from_level(current),
    });
    runs
}

fn paragraph_direction(bidi: &BidiInfo<'_>) -> BidiDirection {
    bidi.paragraphs
        .first()
        .map(|para| direction_from_level(para.level))
        .unwrap_or(BidiDirection::Ltr)
}

fn direction_from_level(level: Level) -> BidiDirection {
    if level.is_rtl() {
        BidiDirection::Rtl
    } else {
        BidiDirection::Ltr
    }
}