Skip to main content

cranpose_text/
itemizer.rs

1use std::ops::Range;
2
3use crate::{
4    bidi_level::{BidiLevel, level_runs},
5    break_leads::find_break_lead,
6    font_family_resolver::FontFamilyResolver,
7    paragraph::{ResolvedTextDirection, TextDirection},
8    script::{Script, ascii_script, script_runs},
9    shaping_span::ShapingSpan,
10    typeface::Typeface,
11};
12
13/// A stretch of text that one typeface shapes in one direction and script,
14/// inside one style span.
15#[derive(Clone, Debug)]
16pub struct TextItem<'a> {
17    /// The byte range in the text.
18    pub range: Range<usize>,
19    /// The bidi embedding level.
20    pub level: BidiLevel,
21    /// The base level of the paragraph the item is in.
22    pub paragraph_level: BidiLevel,
23    /// The script.
24    pub script: Script,
25    /// The typeface that draws it.
26    pub typeface: &'a Typeface,
27    /// The index of the style span it lies in.
28    pub span: usize,
29}
30
31/// Splits text into the items a shaper takes, in logical order: paragraphs at
32/// bidi paragraph separators, each with the direction `TextDirection` gives
33/// it, then the runs of one bidi level, one script, one style span and one
34/// typeface of that span. Keeps its scratch storage from one text to the next.
35#[derive(Default)]
36pub struct Itemizer {
37    levels: Vec<(usize, BidiLevel)>,
38    scripts: Vec<(usize, Script)>,
39    // The end of each font run, its typeface and its span.
40    fonts: Vec<(usize, usize, usize)>,
41}
42
43impl Itemizer {
44    /// Passes the items of `text` to `visit`. `spans` cover the text in
45    /// order with no gap.
46    pub fn itemize<'r>(
47        &mut self,
48        text: &str,
49        direction: TextDirection,
50        spans: &[ShapingSpan<'_>],
51        resolver: &'r FontFamilyResolver,
52        mut visit: impl FnMut(TextItem<'r>),
53    ) {
54        let mut start = 0;
55        while start < text.len() {
56            let end = paragraph_end(text, start);
57            self.itemize_paragraph(text, start..end, direction, spans, resolver, &mut visit);
58            start = end;
59        }
60    }
61
62    fn itemize_paragraph<'r>(
63        &mut self,
64        text: &str,
65        paragraph: Range<usize>,
66        direction: TextDirection,
67        spans: &[ShapingSpan<'_>],
68        resolver: &'r FontFamilyResolver,
69        visit: &mut impl FnMut(TextItem<'r>),
70    ) {
71        let Some(content) = text.get(paragraph.clone()) else {
72            return;
73        };
74        let base = match direction.resolve(content) {
75            ResolvedTextDirection::Ltr => BidiLevel::LTR,
76            ResolvedTextDirection::Rtl => BidiLevel::RTL,
77        };
78        // Left to right ASCII in one span, most text, is one level and one
79        // script: its items are the runs of its typefaces.
80        if let [span] = spans
81            && base == BidiLevel::LTR
82            && content.is_ascii()
83        {
84            let script = ascii_script(content);
85            resolver.typeface_runs(content, span.font, |run, typeface| {
86                visit(TextItem {
87                    range: paragraph.start + run.start..paragraph.start + run.end,
88                    level: base,
89                    paragraph_level: base,
90                    script,
91                    typeface: &resolver.typefaces()[typeface],
92                    span: 0,
93                });
94            });
95            return;
96        }
97        self.itemize_mixed((text, content), paragraph, base, (spans, resolver), visit);
98    }
99
100    // The items of `content`, a paragraph of `text` with more than one span,
101    // text right to left or text past ASCII: the runs of its bidi levels,
102    // scripts and typefaces. Out of line, as most text takes the ASCII
103    // items above.
104    #[inline(never)]
105    fn itemize_mixed<'r>(
106        &mut self,
107        (text, content): (&str, &str),
108        paragraph: Range<usize>,
109        base: BidiLevel,
110        (spans, resolver): (&[ShapingSpan<'_>], &'r FontFamilyResolver),
111        visit: &mut impl FnMut(TextItem<'r>),
112    ) {
113        self.levels.clear();
114        level_runs(content, base, paragraph.start, &mut self.levels);
115        self.scripts.clear();
116        script_runs(content, paragraph.start, &mut self.scripts);
117        self.fonts.clear();
118        for (index, span) in spans.iter().enumerate() {
119            let start = span.range.start.max(paragraph.start);
120            let end = span.range.end.min(paragraph.end);
121            if let Some(part) = text.get(start..end).filter(|part| !part.is_empty()) {
122                resolver.typeface_runs(part, span.font, |run, typeface| {
123                    self.fonts.push((start + run.end, typeface, index));
124                });
125            }
126        }
127        let (mut level, mut script, mut font) = (0, 0, 0);
128        let mut start = paragraph.start;
129        while let (
130            Some(&(level_end, bidi)),
131            Some(&(script_end, writing)),
132            Some(&(font_end, typeface, span)),
133        ) = (
134            self.levels.get(level),
135            self.scripts.get(script),
136            self.fonts.get(font),
137        ) {
138            let end = level_end.min(script_end).min(font_end);
139            visit(TextItem {
140                range: start..end,
141                level: bidi,
142                paragraph_level: base,
143                script: writing,
144                typeface: &resolver.typefaces()[typeface],
145                span,
146            });
147            level += usize::from(level_end == end);
148            script += usize::from(script_end == end);
149            font += usize::from(font_end == end);
150            start = end;
151        }
152    }
153}
154
155// The end of the paragraph that starts at `start`: just past the next bidi
156// paragraph separator (class B: line feed, carriage return and line feed as
157// one, the information separators 1C to 1E, next line and the paragraph
158// separator), or the end of `text`. Found by the first byte of each, which
159// in UTF-8 starts no other character it is not part of.
160fn paragraph_end(text: &str, start: usize) -> usize {
161    let bytes = text.as_bytes();
162    let mut at = start;
163    while let Some(found) = find_break_lead(bytes, at) {
164        at = found;
165        let after = match &bytes[at..] {
166            [b'\r', b'\n', ..] => at + 2,
167            [b'\n' | b'\r' | 0x1C..=0x1E, ..] => at + 1,
168            [0xC2, 0x85, ..] => at + 2,
169            [0xE2, 0x80, 0xA9, ..] => at + 3,
170            _ => {
171                at += 1;
172                continue;
173            }
174        };
175        return after;
176    }
177    text.len()
178}