| 1 | use onestore::page::text::Paragraph; |
| 2 | use parley::{ |
| 3 | Affinity, Alignment, AlignmentOptions, BoundingBox, FontContext, FontFamily, FontFamilyName, |
| 4 | FontStyle, FontWeight, GenericFamily, Layout, LayoutContext, OverflowWrap, |
| 5 | PositionedLayoutItem, StyleProperty, |
| 6 | editing::{Cursor, Selection}, |
| 7 | fontique::{Blob, FontInfo, SourceCache}, |
| 8 | }; |
| 9 | use skrifa::{FontRef, MetadataProvider, raw::TableProvider, string::StringId}; |
| 10 | use std::{ |
| 11 | collections::{BTreeMap, BTreeSet}, |
| 12 | fmt, |
| 13 | ops::Range, |
| 14 | sync::{ |
| 15 | Arc, OnceLock, |
| 16 | atomic::{AtomicU64, Ordering}, |
| 17 | }, |
| 18 | }; |
| 19 | |
| 20 | pub struct TextEngine { |
| 21 | pub fonts: FontContext, |
| 22 | context: LayoutContext<TextBrush>, |
| 23 | arial_substitutes: BTreeSet<u64>, |
| 24 | /// The substitute each family lays out in, by family. |
| 25 | substitutes: BTreeMap<&'static str, Substitute>, |
| 26 | } |
| 27 | |
| 28 | /// A font laid out in place of a family. |
| 29 | #[derive(Clone)] |
| 30 | pub struct Substitute { |
| 31 | pub name: &'static str, |
| 32 | /// Its faces as registered for the family. |
| 33 | pub faces: Vec<(Blob<u8>, FontInfo)>, |
| 34 | bundled: bool, |
| 35 | } |
| 36 | |
| 37 | /// Superscripts and subscripts draw at this fraction of their run's size. |
| 38 | const SCRIPT_SCALE: f32 = 2.0 / 3.0; |
| 39 | const SUPERSCRIPT_RISE: f32 = 1.0 / 3.0; |
| 40 | const SUBSCRIPT_DROP: f32 = 0.08; |
| 41 | |
| 42 | #[derive(Clone, Copy, Debug, Default, PartialEq)] |
| 43 | pub struct TextBrush { |
| 44 | /// COLORREF, or `None` for OneNote's automatic colour, which follows the paper. |
| 45 | pub(crate) color: Option<u32>, |
| 46 | pub(crate) highlight: Option<u32>, |
| 47 | /// Points the run draws above the line's baseline: positive for a superscript, |
| 48 | /// negative for a subscript. Line metrics and hit-testing keep the baseline. |
| 49 | pub(crate) rise: f32, |
| 50 | } |
| 51 | |
| 52 | #[derive(Clone, Debug)] |
| 53 | pub struct LineBox { |
| 54 | pub source: Range<usize>, |
| 55 | pub top: f32, |
| 56 | pub baseline: f32, |
| 57 | pub height: f32, |
| 58 | } |
| 59 | |
| 60 | #[derive(Clone)] |
| 61 | pub struct TextLayout { |
| 62 | id: u64, |
| 63 | pub(crate) shaped: Arc<Layout<TextBrush>>, |
| 64 | /// The paragraph text laid out, which a PDF's text layer maps glyphs back to. |
| 65 | #[cfg(feature = "gpu")] |
| 66 | pub(crate) text: Arc<str>, |
| 67 | lines: Vec<LineBox>, |
| 68 | /// Where each inline space lies: its index in the spaces laid out, its x, and its line. |
| 69 | spaces: Vec<(usize, f32, usize)>, |
| 70 | } |
| 71 | |
| 72 | /// Room kept in a line for something drawn inline, such as an equation, before the text at |
| 73 | /// byte `index`; the line grows to its ascent and descent. |
| 74 | #[derive(Clone, Copy, Debug, PartialEq)] |
| 75 | pub(crate) struct InlineSpace { |
| 76 | pub(crate) index: usize, |
| 77 | pub(crate) width: f32, |
| 78 | pub(crate) ascent: f32, |
| 79 | pub(crate) descent: f32, |
| 80 | } |
| 81 | |
| 82 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 83 | pub enum LayoutError { |
| 84 | InvalidWidth, |
| 85 | InvalidFontSize, |
| 86 | InvalidSourceRange, |
| 87 | InvalidFontMetrics, |
| 88 | InvalidIndentation, |
| 89 | InvalidSpacing, |
| 90 | InvalidList, |
| 91 | UnsupportedContent, |
| 92 | UnsupportedSubstituteFont, |
| 93 | } |
| 94 | |
| 95 | impl fmt::Display for LayoutError { |
| 96 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 97 | match self { |
| 98 | Self::UnsupportedSubstituteFont => { |
| 99 | write!( |
| 100 | f, |
| 101 | "Choose an Arimo, Carlito, Caladea, Tinos or Cousine font file." |
| 102 | ) |
| 103 | } |
| 104 | _ => write!(f, "Text layout failed: {self:?}"), |
| 105 | } |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | impl std::error::Error for LayoutError {} |
| 110 | |
| 111 | /// What a run without a font or size of its own is laid out in. |
| 112 | pub(crate) const DEFAULT_FONT: &str = "Arial"; |
| 113 | pub(crate) const DEFAULT_FONT_SIZE: f32 = 11.0; |
| 114 | |
| 115 | /// Metric-compatible faces for the fonts OneNote pages use most, under the SIL Open Font |
| 116 | /// Licence files beside them, deflated, by the family each stands in for. The build script |
| 117 | /// leaves out each whose family the system ships, and every one from the browser's module, |
| 118 | /// which fetches them beside it instead (`register_substitute`). |
| 119 | const BUNDLED: &[(&str, &[&[u8]])] = include!(concat!(env!("OUT_DIR"), "/bundled.rs")); |
| 120 | |
| 121 | /// Each bundled family's faces, inflated the first time an engine lacks the family. |
| 122 | static INFLATED: [OnceLock<Vec<Blob<u8>>>; BUNDLED.len()] = |
| 123 | [const { OnceLock::new() }; BUNDLED.len()]; |
| 124 | |
| 125 | impl Default for TextEngine { |
| 126 | /// An engine that lays out each bundled family in its substitute where it is missing. |
| 127 | fn default() -> Self { |
| 128 | let mut engine = Self { |
| 129 | // Clones share loaded font files, so glyphs one lays out draw from the same cache. |
| 130 | fonts: FontContext { |
| 131 | source_cache: SourceCache::new_shared(), |
| 132 | ..FontContext::default() |
| 133 | }, |
| 134 | context: LayoutContext::default(), |
| 135 | arial_substitutes: BTreeSet::new(), |
| 136 | substitutes: BTreeMap::new(), |
| 137 | }; |
| 138 | for (&(family, faces), inflated) in BUNDLED.iter().zip(&INFLATED) { |
| 139 | if engine.fonts.collection.family_id(family).is_some() { |
| 140 | continue; |
| 141 | } |
| 142 | let faces = inflated.get_or_init(|| { |
| 143 | faces |
| 144 | .iter() |
| 145 | .map(|face| { |
| 146 | let face = miniz_oxide::inflate::decompress_to_vec(face); |
| 147 | Blob::new(Arc::new(face.expect("bundled substitutes inflate"))) |
| 148 | }) |
| 149 | .collect() |
| 150 | }); |
| 151 | for face in faces { |
| 152 | engine |
| 153 | .register(face.clone(), true) |
| 154 | .expect("bundled substitutes register"); |
| 155 | } |
| 156 | } |
| 157 | engine |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | /// Another engine with the same fonts and substitutes, to lay out on another thread. |
| 162 | impl Clone for TextEngine { |
| 163 | fn clone(&self) -> Self { |
| 164 | Self { |
| 165 | fonts: self.fonts.clone(), |
| 166 | context: LayoutContext::default(), |
| 167 | arial_substitutes: self.arial_substitutes.clone(), |
| 168 | substitutes: self.substitutes.clone(), |
| 169 | } |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | impl TextEngine { |
| 174 | /// Register Arimo as Arial, Carlito as Calibri, Caladea as Cambria, Tinos as Times New |
| 175 | /// Roman or Cousine as Courier New before creating layouts, in place of the bundled |
| 176 | /// substitute. |
| 177 | pub fn register_substitute(&mut self, data: Blob<u8>) -> Result<&'static str, LayoutError> { |
| 178 | self.register(data, false) |
| 179 | } |
| 180 | |
| 181 | fn register(&mut self, data: Blob<u8>, bundled: bool) -> Result<&'static str, LayoutError> { |
| 182 | let font = |
| 183 | FontRef::new(data.as_ref()).map_err(|_| LayoutError::UnsupportedSubstituteFont)?; |
| 184 | let family = font |
| 185 | .localized_strings(StringId::TYPOGRAPHIC_FAMILY_NAME) |
| 186 | .english_or_first() |
| 187 | .or_else(|| { |
| 188 | font.localized_strings(StringId::FAMILY_NAME) |
| 189 | .english_or_first() |
| 190 | }) |
| 191 | .map(|name| name.to_string()) |
| 192 | .ok_or(LayoutError::UnsupportedSubstituteFont)?; |
| 193 | let (substitute, target) = match family.as_str() { |
| 194 | "Arimo" => ("Arimo", "Arial"), |
| 195 | "Carlito" => ("Carlito", "Calibri"), |
| 196 | "Caladea" => ("Caladea", "Cambria"), |
| 197 | "Tinos" => ("Tinos", "Times New Roman"), |
| 198 | "Cousine" => ("Cousine", "Courier New"), |
| 199 | _ => return Err(LayoutError::UnsupportedSubstituteFont), |
| 200 | }; |
| 201 | let id = data.id(); |
| 202 | if target == "Arial" { |
| 203 | let head = font.head().map_err(|_| LayoutError::InvalidFontMetrics)?; |
| 204 | let hhea = font.hhea().map_err(|_| LayoutError::InvalidFontMetrics)?; |
| 205 | if head.units_per_em() == 0 |
| 206 | || hhea.ascender().to_i16() <= 0 |
| 207 | || hhea.descender().to_i16() > 0 |
| 208 | { |
| 209 | return Err(LayoutError::InvalidFontMetrics); |
| 210 | } |
| 211 | } |
| 212 | let collection = &mut self.fonts.collection; |
| 213 | // A substitute of the caller's replaces the bundled one outright. |
| 214 | if !bundled |
| 215 | && self |
| 216 | .substitutes |
| 217 | .get(target) |
| 218 | .is_some_and(|known| known.bundled) |
| 219 | && let Some(replaced) = self.substitutes.remove(target) |
| 220 | && let Some(family) = collection.family_id(target) |
| 221 | { |
| 222 | for (face, info) in replaced.faces { |
| 223 | collection.unregister_font(family, info.width(), info.style(), info.weight()); |
| 224 | self.arial_substitutes.remove(&face.id()); |
| 225 | } |
| 226 | } |
| 227 | let registered = collection.register_fonts( |
| 228 | data.clone(), |
| 229 | Some(parley::fontique::FontInfoOverride { |
| 230 | family_name: Some(target), |
| 231 | ..Default::default() |
| 232 | }), |
| 233 | ); |
| 234 | if registered.is_empty() { |
| 235 | return Err(LayoutError::UnsupportedSubstituteFont); |
| 236 | } |
| 237 | if target == "Arial" { |
| 238 | self.arial_substitutes.insert(id); |
| 239 | } |
| 240 | let entry = self.substitutes.entry(target).or_insert(Substitute { |
| 241 | name: substitute, |
| 242 | faces: Vec::new(), |
| 243 | bundled, |
| 244 | }); |
| 245 | entry.faces.extend( |
| 246 | registered |
| 247 | .into_iter() |
| 248 | .flat_map(|(_, infos)| infos) |
| 249 | .map(|info| (data.clone(), info)), |
| 250 | ); |
| 251 | Ok(target) |
| 252 | } |
| 253 | |
| 254 | /// The font `family` lays out in when a substitute stands in for it. |
| 255 | pub fn substitute(&self, family: &str) -> Option<&Substitute> { |
| 256 | self.substitutes.get(family) |
| 257 | } |
| 258 | |
| 259 | fn shape( |
| 260 | &mut self, |
| 261 | paragraph: &Paragraph, |
| 262 | width: f32, |
| 263 | spaces: &[InlineSpace], |
| 264 | ) -> Result<Layout<TextBrush>, LayoutError> { |
| 265 | if !width.is_finite() || width <= 0.0 { |
| 266 | return Err(LayoutError::InvalidWidth); |
| 267 | } |
| 268 | let text = paragraph.text(); |
| 269 | let mut builder = self |
| 270 | .context |
| 271 | .ranged_builder(&mut self.fonts, text, 1.0, false); |
| 272 | builder.push_default(StyleProperty::OverflowWrap(OverflowWrap::BreakWord)); |
| 273 | // OneNote 2010 uses unkerned advances for text and table widths. |
| 274 | builder.push_default(StyleProperty::FontFeatures(r#""kern" 0"#.into())); |
| 275 | let mut start = 0; |
| 276 | for (index, span) in paragraph.spans().iter().enumerate() { |
| 277 | let format = &span.format; |
| 278 | let link = format.hyperlink == Some(true); |
| 279 | let size = format.font_size.unwrap_or(DEFAULT_FONT_SIZE); |
| 280 | if !size.is_finite() || size <= 0.0 { |
| 281 | return Err(LayoutError::InvalidFontSize); |
| 282 | } |
| 283 | let rise = if format.superscript == Some(true) { |
| 284 | SUPERSCRIPT_RISE * size |
| 285 | } else if format.subscript == Some(true) { |
| 286 | -SUBSCRIPT_DROP * size |
| 287 | } else { |
| 288 | 0.0 |
| 289 | }; |
| 290 | let size = if rise == 0.0 { |
| 291 | size |
| 292 | } else { |
| 293 | size * SCRIPT_SCALE |
| 294 | }; |
| 295 | let properties = [ |
| 296 | StyleProperty::FontFamily(FontFamily::List( |
| 297 | [ |
| 298 | Some(FontFamilyName::named( |
| 299 | format.font.as_deref().unwrap_or(DEFAULT_FONT), |
| 300 | )), |
| 301 | // macOS ships STIX Two Math where Windows has Cambria Math. |
| 302 | (format.font.as_deref() == Some("Cambria Math")) |
| 303 | .then_some(FontFamilyName::named("STIX Two Math")), |
| 304 | // A missing family otherwise falls back per script and can put digits in an emoji font. |
| 305 | Some(GenericFamily::SansSerif.into()), |
| 306 | ] |
| 307 | .into_iter() |
| 308 | .flatten() |
| 309 | .collect::<Vec<_>>() |
| 310 | .into(), |
| 311 | )), |
| 312 | StyleProperty::FontSize(size), |
| 313 | StyleProperty::FontWeight(if format.bold == Some(true) { |
| 314 | FontWeight::BOLD |
| 315 | } else { |
| 316 | FontWeight::NORMAL |
| 317 | }), |
| 318 | StyleProperty::FontStyle(if format.italic == Some(true) { |
| 319 | FontStyle::Italic |
| 320 | } else { |
| 321 | FontStyle::Normal |
| 322 | }), |
| 323 | StyleProperty::Brush(TextBrush { |
| 324 | // Links take OneNote's blue unless given a colour of their own. |
| 325 | color: match format.color { |
| 326 | Some(color) if color != 0xff000000 => Some(color), |
| 327 | _ if link => Some(0x00ff0000), |
| 328 | _ => None, |
| 329 | }, |
| 330 | highlight: format.highlight.filter(|color| *color != 0xff000000), |
| 331 | rise, |
| 332 | }), |
| 333 | StyleProperty::Underline(format.underline.unwrap_or(false) || link), |
| 334 | StyleProperty::Strikethrough(format.strike.unwrap_or(false)), |
| 335 | ]; |
| 336 | for property in properties { |
| 337 | if index == 0 { |
| 338 | builder.push_default(property.clone()); |
| 339 | } |
| 340 | builder.push(property, start..span.end); |
| 341 | } |
| 342 | start = span.end; |
| 343 | } |
| 344 | for (id, space) in spaces.iter().enumerate() { |
| 345 | builder.push_inline_box(parley::InlineBox { |
| 346 | id: id as u64, |
| 347 | kind: parley::InlineBoxKind::InFlow, |
| 348 | index: space.index, |
| 349 | width: space.width, |
| 350 | height: space.ascent + space.descent, |
| 351 | baseline: Some(space.ascent), |
| 352 | }); |
| 353 | } |
| 354 | let mut shaped = builder.build(text); |
| 355 | shaped.break_all_lines(Some(width)); |
| 356 | let alignment = match paragraph.spans()[0].format.alignment { |
| 357 | Some(1) => Alignment::Center, |
| 358 | Some(2) => Alignment::End, |
| 359 | _ => Alignment::Start, |
| 360 | }; |
| 361 | shaped.align(alignment, AlignmentOptions::default()); |
| 362 | Ok(shaped) |
| 363 | } |
| 364 | |
| 365 | fn font_extents( |
| 366 | &self, |
| 367 | run: parley::layout::Run<'_, TextBrush>, |
| 368 | ) -> Result<(f32, f32), LayoutError> { |
| 369 | let data = &run.font().font; |
| 370 | let font = FontRef::from_index(data.data.as_ref(), data.index) |
| 371 | .map_err(|_| LayoutError::InvalidFontMetrics)?; |
| 372 | Ok(if self.arial_substitutes.contains(&data.data.id()) { |
| 373 | // Arimo's hhea extents match Arial's Windows extents; omit hhea line gap. |
| 374 | let head = font.head().map_err(|_| LayoutError::InvalidFontMetrics)?; |
| 375 | let hhea = font.hhea().map_err(|_| LayoutError::InvalidFontMetrics)?; |
| 376 | let scale = run.font_size() / f32::from(head.units_per_em()); |
| 377 | ( |
| 378 | f32::from(hhea.ascender().to_i16()) * scale, |
| 379 | -f32::from(hhea.descender().to_i16()) * scale, |
| 380 | ) |
| 381 | } else if let (Ok(head), Ok(os2)) = (font.head(), font.os2()) |
| 382 | && head.units_per_em() != 0 |
| 383 | && font |
| 384 | .table_data(skrifa::raw::types::Tag::new(b"MATH")) |
| 385 | .is_some() |
| 386 | { |
| 387 | // A math font's Windows extents reach its tallest operators; OneNote sets linear |
| 388 | // math in lines of text height. |
| 389 | let scale = run.font_size() / f32::from(head.units_per_em()); |
| 390 | ( |
| 391 | f32::from(os2.s_typo_ascender()) * scale, |
| 392 | -f32::from(os2.s_typo_descender()) * scale, |
| 393 | ) |
| 394 | } else if let (Ok(head), Ok(os2)) = (font.head(), font.os2()) |
| 395 | && head.units_per_em() != 0 |
| 396 | && (os2.us_win_ascent() != 0 || os2.us_win_descent() != 0) |
| 397 | { |
| 398 | let scale = run.font_size() / f32::from(head.units_per_em()); |
| 399 | ( |
| 400 | f32::from(os2.us_win_ascent()) * scale, |
| 401 | f32::from(os2.us_win_descent()) * scale, |
| 402 | ) |
| 403 | } else { |
| 404 | let metrics = run.font_metrics(); |
| 405 | (metrics.ascent, metrics.descent) |
| 406 | }) |
| 407 | } |
| 408 | |
| 409 | pub fn layout(&mut self, paragraph: &Paragraph, width: f32) -> Result<TextLayout, LayoutError> { |
| 410 | self.layout_with(paragraph, width, &[]) |
| 411 | } |
| 412 | |
| 413 | /// Lays out `paragraph` keeping `spaces` inline, for what draws in them. |
| 414 | pub(crate) fn layout_with( |
| 415 | &mut self, |
| 416 | paragraph: &Paragraph, |
| 417 | width: f32, |
| 418 | spaces: &[InlineSpace], |
| 419 | ) -> Result<TextLayout, LayoutError> { |
| 420 | let shaped = self.shape(paragraph, width, spaces)?; |
| 421 | let mut placed = Vec::new(); |
| 422 | let text = paragraph.text(); |
| 423 | let mut lines = Vec::with_capacity(shaped.len()); |
| 424 | let mut top = 0.0_f64; |
| 425 | let mut source_end = 0; |
| 426 | for line in shaped.lines() { |
| 427 | if !line.metrics().advance.is_finite() |
| 428 | || !line.metrics().trailing_whitespace.is_finite() |
| 429 | { |
| 430 | return Err(LayoutError::InvalidFontMetrics); |
| 431 | } |
| 432 | // Parley gives its synthetic empty line a 0..1 range. |
| 433 | let range = if text.is_empty() { |
| 434 | 0..0 |
| 435 | } else { |
| 436 | line.text_range() |
| 437 | }; |
| 438 | if range.start != source_end |
| 439 | || range.end < range.start |
| 440 | || !text.is_char_boundary(range.end) |
| 441 | { |
| 442 | return Err(LayoutError::InvalidSourceRange); |
| 443 | } |
| 444 | source_end = range.end; |
| 445 | let mut ascent = 0.0_f32; |
| 446 | let mut descent = 0.0_f32; |
| 447 | for run in line.runs() { |
| 448 | let data = &run.font().font; |
| 449 | let font = FontRef::from_index(data.data.as_ref(), data.index) |
| 450 | .map_err(|_| LayoutError::InvalidFontMetrics)?; |
| 451 | // Color glyphs fit the source font's line box instead of shifting annotation rows. |
| 452 | let (a, d) = if font.colr().is_ok() || font.sbix().is_ok() || font.cbdt().is_ok() { |
| 453 | let format = &paragraph |
| 454 | .spans() |
| 455 | .iter() |
| 456 | .find(|span| span.end > run.text_range().start) |
| 457 | .unwrap_or_else(|| paragraph.spans().last().unwrap()) |
| 458 | .format; |
| 459 | let sample = |
| 460 | self.shape(&Paragraph::new("Mg".into(), format.clone()), f32::MAX, &[])?; |
| 461 | sample.lines().flat_map(|line| line.runs()).try_fold( |
| 462 | (0.0_f32, 0.0_f32), |
| 463 | |(a, d), run| { |
| 464 | let (next_a, next_d) = self.font_extents(run)?; |
| 465 | Ok::<_, LayoutError>((a.max(next_a), d.max(next_d))) |
| 466 | }, |
| 467 | )? |
| 468 | } else { |
| 469 | self.font_extents(run)? |
| 470 | }; |
| 471 | if !a.is_finite() || !d.is_finite() || a < 0.0 || d < 0.0 { |
| 472 | return Err(LayoutError::InvalidFontMetrics); |
| 473 | } |
| 474 | ascent = ascent.max(a); |
| 475 | descent = descent.max(d); |
| 476 | } |
| 477 | for item in line.items() { |
| 478 | if let PositionedLayoutItem::InlineBox(inline) = item { |
| 479 | let space = &spaces[inline.id as usize]; |
| 480 | ascent = ascent.max(space.ascent); |
| 481 | descent = descent.max(space.descent); |
| 482 | placed.push((inline.id as usize, inline.x, lines.len())); |
| 483 | } |
| 484 | } |
| 485 | let height = ascent + descent; |
| 486 | if height <= 0.0 || !height.is_finite() { |
| 487 | return Err(LayoutError::InvalidFontMetrics); |
| 488 | } |
| 489 | lines.push(LineBox { |
| 490 | source: range, |
| 491 | top: top as f32, |
| 492 | baseline: (top + f64::from(ascent)) as f32, |
| 493 | height, |
| 494 | }); |
| 495 | top += f64::from(height); |
| 496 | if !(top as f32).is_finite() { |
| 497 | return Err(LayoutError::InvalidFontMetrics); |
| 498 | } |
| 499 | } |
| 500 | if source_end != text.len() || lines.is_empty() { |
| 501 | return Err(LayoutError::InvalidSourceRange); |
| 502 | } |
| 503 | static NEXT_ID: AtomicU64 = AtomicU64::new(0); |
| 504 | let id = NEXT_ID |
| 505 | .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |id| id.checked_add(1)) |
| 506 | .expect("Text layout identities exhausted"); |
| 507 | Ok(TextLayout { |
| 508 | id, |
| 509 | shaped: Arc::new(shaped), |
| 510 | #[cfg(feature = "gpu")] |
| 511 | text: text.into(), |
| 512 | lines, |
| 513 | spaces: placed, |
| 514 | }) |
| 515 | } |
| 516 | } |
| 517 | |
| 518 | impl TextLayout { |
| 519 | /// Process-unique identity for caches of an externally immutable layout. |
| 520 | pub(crate) fn id(&self) -> u64 { |
| 521 | self.id |
| 522 | } |
| 523 | |
| 524 | pub(crate) fn minimum_line_height(&mut self, minimum: f32) -> Result<(), LayoutError> { |
| 525 | if !minimum.is_finite() || minimum < 0.0 { |
| 526 | return Err(LayoutError::InvalidSpacing); |
| 527 | } |
| 528 | if minimum == 0.0 { |
| 529 | return Ok(()); |
| 530 | } |
| 531 | let mut top = 0.0_f64; |
| 532 | for line in &mut self.lines { |
| 533 | let ascent = line.baseline - line.top; |
| 534 | line.height = line.height.max(minimum); |
| 535 | line.top = top as f32; |
| 536 | line.baseline = (top + f64::from(ascent)) as f32; |
| 537 | top += f64::from(line.height); |
| 538 | if !(top as f32).is_finite() { |
| 539 | return Err(LayoutError::InvalidSpacing); |
| 540 | } |
| 541 | } |
| 542 | Ok(()) |
| 543 | } |
| 544 | |
| 545 | /// Each inline space laid out, by its index, with its x and its line's baseline. |
| 546 | pub fn spaces(&self) -> impl Iterator<Item = (usize, [f32; 2])> + '_ { |
| 547 | self.spaces |
| 548 | .iter() |
| 549 | .map(|&(id, x, line)| (id, [x, self.lines[line].baseline])) |
| 550 | } |
| 551 | |
| 552 | pub fn lines(&self) -> impl Iterator<Item = (parley::Line<'_, TextBrush>, &LineBox)> { |
| 553 | self.shaped.lines().zip(&self.lines) |
| 554 | } |
| 555 | |
| 556 | pub fn backgrounds(&self) -> impl Iterator<Item = (BoundingBox, u32)> { |
| 557 | self.lines().flat_map(|(line, bounds)| { |
| 558 | line.items().filter_map(move |item| { |
| 559 | let PositionedLayoutItem::GlyphRun(run) = item else { |
| 560 | return None; |
| 561 | }; |
| 562 | let color = run.style().brush.highlight?; |
| 563 | Some(( |
| 564 | BoundingBox { |
| 565 | x0: f64::from(run.offset()), |
| 566 | y0: f64::from(bounds.top), |
| 567 | x1: f64::from(run.offset() + run.advance()), |
| 568 | y1: f64::from(bounds.top + bounds.height), |
| 569 | }, |
| 570 | color, |
| 571 | )) |
| 572 | }) |
| 573 | }) |
| 574 | } |
| 575 | |
| 576 | pub fn height(&self) -> f32 { |
| 577 | let last = self.lines.last().unwrap(); |
| 578 | last.top + last.height |
| 579 | } |
| 580 | |
| 581 | pub(crate) fn cursor(&self, byte: usize, affinity: Affinity) -> Cursor { |
| 582 | Cursor::from_byte_index(&self.shaped, byte, affinity) |
| 583 | } |
| 584 | |
| 585 | pub(crate) fn hit_test(&self, x: f32, y: f32) -> Cursor { |
| 586 | let index = self |
| 587 | .lines |
| 588 | .partition_point(|line| line.top + line.height <= y) |
| 589 | .min(self.lines.len() - 1); |
| 590 | let metrics = *self.shaped.get(index).unwrap().metrics(); |
| 591 | Cursor::from_point( |
| 592 | &self.shaped, |
| 593 | x, |
| 594 | (metrics.block_min_coord + metrics.block_max_coord) * 0.5, |
| 595 | ) |
| 596 | } |
| 597 | |
| 598 | pub fn caret(&self, cursor: Cursor, width: f32) -> BoundingBox { |
| 599 | let mut rect = cursor.geometry(&self.shaped, width); |
| 600 | // parley pairs the logical end of an RTL cluster leading its line with the previous |
| 601 | // line's last cluster, placing the caret at that line's end. |
| 602 | if cursor.affinity() == Affinity::Upstream |
| 603 | && let Some(cluster) = cursor |
| 604 | .index() |
| 605 | .checked_sub(1) |
| 606 | .and_then(|index| parley::Cluster::from_byte_index(&self.shaped, index)) |
| 607 | && cluster.is_rtl() |
| 608 | && cluster |
| 609 | .previous_visual() |
| 610 | .is_some_and(|previous| previous.path().line_index() != cluster.path().line_index()) |
| 611 | { |
| 612 | let x = f64::from(cluster.visual_offset().unwrap_or_default()); |
| 613 | let metrics = *cluster.line().metrics(); |
| 614 | rect = BoundingBox::new( |
| 615 | x, |
| 616 | f64::from(metrics.block_min_coord), |
| 617 | x + f64::from(width), |
| 618 | f64::from(metrics.block_max_coord), |
| 619 | ); |
| 620 | } |
| 621 | let index = self |
| 622 | .shaped |
| 623 | .lines() |
| 624 | .position(|line| rect.y0 < f64::from(line.metrics().block_max_coord)) |
| 625 | .unwrap_or(self.lines.len() - 1); |
| 626 | let line = &self.lines[index]; |
| 627 | rect.y0 = f64::from(line.top); |
| 628 | rect.y1 = f64::from(line.top + line.height); |
| 629 | rect |
| 630 | } |
| 631 | |
| 632 | /// Each line's part of `selection` as a mark under the text runs: its left, its right |
| 633 | /// and the line's baseline. |
| 634 | pub(crate) fn underlines(&self, selection: Selection) -> Vec<[f32; 3]> { |
| 635 | selection |
| 636 | .geometry(&self.shaped) |
| 637 | .into_iter() |
| 638 | .map(|(rect, index)| [rect.x0 as f32, rect.x1 as f32, self.lines[index].baseline]) |
| 639 | .collect() |
| 640 | } |
| 641 | |
| 642 | pub fn selection(&self, selection: Selection) -> Vec<BoundingBox> { |
| 643 | selection |
| 644 | .geometry(&self.shaped) |
| 645 | .into_iter() |
| 646 | .map(|(mut rect, index)| { |
| 647 | let line = &self.lines[index]; |
| 648 | rect.y0 = f64::from(line.top); |
| 649 | rect.y1 = f64::from(line.top + line.height); |
| 650 | rect |
| 651 | }) |
| 652 | .collect() |
| 653 | } |
| 654 | } |
| 655 | |
| 656 | #[cfg(test)] |
| 657 | mod tests { |
| 658 | use super::*; |
| 659 | use onestore::document::Format; |
| 660 | |
| 661 | #[test] |
| 662 | #[ignore = "requires Arial"] |
| 663 | fn native_unkerned_advances() { |
| 664 | let mut engine = TextEngine::default(); |
| 665 | for (font, expected) in [("Calibri", 149.63843), ("Arial", 184.58305)] { |
| 666 | let paragraph = Paragraph::new( |
| 667 | "AVATAR AVATAR SECOND OFFICE".into(), |
| 668 | onestore::document::Format { |
| 669 | font: Some(font.into()), |
| 670 | font_size: Some(11.0), |
| 671 | ..Default::default() |
| 672 | }, |
| 673 | ); |
| 674 | let layout = engine.layout(&paragraph, 400.0).unwrap(); |
| 675 | assert_eq!(layout.lines().count(), 1); |
| 676 | assert!((layout.lines().next().unwrap().0.metrics().advance - expected).abs() < 0.001); |
| 677 | } |
| 678 | } |
| 679 | |
| 680 | #[test] |
| 681 | fn a_registered_substitute_replaces_the_bundled_one() { |
| 682 | let mut engine = TextEngine::default(); |
| 683 | if engine.substitute("Calibri").is_none() { |
| 684 | return; |
| 685 | } |
| 686 | let explicit = Blob::new(Arc::new( |
| 687 | include_bytes!("../assets/fonts/Carlito-Regular.ttf").to_vec(), |
| 688 | )); |
| 689 | assert_eq!(engine.register_substitute(explicit.clone()), Ok("Calibri")); |
| 690 | let substitute = engine.substitute("Calibri").unwrap(); |
| 691 | assert_eq!(substitute.faces.len(), 1); |
| 692 | let paragraph = Paragraph::new( |
| 693 | "Calibri".into(), |
| 694 | Format { |
| 695 | font: Some("Calibri".into()), |
| 696 | bold: Some(true), |
| 697 | ..Format::default() |
| 698 | }, |
| 699 | ); |
| 700 | let layout = engine.layout(&paragraph, 400.0).unwrap(); |
| 701 | let (line, _) = layout.lines().next().unwrap(); |
| 702 | for item in line.items() { |
| 703 | let PositionedLayoutItem::GlyphRun(run) = item else { |
| 704 | continue; |
| 705 | }; |
| 706 | assert_eq!(run.run().font().font.data.id(), explicit.id()); |
| 707 | } |
| 708 | } |
| 709 | |
| 710 | #[test] |
| 711 | fn rejected_substitute_leaves_font_selection_unchanged() { |
| 712 | let mut engine = TextEngine::default(); |
| 713 | let paragraph = Paragraph::new("first words wrap here".into(), Format::default()); |
| 714 | let before = engine.layout(&paragraph, 72.0).unwrap(); |
| 715 | for data in [Vec::new(), vec![0; 512], b"not a font".to_vec()] { |
| 716 | let result = |
| 717 | engine.register_substitute(parley::fontique::Blob::new(std::sync::Arc::new(data))); |
| 718 | assert_eq!(result.unwrap_err(), LayoutError::UnsupportedSubstituteFont); |
| 719 | } |
| 720 | assert!(engine.arial_substitutes.is_empty()); |
| 721 | let after = engine.layout(&paragraph, 72.0).unwrap(); |
| 722 | assert_eq!(before.height(), after.height()); |
| 723 | let lines = |layout: &TextLayout| { |
| 724 | layout |
| 725 | .lines() |
| 726 | .map(|(_, line)| (line.source.clone(), line.baseline)) |
| 727 | .collect::<Vec<_>>() |
| 728 | }; |
| 729 | assert_eq!(lines(&before), lines(&after)); |
| 730 | assert_eq!( |
| 731 | before |
| 732 | .lines() |
| 733 | .next() |
| 734 | .unwrap() |
| 735 | .0 |
| 736 | .runs() |
| 737 | .next() |
| 738 | .unwrap() |
| 739 | .font() |
| 740 | .font |
| 741 | .data |
| 742 | .id(), |
| 743 | after |
| 744 | .lines() |
| 745 | .next() |
| 746 | .unwrap() |
| 747 | .0 |
| 748 | .runs() |
| 749 | .next() |
| 750 | .unwrap() |
| 751 | .font() |
| 752 | .font |
| 753 | .data |
| 754 | .id() |
| 755 | ); |
| 756 | } |
| 757 | |
| 758 | #[test] |
| 759 | fn missing_family_keeps_digits_in_the_letters_face() { |
| 760 | let paragraph = Paragraph::new( |
| 761 | "Monday, August 10, 2026".into(), |
| 762 | Format { |
| 763 | font: Some("Snowbound Missing Family".into()), |
| 764 | ..Format::default() |
| 765 | }, |
| 766 | ); |
| 767 | let layout = TextEngine::default().layout(&paragraph, 468.0).unwrap(); |
| 768 | let fonts = layout |
| 769 | .lines() |
| 770 | .flat_map(|(line, _)| { |
| 771 | line.runs() |
| 772 | .map(|run| run.font().font.data.id()) |
| 773 | .collect::<Vec<_>>() |
| 774 | }) |
| 775 | .collect::<BTreeSet<_>>(); |
| 776 | assert_eq!(fonts.len(), 1); |
| 777 | } |
| 778 | |
| 779 | #[test] |
| 780 | fn scripts_shrink_and_shift_without_moving_the_baseline() { |
| 781 | let mut engine = TextEngine::default(); |
| 782 | let script = |superscript, subscript| Format { |
| 783 | superscript, |
| 784 | subscript, |
| 785 | ..Format::default() |
| 786 | }; |
| 787 | let layout = engine |
| 788 | .layout( |
| 789 | &Paragraph::from_runs([ |
| 790 | ("x".into(), Format::default()), |
| 791 | ("2".into(), script(Some(true), None)), |
| 792 | ("i".into(), script(None, Some(true))), |
| 793 | ]), |
| 794 | 300.0, |
| 795 | ) |
| 796 | .unwrap(); |
| 797 | let runs: Vec<_> = layout |
| 798 | .lines() |
| 799 | .flat_map(|(line, _)| line.items().collect::<Vec<_>>()) |
| 800 | .filter_map(|item| match item { |
| 801 | PositionedLayoutItem::GlyphRun(run) => { |
| 802 | Some((run.run().font_size(), run.style().brush.rise)) |
| 803 | } |
| 804 | _ => None, |
| 805 | }) |
| 806 | .collect(); |
| 807 | let third = 11.0 / 3.0; |
| 808 | assert_eq!(runs[0], (11.0, 0.0)); |
| 809 | assert!((runs[1].0 - 2.0 * third).abs() < 0.001 && (runs[1].1 - third).abs() < 0.001); |
| 810 | assert!(runs[2].1 < 0.0); |
| 811 | let plain = engine |
| 812 | .layout(&Paragraph::new("x2i".into(), Format::default()), 300.0) |
| 813 | .unwrap(); |
| 814 | assert_eq!( |
| 815 | layout.lines().next().unwrap().1.baseline, |
| 816 | plain.lines().next().unwrap().1.baseline |
| 817 | ); |
| 818 | } |
| 819 | |
| 820 | #[test] |
| 821 | fn links_draw_blue_and_underlined_unless_coloured() { |
| 822 | let mut engine = TextEngine::default(); |
| 823 | let link = Format { |
| 824 | hyperlink: Some(true), |
| 825 | ..Format::default() |
| 826 | }; |
| 827 | let layout = engine |
| 828 | .layout( |
| 829 | &Paragraph::from_runs([ |
| 830 | ("plain ".into(), Format::default()), |
| 831 | ("link".into(), link.clone()), |
| 832 | ( |
| 833 | " red".into(), |
| 834 | Format { |
| 835 | color: Some(0x0000_00ff), |
| 836 | ..link |
| 837 | }, |
| 838 | ), |
| 839 | ]), |
| 840 | 300.0, |
| 841 | ) |
| 842 | .unwrap(); |
| 843 | let runs: Vec<_> = layout |
| 844 | .lines() |
| 845 | .flat_map(|(line, _)| line.items().collect::<Vec<_>>()) |
| 846 | .filter_map(|item| match item { |
| 847 | PositionedLayoutItem::GlyphRun(run) => { |
| 848 | let style = run.style(); |
| 849 | Some((style.brush.color, style.underline.is_some())) |
| 850 | } |
| 851 | _ => None, |
| 852 | }) |
| 853 | .collect(); |
| 854 | assert_eq!( |
| 855 | runs, |
| 856 | [ |
| 857 | (None, false), |
| 858 | (Some(0x00ff_0000), true), |
| 859 | (Some(0x0000_00ff), true) |
| 860 | ] |
| 861 | ); |
| 862 | } |
| 863 | |
| 864 | #[test] |
| 865 | fn paragraph_alignment_offsets_lines_within_the_wrap_width() { |
| 866 | let mut engine = TextEngine::default(); |
| 867 | let mut offset = |alignment| { |
| 868 | let layout = engine |
| 869 | .layout( |
| 870 | &Paragraph::new( |
| 871 | "Short".into(), |
| 872 | Format { |
| 873 | alignment, |
| 874 | ..Format::default() |
| 875 | }, |
| 876 | ), |
| 877 | 200.0, |
| 878 | ) |
| 879 | .unwrap(); |
| 880 | let (line, _) = layout.lines().next().unwrap(); |
| 881 | let advance = line.metrics().advance; |
| 882 | let x = line |
| 883 | .items() |
| 884 | .find_map(|item| match item { |
| 885 | PositionedLayoutItem::GlyphRun(run) => Some(run.offset()), |
| 886 | _ => None, |
| 887 | }) |
| 888 | .unwrap(); |
| 889 | (x, advance) |
| 890 | }; |
| 891 | let (left, advance) = offset(None); |
| 892 | assert_eq!(left, 0.0); |
| 893 | assert!(((offset(Some(1)).0) - (200.0 - advance) / 2.0).abs() < 0.01); |
| 894 | assert!(((offset(Some(2)).0) - (200.0 - advance)).abs() < 0.01); |
| 895 | } |
| 896 | |
| 897 | #[test] |
| 898 | fn highlights_share_line_geometry_without_changing_wraps() { |
| 899 | let paragraph = Paragraph::from_runs([ |
| 900 | ( |
| 901 | "A highlighted ".into(), |
| 902 | Format { |
| 903 | highlight: Some(0x0000ffff), |
| 904 | ..Format::default() |
| 905 | }, |
| 906 | ), |
| 907 | ( |
| 908 | "phrase and ".into(), |
| 909 | Format { |
| 910 | highlight: Some(0), |
| 911 | ..Format::default() |
| 912 | }, |
| 913 | ), |
| 914 | ( |
| 915 | "automatic background".into(), |
| 916 | Format { |
| 917 | highlight: Some(0xff000000), |
| 918 | ..Format::default() |
| 919 | }, |
| 920 | ), |
| 921 | ]); |
| 922 | let mut engine = TextEngine::default(); |
| 923 | let layout = engine.layout(&paragraph, 80.0).unwrap(); |
| 924 | let plain = engine |
| 925 | .layout( |
| 926 | &Paragraph::new(paragraph.text().into(), Format::default()), |
| 927 | 80.0, |
| 928 | ) |
| 929 | .unwrap(); |
| 930 | assert_eq!( |
| 931 | layout |
| 932 | .lines() |
| 933 | .map(|(_, l)| l.source.clone()) |
| 934 | .collect::<Vec<_>>(), |
| 935 | plain |
| 936 | .lines() |
| 937 | .map(|(_, l)| l.source.clone()) |
| 938 | .collect::<Vec<_>>() |
| 939 | ); |
| 940 | let backgrounds = layout.backgrounds().collect::<Vec<_>>(); |
| 941 | assert!(backgrounds.iter().any(|(_, color)| *color == 0)); |
| 942 | assert!(backgrounds.iter().any(|(_, color)| *color == 0x0000ffff)); |
| 943 | assert!(!backgrounds.iter().any(|(_, color)| *color == 0xff000000)); |
| 944 | for (rect, _) in backgrounds { |
| 945 | assert!(rect.width() > 0.0); |
| 946 | assert!( |
| 947 | layout |
| 948 | .lines() |
| 949 | .any(|(_, line)| rect.y0 == f64::from(line.top) |
| 950 | && rect.y1 == f64::from(line.top + line.height)) |
| 951 | ); |
| 952 | } |
| 953 | } |
| 954 | |
| 955 | #[test] |
| 956 | fn color_boundaries_preserve_contextual_shaping_and_wrapping() { |
| 957 | let mut engine = TextEngine::default(); |
| 958 | for text in ["office affinity", "العربية سلام", "ae\u{301}👩🏽💻z"] { |
| 959 | let plain = Paragraph::new(text.into(), Format::default()); |
| 960 | let colored = Paragraph::from_runs(text.chars().enumerate().map(|(index, ch)| { |
| 961 | ( |
| 962 | ch.to_string(), |
| 963 | Format { |
| 964 | color: Some(if index % 2 == 0 { 0xff } else { 0xff0000 }), |
| 965 | ..Format::default() |
| 966 | }, |
| 967 | ) |
| 968 | })); |
| 969 | for width in [1.0, 40.0, 110.0, 1000.0] { |
| 970 | let plain = engine.layout(&plain, width).unwrap(); |
| 971 | let colored = engine.layout(&colored, width).unwrap(); |
| 972 | assert_eq!(plain.lines.len(), colored.lines.len()); |
| 973 | for ((plain_line, plain_box), (colored_line, colored_box)) in |
| 974 | plain.lines().zip(colored.lines()) |
| 975 | { |
| 976 | assert_eq!(plain_box.source, colored_box.source); |
| 977 | assert_eq!(plain_box.baseline, colored_box.baseline); |
| 978 | assert_eq!(plain_box.height, colored_box.height); |
| 979 | let glyphs = |line: parley::Line<'_, TextBrush>| { |
| 980 | line.items() |
| 981 | .flat_map(|item| match item { |
| 982 | PositionedLayoutItem::GlyphRun(run) => run |
| 983 | .positioned_glyphs() |
| 984 | .map(|glyph| { |
| 985 | ( |
| 986 | run.run().font().font.data.id(), |
| 987 | glyph.id, |
| 988 | glyph.x, |
| 989 | glyph.y, |
| 990 | ) |
| 991 | }) |
| 992 | .collect::<Vec<_>>(), |
| 993 | PositionedLayoutItem::InlineBox(_) => unreachable!(), |
| 994 | }) |
| 995 | .collect::<Vec<_>>() |
| 996 | }; |
| 997 | let plain_glyphs = glyphs(plain_line); |
| 998 | let colored_glyphs = glyphs(colored_line); |
| 999 | assert_eq!(plain_glyphs.len(), colored_glyphs.len()); |
| 1000 | for (a, b) in plain_glyphs.into_iter().zip(colored_glyphs) { |
| 1001 | assert_eq!((a.0, a.1), (b.0, b.1), "{text}, width {width}"); |
| 1002 | assert!((a.2 - b.2).abs() < 0.001 && (a.3 - b.3).abs() < 0.001); |
| 1003 | } |
| 1004 | } |
| 1005 | } |
| 1006 | } |
| 1007 | } |
| 1008 | |
| 1009 | #[test] |
| 1010 | fn empty_paragraph_has_source_position_and_caret() { |
| 1011 | let paragraph = Paragraph::new(String::new(), Format::default()); |
| 1012 | let layout = TextEngine::default().layout(&paragraph, 100.0).unwrap(); |
| 1013 | assert_eq!(layout.lines().next().unwrap().1.source, 0..0); |
| 1014 | assert!(layout.height() > 0.0); |
| 1015 | let cursor = layout.hit_test(50.0, 50.0); |
| 1016 | assert_eq!(cursor.index(), 0); |
| 1017 | let caret = layout.caret(cursor, 1.0); |
| 1018 | assert_eq!(caret.y0, 0.0); |
| 1019 | assert_eq!(caret.y1, f64::from(layout.height())); |
| 1020 | } |
| 1021 | |
| 1022 | #[test] |
| 1023 | fn long_words_wrap_with_complete_source_and_usable_carets() { |
| 1024 | let paragraph = Paragraph::new( |
| 1025 | "Wrapped HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789".into(), |
| 1026 | Format::default(), |
| 1027 | ); |
| 1028 | let width = 110.0; |
| 1029 | let layout = TextEngine::default().layout(&paragraph, width).unwrap(); |
| 1030 | let mut end = 0; |
| 1031 | for (line, bounds) in layout.lines() { |
| 1032 | assert!(line.metrics().advance - line.metrics().trailing_whitespace <= width); |
| 1033 | assert_eq!(bounds.source.start, end); |
| 1034 | end = bounds.source.end; |
| 1035 | } |
| 1036 | assert_eq!(end, paragraph.text().len()); |
| 1037 | for byte in 0..=end { |
| 1038 | for affinity in [Affinity::Upstream, Affinity::Downstream] { |
| 1039 | let rect = layout.caret(layout.cursor(byte, affinity), 0.0); |
| 1040 | let hit = layout.hit_test(rect.x0 as f32, ((rect.y0 + rect.y1) * 0.5) as f32); |
| 1041 | assert_eq!(hit.index(), byte, "byte {byte}, {affinity:?}, {rect:?}"); |
| 1042 | } |
| 1043 | } |
| 1044 | } |
| 1045 | |
| 1046 | #[test] |
| 1047 | fn wrapped_carets_hit_the_same_source_position() { |
| 1048 | let paragraph = Paragraph::new( |
| 1049 | "A quiet evening under the trees, with notes beside every line.".into(), |
| 1050 | Format::default(), |
| 1051 | ); |
| 1052 | let layout = TextEngine::default().layout(&paragraph, 110.0).unwrap(); |
| 1053 | assert!(layout.lines.len() > 1); |
| 1054 | for byte in 0..=paragraph.text().len() { |
| 1055 | for affinity in [Affinity::Upstream, Affinity::Downstream] { |
| 1056 | let cursor = layout.cursor(byte, affinity); |
| 1057 | let rect = layout.caret(cursor, 0.0); |
| 1058 | let hit = layout.hit_test(rect.x0 as f32, ((rect.y0 + rect.y1) * 0.5) as f32); |
| 1059 | assert_eq!(hit.index(), byte, "byte {byte}, {affinity:?}, {rect:?}"); |
| 1060 | } |
| 1061 | } |
| 1062 | } |
| 1063 | |
| 1064 | #[test] |
| 1065 | fn visual_navigation_keeps_combining_and_emoji_sequences_intact() { |
| 1066 | let graphemes = ["a", "e\u{301}", "👩🏽💻", "🇨🇦", "👩👩👧👦", "z"]; |
| 1067 | let text = graphemes.concat(); |
| 1068 | let paragraph = Paragraph::new(text, Format::default()); |
| 1069 | let layout = TextEngine::default().layout(&paragraph, 1000.0).unwrap(); |
| 1070 | let mut byte = 0; |
| 1071 | let mut cursor = layout.cursor(0, Affinity::Downstream); |
| 1072 | for grapheme in graphemes { |
| 1073 | byte += grapheme.len(); |
| 1074 | cursor = cursor.next_visual(&layout.shaped); |
| 1075 | assert_eq!(cursor.index(), byte); |
| 1076 | } |
| 1077 | for grapheme in graphemes.into_iter().rev() { |
| 1078 | byte -= grapheme.len(); |
| 1079 | cursor = cursor.previous_visual(&layout.shaped); |
| 1080 | assert_eq!(cursor.index(), byte); |
| 1081 | } |
| 1082 | } |
| 1083 | |
| 1084 | #[test] |
| 1085 | fn emoji_only_lines_have_nonzero_metrics() { |
| 1086 | let paragraph = Paragraph::new("👩👩👧👦".into(), Format::default()); |
| 1087 | let layout = TextEngine::default().layout(&paragraph, 100.0).unwrap(); |
| 1088 | assert!(layout.height() > 0.0); |
| 1089 | let caret = layout.caret(layout.cursor(0, Affinity::Downstream), 1.0); |
| 1090 | assert!(caret.height() > 0.0); |
| 1091 | } |
| 1092 | |
| 1093 | #[cfg(target_os = "macos")] |
| 1094 | #[test] |
| 1095 | fn color_emoji_preserve_text_baselines_and_respect_explicit_font_size() { |
| 1096 | let mut engine = TextEngine::default(); |
| 1097 | for size in [11.0, 22.0] { |
| 1098 | let format = Format { |
| 1099 | font_size: Some(size), |
| 1100 | ..Default::default() |
| 1101 | }; |
| 1102 | let base = engine |
| 1103 | .layout(&Paragraph::new("Mg".into(), format.clone()), 1000.0) |
| 1104 | .unwrap(); |
| 1105 | for text in ["🌳", "👩👩👧👦", "before 🌳 after", "Mg\n🌳\nMg"] { |
| 1106 | let layout = engine |
| 1107 | .layout(&Paragraph::new(text.into(), format.clone()), 1000.0) |
| 1108 | .unwrap(); |
| 1109 | for (_, line) in layout.lines() { |
| 1110 | assert_eq!(line.height, base.lines[0].height, "{text}"); |
| 1111 | assert!( |
| 1112 | (line.baseline - line.top - base.lines[0].baseline).abs() < 0.0001, |
| 1113 | "{text}" |
| 1114 | ); |
| 1115 | } |
| 1116 | } |
| 1117 | } |
| 1118 | } |
| 1119 | |
| 1120 | #[test] |
| 1121 | fn bidi_hit_testing_stays_on_grapheme_boundaries() { |
| 1122 | let graphemes = [ |
| 1123 | "a", |
| 1124 | " ", |
| 1125 | "ש", |
| 1126 | "ל", |
| 1127 | "ו", |
| 1128 | "ם", |
| 1129 | " ", |
| 1130 | "1", |
| 1131 | "2", |
| 1132 | "3", |
| 1133 | " ", |
| 1134 | "👩🏽💻", |
| 1135 | " ", |
| 1136 | "e\u{301}", |
| 1137 | ]; |
| 1138 | let mut boundaries = vec![0]; |
| 1139 | for grapheme in graphemes { |
| 1140 | boundaries.push(boundaries.last().unwrap() + grapheme.len()); |
| 1141 | } |
| 1142 | let paragraph = Paragraph::new(graphemes.concat(), Format::default()); |
| 1143 | let mut engine = TextEngine::default(); |
| 1144 | for width in [1.0, 10.0, 40.0, 80.0, 1000.0] { |
| 1145 | let layout = engine.layout(&paragraph, width).unwrap(); |
| 1146 | for (_, line) in layout.lines() { |
| 1147 | assert!(boundaries.contains(&line.source.start)); |
| 1148 | assert!(boundaries.contains(&line.source.end)); |
| 1149 | } |
| 1150 | for y in (-10..layout.height() as i32 + 10).step_by(3) { |
| 1151 | for x in (-10..width as i32 + 10).step_by(3) { |
| 1152 | let cursor = layout.hit_test(x as f32, y as f32); |
| 1153 | assert!( |
| 1154 | boundaries.contains(&cursor.index()), |
| 1155 | "{width}: {x}, {y} -> {cursor:?}" |
| 1156 | ); |
| 1157 | let caret = layout.caret(cursor, 1.0); |
| 1158 | assert!(caret.x0.is_finite() && caret.y0.is_finite() && caret.height() > 0.0); |
| 1159 | } |
| 1160 | } |
| 1161 | } |
| 1162 | } |
| 1163 | |
| 1164 | #[test] |
| 1165 | fn selection_uses_the_same_line_boxes_as_hit_testing() { |
| 1166 | let paragraph = Paragraph::new( |
| 1167 | "one two three four five six seven".into(), |
| 1168 | Format::default(), |
| 1169 | ); |
| 1170 | let layout = TextEngine::default().layout(&paragraph, 60.0).unwrap(); |
| 1171 | let selection = Selection::from(layout.cursor(0, Affinity::Downstream)) |
| 1172 | .extend(layout.cursor(paragraph.text().len(), Affinity::Upstream)); |
| 1173 | let rects = layout.selection(selection); |
| 1174 | assert_eq!(rects.len(), layout.lines.len()); |
| 1175 | for (rect, line) in rects.iter().zip(&layout.lines) { |
| 1176 | assert_eq!(rect.y0, f64::from(line.top)); |
| 1177 | assert_eq!(rect.y1, f64::from(line.top + line.height)); |
| 1178 | let cursor = layout.hit_test(rect.x0 as f32 + 0.01, ((rect.y0 + rect.y1) * 0.5) as f32); |
| 1179 | assert_eq!(cursor.index(), line.source.start); |
| 1180 | } |
| 1181 | } |
| 1182 | } |