azul_layout/text/
layout.rs

1//! Contains functions for breaking a string into words, calculate
2//! the positions of words / lines and do glyph positioning
3
4use alloc::{string::String, vec::Vec};
5
6pub use azul_core::{
7    app_resources::{
8        FontMetrics, GlyphIndex, IndexOfLineBreak, LayoutedGlyphs, LineBreaks, LineLength,
9        RemainingSpaceToRight, ShapedWord, ShapedWords, Word, WordIndex, WordPositions, WordType,
10        Words,
11    },
12    callbacks::InlineText,
13    display_list::GlyphInstance,
14    ui_solver::{
15        InlineTextLayout, ResolvedTextLayoutOptions, TextLayoutOptions, DEFAULT_LETTER_SPACING,
16        DEFAULT_LINE_HEIGHT, DEFAULT_TAB_WIDTH, DEFAULT_WORD_SPACING,
17    },
18    window::{LogicalPosition, LogicalRect, LogicalSize},
19};
20pub use azul_css::FontRef;
21
22pub use super::shaping::ParsedFont;
23
24/// Creates a font from a font file (TTF, OTF, WOFF, etc.)
25///
26/// NOTE: EXPENSIVE function, needs to parse tables, etc.
27pub fn parse_font(
28    font_bytes: &[u8],
29    font_index: usize,
30    parse_outlines: bool,
31) -> Option<ParsedFont> {
32    ParsedFont::from_bytes(font_bytes, font_index, parse_outlines)
33}
34
35/// Splits the text by whitespace into logical units (word, tab, return, whitespace).
36pub fn split_text_into_words(text: &str) -> Words {
37    use unicode_normalization::UnicodeNormalization;
38
39    // Necessary because we need to handle both \n and \r\n characters
40    // If we just look at the characters one-by-one, this wouldn't be possible.
41    let normalized_string = text.nfc().collect::<String>();
42    let normalized_chars = normalized_string.chars().collect::<Vec<char>>();
43
44    let mut words = Vec::new();
45
46    // Instead of storing the actual word, the word is only stored as an index instead,
47    // which reduces allocations and is important for later on introducing RTL text
48    // (where the position of the character data does not correspond to the actual glyph order).
49    let mut current_word_start = 0;
50    let mut last_char_idx = 0;
51    let mut last_char_was_whitespace = false;
52
53    for (ch_idx, ch) in normalized_chars.iter().enumerate() {
54        let ch = *ch;
55        let current_char_is_whitespace = ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n';
56
57        let should_push_delimiter = match ch {
58            ' ' => Some(Word {
59                start: last_char_idx + 1,
60                end: ch_idx + 1,
61                word_type: WordType::Space,
62            }),
63            '\t' => Some(Word {
64                start: last_char_idx + 1,
65                end: ch_idx + 1,
66                word_type: WordType::Tab,
67            }),
68            '\n' => {
69                Some(if normalized_chars[last_char_idx] == '\r' {
70                    // "\r\n" return
71                    Word {
72                        start: last_char_idx,
73                        end: ch_idx + 1,
74                        word_type: WordType::Return,
75                    }
76                } else {
77                    // "\n" return
78                    Word {
79                        start: last_char_idx + 1,
80                        end: ch_idx + 1,
81                        word_type: WordType::Return,
82                    }
83                })
84            }
85            _ => None,
86        };
87
88        // Character is a whitespace or the character is the last character in the text (end of
89        // text)
90        let should_push_word = if current_char_is_whitespace && !last_char_was_whitespace {
91            Some(Word {
92                start: current_word_start,
93                end: ch_idx,
94                word_type: WordType::Word,
95            })
96        } else {
97            None
98        };
99
100        if current_char_is_whitespace {
101            current_word_start = ch_idx + 1;
102        }
103
104        let mut push_words = |arr: [Option<Word>; 2]| {
105            words.extend(arr.iter().filter_map(|e| *e));
106        };
107
108        push_words([should_push_word, should_push_delimiter]);
109
110        last_char_was_whitespace = current_char_is_whitespace;
111        last_char_idx = ch_idx;
112    }
113
114    // Push the last word
115    if current_word_start != last_char_idx + 1 {
116        words.push(Word {
117            start: current_word_start,
118            end: normalized_chars.len(),
119            word_type: WordType::Word,
120        });
121    }
122
123    // If the last item is a `Return`, remove it
124    if let Some(Word {
125        word_type: WordType::Return,
126        ..
127    }) = words.last()
128    {
129        words.pop();
130    }
131
132    Words {
133        items: words.into(),
134        internal_str: normalized_string.into(),
135        internal_chars: normalized_chars.iter().map(|c| *c as u32).collect(),
136    }
137}
138
139/// Takes a text broken into semantic items and shape all the words
140/// (does NOT scale the words, only shapes them)
141pub fn shape_words(words: &Words, font: &ParsedFont) -> ShapedWords {
142    let (script, lang) = super::shaping::estimate_script_and_language(&words.internal_str);
143
144    // Get the dimensions of the space glyph
145    let space_advance = font
146        .get_space_width()
147        .unwrap_or(font.font_metrics.units_per_em as usize);
148
149    let mut longest_word_width = 0_usize;
150
151    // NOTE: This takes the longest part of the entire layout process -- NEED TO PARALLELIZE
152    let shaped_words = words
153        .items
154        .iter()
155        .filter(|w| w.word_type == WordType::Word)
156        .map(|word| {
157            use super::shaping::ShapedTextBufferUnsized;
158
159            let chars = &words.internal_chars.as_ref()[word.start..word.end];
160            let shaped_word = font.shape(chars, script, lang);
161            let word_width = shaped_word.get_word_visual_width_unscaled();
162
163            longest_word_width = longest_word_width.max(word_width);
164
165            let ShapedTextBufferUnsized { infos } = shaped_word;
166
167            ShapedWord {
168                glyph_infos: infos.into(),
169                word_width,
170            }
171        })
172        .collect();
173
174    ShapedWords {
175        items: shaped_words,
176        longest_word_width,
177        space_advance,
178        font_metrics_units_per_em: font.font_metrics.units_per_em,
179        font_metrics_ascender: font.font_metrics.get_ascender_unscaled(),
180        font_metrics_descender: font.font_metrics.get_descender_unscaled(),
181        font_metrics_line_gap: font.font_metrics.get_line_gap_unscaled(),
182    }
183}
184
185/// Positions the words on the screen (does not layout any glyph positions!), necessary for
186/// estimating the intrinsic width + height of the text content.
187pub fn position_words(
188    words: &Words,
189    shaped_words: &ShapedWords,
190    text_layout_options: &ResolvedTextLayoutOptions,
191) -> WordPositions {
192    use core::f32;
193
194    use azul_core::{app_resources::WordPosition, ui_solver::InlineTextLine};
195
196    use self::{LineCaretIntersection::*, WordType::*};
197
198    let font_size_px = text_layout_options.font_size_px;
199    let space_advance_px = shaped_words.get_space_advance_px(text_layout_options.font_size_px);
200    let word_spacing_px = space_advance_px
201        * text_layout_options
202            .word_spacing
203            .as_ref()
204            .copied()
205            .unwrap_or(DEFAULT_WORD_SPACING);
206    let line_height_px = space_advance_px
207        * text_layout_options
208            .line_height
209            .as_ref()
210            .copied()
211            .unwrap_or(DEFAULT_LINE_HEIGHT);
212    let tab_width_px = space_advance_px
213        * text_layout_options
214            .tab_width
215            .as_ref()
216            .copied()
217            .unwrap_or(DEFAULT_TAB_WIDTH);
218    let spacing_multiplier = text_layout_options
219        .letter_spacing
220        .as_ref()
221        .copied()
222        .unwrap_or(0.0);
223
224    let mut line_breaks = Vec::new();
225    let mut word_positions = Vec::new();
226    let mut line_caret_x = text_layout_options.leading.as_ref().copied().unwrap_or(0.0);
227    let mut line_caret_y = font_size_px + line_height_px;
228    let mut shaped_word_idx = 0;
229    let mut last_shaped_word_word_idx = 0;
230    let mut last_line_start_idx = 0;
231
232    let last_word_idx = words.items.len().saturating_sub(1);
233
234    // The last word is a bit special: Any text must have at least one line break!
235    for (word_idx, word) in words.items.iter().enumerate() {
236        match word.word_type {
237            Word => {
238                // shaped words only contains the actual shaped words, not spaces / tabs / return
239                // chars
240                let shaped_word = match shaped_words.items.get(shaped_word_idx) {
241                    Some(s) => s,
242                    None => continue,
243                };
244
245                let letter_spacing_px =
246                    spacing_multiplier * shaped_word.number_of_glyphs().saturating_sub(1) as f32;
247
248                // Calculate where the caret would be for the next word
249                let shaped_word_width = shaped_word.get_word_width(
250                    shaped_words.font_metrics_units_per_em,
251                    text_layout_options.font_size_px,
252                ) + letter_spacing_px;
253
254                // Determine if a line break is necessary
255                let caret_intersection = LineCaretIntersection::new(
256                    line_caret_x,
257                    shaped_word_width,
258                    line_caret_y,
259                    font_size_px + line_height_px,
260                    text_layout_options.max_horizontal_width.as_ref().copied(),
261                );
262
263                // Correct and advance the line caret position
264                match caret_intersection {
265                    NoLineBreak { new_x, new_y } => {
266                        word_positions.push(WordPosition {
267                            shaped_word_index: Some(shaped_word_idx),
268                            position: LogicalPosition::new(line_caret_x, line_caret_y),
269                            size: LogicalSize::new(
270                                shaped_word_width,
271                                font_size_px + line_height_px,
272                            ),
273                        });
274                        line_caret_x = new_x;
275                        line_caret_y = new_y;
276                    }
277                    LineBreak { new_x, new_y } => {
278                        // push the line break first
279                        line_breaks.push(InlineTextLine {
280                            word_start: last_line_start_idx,
281                            word_end: word_idx.saturating_sub(1).max(last_line_start_idx),
282                            bounds: LogicalRect::new(
283                                LogicalPosition::new(0.0, line_caret_y),
284                                LogicalSize::new(line_caret_x, font_size_px + line_height_px),
285                            ),
286                        });
287                        last_line_start_idx = word_idx;
288
289                        word_positions.push(WordPosition {
290                            shaped_word_index: Some(shaped_word_idx),
291                            position: LogicalPosition::new(new_x, new_y),
292                            size: LogicalSize::new(
293                                shaped_word_width,
294                                font_size_px + line_height_px,
295                            ),
296                        });
297                        line_caret_x = new_x + shaped_word_width; // add word width for the next word
298                        line_caret_y = new_y;
299                    }
300                }
301
302                shaped_word_idx += 1;
303                last_shaped_word_word_idx = word_idx;
304            }
305            Return => {
306                if word_idx != last_word_idx {
307                    line_breaks.push(InlineTextLine {
308                        word_start: last_line_start_idx,
309                        word_end: word_idx.saturating_sub(1).max(last_line_start_idx),
310                        bounds: LogicalRect::new(
311                            LogicalPosition::new(0.0, line_caret_y),
312                            LogicalSize::new(line_caret_x, font_size_px + line_height_px),
313                        ),
314                    });
315                    // don't include the return char in the next line again
316                    last_line_start_idx = word_idx + 1;
317                }
318                word_positions.push(WordPosition {
319                    shaped_word_index: None,
320                    position: LogicalPosition::new(line_caret_x, line_caret_y),
321                    size: LogicalSize::new(0.0, font_size_px + line_height_px),
322                });
323                if word_idx != last_word_idx {
324                    line_caret_x = 0.0;
325                    line_caret_y = line_caret_y + font_size_px + line_height_px;
326                }
327            }
328            Space | Tab => {
329                let x_advance = match word.word_type {
330                    Space => word_spacing_px,
331                    Tab => tab_width_px,
332                    _ => word_spacing_px, // unreachable
333                };
334
335                let caret_intersection = LineCaretIntersection::new(
336                    line_caret_x,
337                    x_advance, // advance by space / tab width
338                    line_caret_y,
339                    font_size_px + line_height_px,
340                    text_layout_options.max_horizontal_width.as_ref().copied(),
341                );
342
343                match caret_intersection {
344                    NoLineBreak { new_x, new_y } => {
345                        word_positions.push(WordPosition {
346                            shaped_word_index: None,
347                            position: LogicalPosition::new(line_caret_x, line_caret_y),
348                            size: LogicalSize::new(x_advance, font_size_px + line_height_px),
349                        });
350                        line_caret_x = new_x;
351                        line_caret_y = new_y;
352                    }
353                    LineBreak { new_x, new_y } => {
354                        // push the line break before increasing
355                        if word_idx != last_word_idx {
356                            line_breaks.push(InlineTextLine {
357                                word_start: last_line_start_idx,
358                                word_end: word_idx.saturating_sub(1).max(last_line_start_idx),
359                                bounds: LogicalRect::new(
360                                    LogicalPosition::new(0.0, line_caret_y),
361                                    LogicalSize::new(line_caret_x, font_size_px + line_height_px),
362                                ),
363                            });
364                            last_line_start_idx = word_idx;
365                        }
366                        word_positions.push(WordPosition {
367                            shaped_word_index: None,
368                            position: LogicalPosition::new(line_caret_x, line_caret_y),
369                            size: LogicalSize::new(x_advance, font_size_px + line_height_px),
370                        });
371                        if word_idx != last_word_idx {
372                            line_caret_x = new_x; // don't add the space width here when pushing onto new line
373                            line_caret_y = new_y;
374                        }
375                    }
376                }
377            }
378        }
379    }
380
381    line_breaks.push(InlineTextLine {
382        word_start: last_line_start_idx,
383        word_end: last_shaped_word_word_idx,
384        bounds: LogicalRect::new(
385            LogicalPosition::new(0.0, line_caret_y),
386            LogicalSize::new(line_caret_x, font_size_px + line_height_px),
387        ),
388    });
389
390    let longest_line_width = line_breaks
391        .iter()
392        .map(|line| line.bounds.size.width)
393        .fold(0.0_f32, f32::max);
394
395    let content_size_y = line_breaks.len() as f32 * (font_size_px + line_height_px);
396    let content_size_x = text_layout_options
397        .max_horizontal_width
398        .as_ref()
399        .copied()
400        .unwrap_or(longest_line_width);
401    let content_size = LogicalSize::new(content_size_x, content_size_y);
402
403    WordPositions {
404        text_layout_options: text_layout_options.clone(),
405        trailing: line_caret_x,
406        number_of_shaped_words: shaped_word_idx,
407        number_of_lines: line_breaks.len(),
408        content_size,
409        word_positions,
410        line_breaks,
411    }
412}
413
414/// Returns the (left-aligned!) bounding boxes of the indidividual text lines
415pub fn word_positions_to_inline_text_layout(word_positions: &WordPositions) -> InlineTextLayout {
416    InlineTextLayout {
417        lines: word_positions.line_breaks.clone().into(),
418        content_size: word_positions.content_size,
419    }
420}
421
422#[derive(Debug, Copy, Clone, PartialOrd, PartialEq)]
423enum LineCaretIntersection {
424    /// In order to not intersect with any holes, the caret needs to
425    /// be advanced to the position x, but can stay on the same line.
426    NoLineBreak { new_x: f32, new_y: f32 },
427    /// Caret needs to advance X number of lines and be positioned
428    /// with a leading of x
429    LineBreak { new_x: f32, new_y: f32 },
430}
431
432impl LineCaretIntersection {
433    #[inline]
434    fn new(
435        current_x: f32,
436        word_width: f32,
437        current_y: f32,
438        line_height: f32,
439        max_width: Option<f32>,
440    ) -> Self {
441        match max_width {
442            None => LineCaretIntersection::NoLineBreak {
443                new_x: current_x + word_width,
444                new_y: current_y,
445            },
446            Some(max) => {
447                // window smaller than minimum word content: don't break line
448                if current_x == 0.0 && max < word_width {
449                    LineCaretIntersection::NoLineBreak {
450                        new_x: current_x + word_width,
451                        new_y: current_y,
452                    }
453                } else if (current_x + word_width) > max {
454                    LineCaretIntersection::LineBreak {
455                        new_x: 0.0,
456                        new_y: current_y + line_height,
457                    }
458                } else {
459                    LineCaretIntersection::NoLineBreak {
460                        new_x: current_x + word_width,
461                        new_y: current_y,
462                    }
463                }
464            }
465        }
466    }
467}
468
469pub fn shape_text(font: &FontRef, text: &str, options: &ResolvedTextLayoutOptions) -> InlineText {
470    let font_data = font.get_data();
471    let parsed_font_downcasted = unsafe { &*(font_data.parsed as *const ParsedFont) };
472
473    let words = split_text_into_words(text);
474    let shaped_words = shape_words(&words, parsed_font_downcasted);
475    let word_positions = position_words(&words, &shaped_words, options);
476    let inline_text_layout = word_positions_to_inline_text_layout(&word_positions);
477
478    azul_core::app_resources::get_inline_text(
479        &words,
480        &shaped_words,
481        &word_positions,
482        &inline_text_layout,
483    )
484}