use anyhow::Result; use rand::random; use super::*; use crate::fonts::{ collect_glyph_indices, collect_line_caret_position_starts, init_fonts, Properties, }; use crate::platform::FontDB as _; use crate::text_layout::DEFAULT_TOP_BOTTOM_RATIO; pub(crate) fn collect_line_caret_position_pairs(line: &Line) -> Vec<(usize, usize)> { line.caret_positions .iter() .map(|pos| (pos.start_offset, pos.last_offset)) .collect_vec() } #[test] fn test_char_indices_ligatures() -> Result<()> { let mut font_db = FontDB::new(); let zapfino = font_db.load_from_system("Zapfino")?; let menlo = font_db.load_from_system("Menlo")?; let text = "This is, m𐍈re 𐍈r less, Zapfino!𐍈"; let line = layout_line( text, LineStyle { font_size: 16.0, line_height_ratio: 1.2, baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO, fixed_width_tab_size: None, }, &[ ( 0..9, StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()), ), ( 9..22, StyleAndFont::new(menlo, Properties::default(), TextStyle::new()), ), ( 22..text.encode_utf16().count(), StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()), ), ], &font_db, ClipConfig::default(), ); // It's easiest to understand what's happening here by visualizing the text and seeing which // characters get combined to become a single glyph. At a high level, what this is testing // is that after laying out the string, we see some characters get combined into a single // glyph. For example, the text "Zapfino" gets combined into a single glyph, which is why // there is a jump from 23 to 30 in the list of glyph indices below. // See https://docs.google.com/drawings/d/18qOKhzA5rWaMuxKVeWFDXh7ebrDjxongarAckkm0qnE/edit // for a full diagram of what's happening here. assert_eq!( line.runs .iter() .flat_map(|r| r.glyphs.iter()) .map(|g| g.index) .collect::>(), vec![0, 2, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 30, 31] ); Ok(()) } #[test] fn test_caret_positions_ligatures() -> Result<()> { // There's some overlap between caret positions and the character indices we // store in glyphs. However, a single glyph may have multiple caret positions // because characters/graphemes may get combined into a single glyph. let mut font_db = FontDB::new(); let zapfino = font_db.load_from_system("Zapfino")?; let menlo = font_db.load_from_system("Menlo")?; // This string has 32 characters, but 35 UTF-16 code points and 41 UTF-8 code points. // Each '𐍈' character encodes as 2 UTF-16 code points or 4 UTF-8 code points. let text = "This is, m𐍈re 𐍈r less, Zapfino!𐍈"; let line = layout_line( text, LineStyle { font_size: 16.0, line_height_ratio: 1.2, baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO, fixed_width_tab_size: None, }, &[ ( 0..9, StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()), ), ( 9..22, StyleAndFont::new(menlo, Properties::default(), TextStyle::new()), ), ( 22..35, StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()), ), ], &font_db, ClipConfig::default(), ); // There are only 23 glyphs because 'Zapfino', 'Th', and 'is' each have ligatures. assert_eq!( line.runs.iter().map(|run| run.glyphs.len()).sum::(), 23 ); // There should be a caret position for each character. assert_eq!( line.caret_positions .iter() .map(|pos| pos.start_offset) .collect::>(), (0..32).collect::>() ); // There is a caret for the 3rd character at the 3rd position, even though // the first 2 characters are represented with 1 glyph. assert_eq!( line.caret_position_for_index(3), line.caret_positions[3].position_in_line ); // Likewise for the second 𐍈, even though it (and the previous one) take // multiple code points. assert_eq!( line.caret_position_for_index(15), line.caret_positions[15].position_in_line ); // This tests hit-testing on a regular character. assert_eq!(line.caret_index_for_x(0.), Some(0)); assert_eq!(line.caret_index_for_x(20.), Some(1)); // This tests hit-testing within a ligature. assert_eq!(line.caret_index_for_x(260.), Some(25)); // This tests rounding up to the next character. assert_eq!(line.caret_index_for_x(268.), Some(26)); // This tests a few random positions within the bound and before the last character. let last_caret_pos = line .caret_positions .last() .map_or(0., |p| p.position_in_line); // The bounded and unbounded method should return the same result. for _ in 0..5 { let pos: f32 = random(); let index = line.caret_index_for_x(pos * last_caret_pos); assert_eq!( index, Some(line.caret_index_for_x_unbounded(pos * last_caret_pos)) ); } // This tests that the unbounded method returns the first index for out-of-bound position to the left assert_eq!(line.caret_index_for_x_unbounded(-1.), line.first_index()); // The bounded method should return `None` assert_eq!(line.caret_index_for_x(-1.), None); // This tests that the unbounded method returns the end index for out-of-bound position to the right assert_eq!( line.caret_index_for_x_unbounded(line.width + 0.1), line.end_index() ); assert_eq!(line.caret_index_for_x(line.width + 0.1), None); // This tests that the unbounded method returns the correct index either before or after the last glyph assert_eq!( line.caret_index_for_x_unbounded(0.9 * last_caret_pos + 0.1 * line.width), line.last_index() ); assert_eq!( line.caret_index_for_x_unbounded(0.1 * last_caret_pos + 0.9 * line.width), line.end_index() ); // The bounded method should always just return the last index assert_eq!( line.caret_index_for_x(0.9 * last_caret_pos + 0.1 * line.width), Some(line.last_index()) ); assert_eq!( line.caret_index_for_x(0.1 * last_caret_pos + 0.9 * line.width), Some(line.last_index()) ); Ok(()) } /// The emojis in this test use font fallback, which means it won't behave /// consistently across platforms. #[test] fn test_emoji_caret_positions() -> Result<()> { let (font_db, font_family) = init_fonts(); // We're using these emoji specifically because they're represented as multiple // combined characters. let text = "πŸ‘¨β€πŸ‘§β€πŸ‘§πŸ‡¨πŸ‡¦"; let line = font_db.text_layout_system().layout_line( text, LineStyle { font_size: 16.0, line_height_ratio: 1.2, baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO, fixed_width_tab_size: None, }, &[( 0..12, StyleAndFont::new(font_family, Properties::default(), TextStyle::new()), )], 10000.0, ClipConfig::default(), ); // We want the leading edge for caret positions, so the first one is at the // start of the line. assert_eq!(line.caret_positions[0].position_in_line, 0.0); assert_eq!( collect_line_caret_position_starts(&line), // CoreText gives us one caret position per visible character. // Each emoji is multiple characters, but one grapheme and therefore one // caret position. vec![0, 5] ); // The first character is within the first emoji, so its caret position is // at the start of the line. assert_eq!(line.caret_position_for_index(0), 0.0); // Likewise, the start of the next emoji returns its start position. assert_eq!( line.caret_position_for_index(5), line.caret_positions[1].position_in_line ); // Subsequent positions within the emoji also resolve to its starting offset. assert_eq!( line.caret_position_for_index(6), line.caret_positions[1].position_in_line ); // Past the end of the last emoji, we clamp to the end of the line. assert_eq!(line.caret_position_for_index(7), line.width); Ok(()) } /// The RTL text and emoji in this test use font fallback, which means /// this test won't behave consistently across platforms. #[test] fn test_bidi_caret_positions() -> Result<()> { let (font_db, font_family) = init_fonts(); let text = "a Χ©ΦΈΧΧœΧ•ΦΉΧ πŸ‡¨πŸ‡¦ test"; let line = font_db.text_layout_system().layout_line( text, LineStyle { font_size: 16.0, line_height_ratio: 1.2, baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO, fixed_width_tab_size: None, }, &[( 0..text.encode_utf16().count(), StyleAndFont::new(font_family, Properties::default(), TextStyle::new()), )], 10000.0, ClipConfig::default(), ); // Caret positions should account for diacritics in the Hebrew text, as well // as the πŸ‡¨πŸ‡¦ emoji consisting of multiple characters. In addition, they should // be sorted by display order. assert_eq!( collect_line_caret_position_pairs(&line), vec![ // "a " (0, 0), (1, 1), // Χ©ΦΈΧΧœΧ•ΦΉΧ (8, 8), (6, 7), (5, 5), (2, 4), // " " (9, 9), // πŸ‡¨πŸ‡¦ (10, 11), // " test" (12, 12), (13, 13), (14, 14), (15, 15), (16, 16) ] ); Ok(()) } #[test] fn test_layout_text_ligatures() -> Result<()> { let mut font_db = FontDB::new(); let zapfino = font_db.load_from_system("Zapfino")?; let menlo = font_db.load_from_system("Menlo")?; let text = "This is, m𐍈re 𐍈r less, Zapfino!𐍈"; let frame = layout_text( text, LineStyle { font_size: 16.0, line_height_ratio: 1.2, baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO, fixed_width_tab_size: None, }, &[ ( 0..9, StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()), ), ( 9..22, StyleAndFont::new(menlo, Properties::default(), TextStyle::new()), ), ( 22..text.encode_utf16().count(), StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()), ), ], &font_db, 125., /* max_width */ f32::MAX, /* max_height */ Default::default(), None, ); // The text should contain multiple lines since it can't fit in 125 pixels on the first // line. assert_eq!(frame.lines().len(), 4); // The text should be wrapped over 4 lines and look like this: // "This is // m𐍈re or // less, // Zapfino!𐍈" assert_eq!( collect_glyph_indices(&frame), vec![ vec![0, 2, 4, 5, 7, 8], vec![9, 10, 11, 12, 13, 14, 15, 16], vec![17, 18, 19, 20, 21, 22,], vec![23, 30, 31] ] ); Ok(()) } #[test] fn test_layout_text_first_line_head_indent_ligatures() -> Result<()> { // Similar test to above, except we add in a left head indent (with reduced max width)! let mut font_db = FontDB::new(); let zapfino = font_db.load_from_system("Zapfino")?; let menlo = font_db.load_from_system("Menlo")?; let text = "This is, m𐍈re 𐍈r less, Zapfino!𐍈"; let frame = layout_text( text, LineStyle { font_size: 16.0, line_height_ratio: 1.2, baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO, fixed_width_tab_size: None, }, &[ ( 0..9, StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()), ), ( 9..22, StyleAndFont::new(menlo, Properties::default(), TextStyle::new()), ), ( 22..text.encode_utf16().count(), StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()), ), ], &font_db, 80., /* max_width */ f32::MAX, /* max_height */ Default::default(), Some(50.), /* first_line_head_indent */ ); // The text should contain multiple lines since we have a 50px left head indent on the first // line and then each line only has 80px. assert_eq!(frame.lines().len(), 6); assert_eq!( collect_glyph_indices(&frame), vec![ vec![0], // left head indent means we don't have much content laid out on this line. vec![1, 2, 4, 5, 7, 8], vec![9, 10, 11, 12, 13, 14, 15, 16], vec![17, 18, 19, 20, 21, 22], vec![23, 24, 25, 27, 28], vec![29, 30, 31], ] ); Ok(()) } #[test] fn test_tab_stops_affect_line_width() -> Result<()> { let mut font_db = FontDB::new(); let menlo = font_db.load_from_system("Menlo")?; let font_size = 13.0; let tab_size = 4; let font_id = font_db.select_font(menlo, Properties::default()); let tab_interval = (font_db .space_advance_width(font_id, font_size) .expect("space width should be measurable") * tab_size as f64) as f32; let style = StyleAndFont::new(menlo, Properties::default(), TextStyle::new()); let strings = "strings"; let tabbed = "\t\t\tstrings"; let strings_line = layout_line( strings, LineStyle { font_size, line_height_ratio: 1.2, baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO, fixed_width_tab_size: None, }, &[(0..strings.chars().count(), style)], &font_db, ClipConfig::default(), ); let tabbed_line = layout_line( tabbed, LineStyle { font_size, line_height_ratio: 1.2, baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO, fixed_width_tab_size: Some(tab_size), }, &[(0..tabbed.chars().count(), style)], &font_db, ClipConfig::default(), ); // Each tab advances to the next stop. let expected_width = (tab_interval * 3.0) + strings_line.width; let error = (tabbed_line.width - expected_width).abs(); assert!( error < 1.0, "expected tabbed width ~{}, got {} (error {})", expected_width, tabbed_line.width, error ); Ok(()) } #[test] fn test_tab_stops_do_not_drift_over_long_runs() -> Result<()> { let mut font_db = FontDB::new(); let menlo = font_db.load_from_system("Menlo")?; let font_size = 13.0; let tab_size = 4; let font_id = font_db.select_font(menlo, Properties::default()); let tab_interval = (font_db .space_advance_width(font_id, font_size) .expect("space width should be measurable") * tab_size as f64) as f32; let style = StyleAndFont::new(menlo, Properties::default(), TextStyle::new()); let strings = "strings"; let strings_line = layout_line( strings, LineStyle { font_size, line_height_ratio: 1.2, baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO, fixed_width_tab_size: None, }, &[(0..strings.chars().count(), style)], &font_db, ClipConfig::default(), ); let tab_count = 100; let tabbed = format!("{}{}", "\t".repeat(tab_count), strings); let tabbed_line = layout_line( &tabbed, LineStyle { font_size, line_height_ratio: 1.2, baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO, fixed_width_tab_size: Some(tab_size), }, &[(0..tabbed.chars().count(), style)], &font_db, ClipConfig::default(), ); let expected_width = (tab_interval * tab_count as f32) + strings_line.width; let error = (tabbed_line.width - expected_width).abs(); assert!( error < 1.0, "expected tabbed width ~{}, got {} (error {})", expected_width, tabbed_line.width, error ); Ok(()) } #[test] fn test_layout_text_large_first_line_head_indent_ligatures() -> Result<()> { // Similar test to above, except we have a large first line head indent which goes beyond the // max_width of the first line! We expect an empty line at the start to account for this (post-layout). let mut font_db = FontDB::new(); let zapfino = font_db.load_from_system("Zapfino")?; let menlo = font_db.load_from_system("Menlo")?; let text = "This is, some text, in Zapfino being laid out!"; let frame = layout_text( text, LineStyle { font_size: 16.0, line_height_ratio: 1.2, baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO, fixed_width_tab_size: None, }, &[ ( 0..9, StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()), ), ( 9..22, StyleAndFont::new(menlo, Properties::default(), TextStyle::new()), ), ( 22..text.encode_utf16().count(), StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()), ), ], &font_db, 80., /* max_width */ f32::MAX, /* max_height */ Default::default(), Some(80.), /* first_line_head_indent */ ); // We expect 1 empty line at the start and then 7 lines of content. assert_eq!(frame.lines().len(), 8); assert_eq!( collect_glyph_indices(&frame), vec![ vec![], // first line head indent takes up entire line! vec![0, 2, 4], vec![5, 7, 8, 9, 10, 11, 12, 13], vec![14, 15, 16, 17, 18, 19, 20, 21, 22], vec![23, 24, 25, 27, 28], vec![29, 30, 31, 32, 33, 34, 35, 36], vec![37, 38, 39, 40, 41], vec![42, 44, 45], ] ); Ok(()) } #[test] fn test_layout_text_last_line_clipped_ligatures() -> Result<()> { let mut font_db = FontDB::new(); let zapfino = font_db.load_from_system("Zapfino")?; let menlo = font_db.load_from_system("Menlo")?; let text = "m𐍈re, Zapfino!ll𐍈, qqqq"; let max_width = 180.; let frame = layout_text( text, LineStyle { font_size: 16.0, line_height_ratio: 1.2, baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO, fixed_width_tab_size: None, }, &[ ( 0..5, StyleAndFont::new(menlo, Properties::default(), TextStyle::new()), ), ( 5..13, StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()), ), ( 13..16, StyleAndFont::new(menlo, Properties::default(), TextStyle::new()), ), ( 16..text.encode_utf16().count(), StyleAndFont::new(menlo, Properties::default(), TextStyle::new()), ), ], &font_db, max_width, 70., /* max_height */ Default::default(), None, ); // The text should only fit one line. assert_eq!(frame.lines().len(), 1); // The text is one line long and should be clipped like so: "m𐍈re, Zapfin𐍈!l...". // Note that the contents are not clipped, but the width being greater than the max width // indicates that when we paint, this is clipped. assert_eq!( collect_glyph_indices(&frame), vec![[0, 1, 2, 3, 4, 5, 6, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22],] ); let first_line = frame.lines().first().unwrap(); assert!(first_line.width > max_width); Ok(()) }