1use onestore::page::text::Paragraph;
2use 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};
9use skrifa::{FontRef, MetadataProvider, raw::TableProvider, string::StringId};
10use std::{
11 collections::{BTreeMap, BTreeSet},
12 fmt,
13 ops::Range,
14 sync::{
15 Arc, OnceLock,
16 atomic::{AtomicU64, Ordering},
17 },
18};
19
20pub 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)]
30pub 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.
38const SCRIPT_SCALE: f32 = 2.0 / 3.0;
39const SUPERSCRIPT_RISE: f32 = 1.0 / 3.0;
40const SUBSCRIPT_DROP: f32 = 0.08;
41
42#[derive(Clone, Copy, Debug, Default, PartialEq)]
43pub 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)]
53pub struct LineBox {
54 pub source: Range<usize>,
55 pub top: f32,
56 pub baseline: f32,
57 pub height: f32,
58}
59
60#[derive(Clone)]
61pub 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)]
75pub(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)]
83pub enum LayoutError {
84 InvalidWidth,
85 InvalidFontSize,
86 InvalidSourceRange,
87 InvalidFontMetrics,
88 InvalidIndentation,
89 InvalidSpacing,
90 InvalidList,
91 UnsupportedContent,
92 UnsupportedSubstituteFont,
93}
94
95impl 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
109impl std::error::Error for LayoutError {}
110
111/// What a run without a font or size of its own is laid out in.
112pub(crate) const DEFAULT_FONT: &str = "Arial";
113pub(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`).
119const 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.
122static INFLATED: [OnceLock<Vec<Blob<u8>>>; BUNDLED.len()] =
123 [const { OnceLock::new() }; BUNDLED.len()];
124
125impl 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.
162impl 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
173impl 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
518impl 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)]
657mod 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}