1use std::convert::TryInto;
7use std::fmt;
8use std::ops::{Range, RangeBounds};
9use std::rc::Rc;
10
11use pango::prelude::FontMapExt;
12use pango::{AttrColor, AttrInt, AttrList, AttrSize, AttrString};
13use pangocairo::FontMap;
14
15use piet::kurbo::{Point, Rect, Size, Vec2};
16use piet::{
17 Error, FontFamily, FontStyle, HitTestPoint, HitTestPosition, LineMetric, Text, TextAlignment,
18 TextAttribute, TextLayout, TextLayoutBuilder, TextStorage, util,
19};
20
21type PangoLayout = pango::Layout;
22type PangoContext = pango::Context;
23type PangoAttribute = pango::Attribute;
24type PangoWeight = pango::Weight;
25type PangoStyle = pango::Style;
26type PangoUnderline = pango::Underline;
27type PangoAlignment = pango::Alignment;
28
29const PANGO_SCALE: f64 = pango::SCALE as f64;
30const UNBOUNDED_WRAP_WIDTH: i32 = -1;
31
32#[derive(Clone)]
33pub struct CairoText {
34 pango_context: PangoContext,
35}
36
37#[derive(Clone)]
38pub struct CairoTextLayout {
39 text: Rc<dyn TextStorage>,
40 is_rtl: bool,
41 size: Size,
42 ink_rect: Rect,
43 pango_offset: Vec2,
44 trailing_ws_width: f64,
45
46 line_metrics: Rc<[LineMetric]>,
47 x_offsets: Rc<[i32]>,
48 pango_layout: PangoLayout,
49}
50
51pub struct CairoTextLayoutBuilder {
52 text: Rc<dyn TextStorage>,
53 defaults: util::LayoutDefaults,
54 attributes: Vec<AttributeWithRange>,
55 last_range_start_pos: usize,
56 width_constraint: f64,
57 pango_layout: PangoLayout,
58}
59
60struct AttributeWithRange {
61 attribute: TextAttribute,
62 range: Option<Range<usize>>, }
64
65impl AttributeWithRange {
66 fn into_pango(self) -> PangoAttribute {
67 let mut pango_attribute: PangoAttribute = match &self.attribute {
68 TextAttribute::FontFamily(family) => {
69 let family = family.name();
70 AttrString::new_family(family).into()
75 }
76
77 TextAttribute::FontSize(size) => {
78 let size = (size * PANGO_SCALE) as i32;
79 AttrSize::new_size_absolute(size).into()
80 }
81
82 TextAttribute::Weight(weight) => {
83 let pango_weights = [
85 (100, PangoWeight::Thin),
86 (200, PangoWeight::Ultralight),
87 (300, PangoWeight::Light),
88 (350, PangoWeight::Semilight),
89 (380, PangoWeight::Book),
90 (400, PangoWeight::Normal),
91 (500, PangoWeight::Medium),
92 (600, PangoWeight::Semibold),
93 (700, PangoWeight::Bold),
94 (800, PangoWeight::Ultrabold),
95 (900, PangoWeight::Heavy),
96 (1_000, PangoWeight::Ultraheavy),
97 ];
98
99 let weight = weight.to_raw() as i32;
100 let mut closest_index = 0;
101 let mut closest_distance = 2_000; for (current_index, pango_weight) in pango_weights.iter().enumerate() {
103 let distance = (pango_weight.0 - weight).abs();
104 if distance < closest_distance {
105 closest_distance = distance;
106 closest_index = current_index;
107 }
108 }
109
110 AttrInt::new_weight(pango_weights[closest_index].1).into()
111 }
112
113 TextAttribute::TextColor(text_color) => {
114 let (r, g, b, _) = text_color.as_rgba8();
115 AttrColor::new_foreground(
116 (r as u16 * 256) + (r as u16),
117 (g as u16 * 256) + (g as u16),
118 (b as u16 * 256) + (b as u16),
119 )
120 .into()
121 }
122
123 TextAttribute::Style(style) => {
124 let style = match style {
125 FontStyle::Regular => PangoStyle::Normal,
126 FontStyle::Italic => PangoStyle::Italic,
127 };
128 AttrInt::new_style(style).into()
129 }
130
131 &TextAttribute::Underline(underline) => {
132 let underline = if underline {
133 PangoUnderline::Single
134 } else {
135 PangoUnderline::None
136 };
137 AttrInt::new_underline(underline).into()
138 }
139
140 &TextAttribute::Strikethrough(strikethrough) => {
141 AttrInt::new_strikethrough(strikethrough).into()
142 }
143 };
144
145 if let Some(range) = self.range {
146 pango_attribute.set_start_index(range.start.try_into().unwrap());
147 pango_attribute.set_end_index(range.end.try_into().unwrap());
148 }
149
150 pango_attribute
151 }
152}
153
154impl CairoText {
155 #[allow(clippy::new_without_default)]
157 pub fn new() -> CairoText {
158 let fontmap = FontMap::default();
159 CairoText {
160 pango_context: fontmap.create_context(),
161 }
162 }
163}
164
165impl Text for CairoText {
166 type TextLayout = CairoTextLayout;
167 type TextLayoutBuilder = CairoTextLayoutBuilder;
168
169 fn font_family(&mut self, family_name: &str) -> Option<FontFamily> {
170 Some(FontFamily::new_unchecked(family_name))
172 }
173
174 fn load_font(&mut self, _data: &[u8]) -> Result<FontFamily, Error> {
175 Err(Error::NotSupported)
183 }
184
185 fn new_text_layout(&mut self, text: impl TextStorage) -> Self::TextLayoutBuilder {
186 let pango_layout = PangoLayout::new(&self.pango_context);
187 pango_layout.set_text(text.as_str());
188
189 pango_layout.set_alignment(PangoAlignment::Left);
190 pango_layout.set_justify(false);
191
192 CairoTextLayoutBuilder {
193 text: Rc::new(text),
194 defaults: util::LayoutDefaults::default(),
195 attributes: Vec::new(),
196 last_range_start_pos: 0,
197 width_constraint: f64::INFINITY,
198 pango_layout,
199 }
200 }
201}
202
203impl fmt::Debug for CairoText {
204 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
205 f.debug_struct("CairoText").finish()
206 }
207}
208
209impl TextLayoutBuilder for CairoTextLayoutBuilder {
210 type Out = CairoTextLayout;
211
212 fn max_width(mut self, width: f64) -> Self {
213 self.width_constraint = width;
214 self
215 }
216
217 fn alignment(self, alignment: TextAlignment) -> Self {
218 match alignment {
229 TextAlignment::Start => {
230 self.pango_layout.set_justify(false);
231 self.pango_layout.set_alignment(PangoAlignment::Left);
232 }
233
234 TextAlignment::End => {
235 self.pango_layout.set_justify(false);
236 self.pango_layout.set_alignment(PangoAlignment::Right);
237 }
238
239 TextAlignment::Center => {
240 self.pango_layout.set_justify(false);
241 self.pango_layout.set_alignment(PangoAlignment::Center);
242 }
243
244 TextAlignment::Justified => {
245 self.pango_layout.set_alignment(PangoAlignment::Left);
246 self.pango_layout.set_justify(true);
247 }
248 }
249
250 self
251 }
252
253 fn default_attribute(mut self, attribute: impl Into<TextAttribute>) -> Self {
254 self.defaults.set(attribute);
255 self
256 }
257
258 fn range_attribute(
259 mut self,
260 range: impl RangeBounds<usize>,
261 attribute: impl Into<TextAttribute>,
262 ) -> Self {
263 let range = util::resolve_range(range, self.text.len());
264 let attribute = attribute.into();
265
266 debug_assert!(
267 range.start >= self.last_range_start_pos,
268 "attributes must be added in non-decreasing start order"
269 );
270 self.last_range_start_pos = range.start;
271
272 self.attributes.push(AttributeWithRange {
273 attribute,
274 range: Some(range),
275 });
276
277 self
278 }
279
280 fn build(self) -> Result<Self::Out, Error> {
281 let pango_attributes = AttrList::new();
282
283 pango_attributes.insert(pango::AttrInt::new_insert_hyphens(false));
284 pango_attributes.insert(
285 AttributeWithRange {
286 attribute: TextAttribute::FontFamily(self.defaults.font),
287 range: None,
288 }
289 .into_pango(),
290 );
291 pango_attributes.insert(
292 AttributeWithRange {
293 attribute: TextAttribute::FontSize(self.defaults.font_size),
294 range: None,
295 }
296 .into_pango(),
297 );
298 pango_attributes.insert(
299 AttributeWithRange {
300 attribute: TextAttribute::Weight(self.defaults.weight),
301 range: None,
302 }
303 .into_pango(),
304 );
305 pango_attributes.insert(
306 AttributeWithRange {
307 attribute: TextAttribute::TextColor(self.defaults.fg_color),
308 range: None,
309 }
310 .into_pango(),
311 );
312 pango_attributes.insert(
313 AttributeWithRange {
314 attribute: TextAttribute::Style(self.defaults.style),
315 range: None,
316 }
317 .into_pango(),
318 );
319 pango_attributes.insert(
320 AttributeWithRange {
321 attribute: TextAttribute::Underline(self.defaults.underline),
322 range: None,
323 }
324 .into_pango(),
325 );
326 pango_attributes.insert(
327 AttributeWithRange {
328 attribute: TextAttribute::Strikethrough(self.defaults.strikethrough),
329 range: None,
330 }
331 .into_pango(),
332 );
333
334 for attribute in self.attributes {
335 pango_attributes.insert(attribute.into_pango());
336 }
337
338 self.pango_layout.set_attributes(Some(&pango_attributes));
339 self.pango_layout.set_wrap(pango::WrapMode::WordChar);
340 self.pango_layout.set_ellipsize(pango::EllipsizeMode::None);
341
342 let mut layout = CairoTextLayout {
344 is_rtl: util::first_strong_rtl(self.text.as_str()),
345 text: self.text,
346 size: Size::ZERO,
347 ink_rect: Rect::ZERO,
348 pango_offset: Vec2::ZERO,
349 trailing_ws_width: 0.0,
350 line_metrics: Rc::new([]),
351 x_offsets: Rc::new([]),
352 pango_layout: self.pango_layout,
353 };
354
355 layout.update_width(self.width_constraint);
356 Ok(layout)
357 }
358}
359
360impl fmt::Debug for CairoTextLayoutBuilder {
361 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
362 f.debug_struct("CairoTextLayoutBuilder").finish()
363 }
364}
365
366impl TextLayout for CairoTextLayout {
367 fn size(&self) -> Size {
368 self.size
369 }
370
371 fn trailing_whitespace_width(&self) -> f64 {
372 self.trailing_ws_width
373 }
374
375 fn image_bounds(&self) -> Rect {
376 self.ink_rect
377 }
378
379 fn text(&self) -> &str {
380 &self.text
381 }
382
383 fn line_text(&self, line_number: usize) -> Option<&str> {
384 self.line_metrics
385 .get(line_number)
386 .map(|lm| &self.text[lm.range()])
387 }
388
389 fn line_metric(&self, line_number: usize) -> Option<LineMetric> {
390 self.line_metrics.get(line_number).cloned()
391 }
392
393 fn line_count(&self) -> usize {
394 self.line_metrics.len()
395 }
396
397 fn hit_test_point(&self, point: Point) -> HitTestPoint {
398 let point = point + self.pango_offset;
399
400 let line_number = self
401 .line_metrics
402 .iter()
403 .position(|lm| lm.y_offset + lm.height >= point.y)
404 .unwrap_or_else(|| self.line_metrics.len().saturating_sub(1));
406 let x_offset = self.x_offsets[line_number];
407 let x = (point.x * PANGO_SCALE) as i32 - x_offset;
408
409 let line = self
410 .pango_layout
411 .line(line_number.try_into().unwrap())
412 .unwrap();
413
414 let line_text = self.line_text(line_number).unwrap();
415 let line_start_idx = self.line_metric(line_number).unwrap().start_offset;
416
417 let hitpos = line.x_to_index(x);
418 let rel_idx = if hitpos.is_inside() {
419 let idx = hitpos.index() as usize - line_start_idx;
420 let trailing_len: usize = line_text[idx..]
421 .chars()
422 .take(hitpos.trailing() as usize)
423 .map(char::len_utf8)
424 .sum();
425 idx + trailing_len
426 } else {
427 let hit_is_left = x <= 0;
428 let hard_break_len = match line_text.as_bytes() {
429 [.., b'\r', b'\n'] => 2,
430 [.., b'\n'] => 1,
431 _ => 0,
432 };
433 if hit_is_left == self.is_rtl {
434 line_text.len().saturating_sub(hard_break_len)
435 } else {
436 0
437 }
438 };
439
440 let is_inside_y = point.y >= 0. && point.y <= self.size.height;
441
442 HitTestPoint::new(line_start_idx + rel_idx, hitpos.is_inside() && is_inside_y)
443 }
444
445 fn hit_test_text_position(&self, idx: usize) -> HitTestPosition {
446 let idx = idx.min(self.text.len());
447 assert!(self.text.is_char_boundary(idx));
448
449 let line_number = self
450 .line_metrics
451 .iter()
452 .enumerate()
453 .find(|(_, metric)| metric.start_offset <= idx && idx < metric.end_offset)
454 .map(|(idx, _)| idx)
455 .unwrap_or_else(|| self.line_metrics.len() - 1);
456 let metric = self.line_metric(line_number).unwrap();
457
458 let hack_around_eol = self.is_rtl && idx == self.text.len();
462 let idx = if hack_around_eol {
463 idx.saturating_sub(1)
465 } else {
466 idx
467 };
468
469 let pos_rect = self.pango_layout.index_to_pos(idx as i32);
470 let x = if hack_around_eol {
471 pos_rect.x() + pos_rect.width()
472 } else {
473 pos_rect.x()
474 };
475
476 let point = Point::new(
477 (x as f64 / PANGO_SCALE) - self.pango_offset.x,
478 (pos_rect.y() as f64 / PANGO_SCALE) + metric.baseline - self.pango_offset.y,
479 );
480
481 HitTestPosition::new(point, line_number)
482 }
483}
484
485impl CairoTextLayout {
486 pub(crate) fn pango_layout(&self) -> &PangoLayout {
487 &self.pango_layout
488 }
489
490 pub(crate) fn pango_offset(&self) -> Vec2 {
491 self.pango_offset
492 }
493
494 fn update_width(&mut self, new_width: impl Into<Option<f64>>) {
495 let new_width = new_width
496 .into()
497 .map(|w| pango::SCALE.saturating_mul(w as i32))
498 .unwrap_or(UNBOUNDED_WRAP_WIDTH);
499 self.pango_layout.set_width(new_width);
500
501 let mut line_metrics = Vec::new();
502 let mut x_offsets = Vec::new();
503 let mut y_offset = 0.;
504 let mut widest_logical_width = 0;
505 let mut widest_whitespaceless_width = 0;
506 let mut iterator = self.pango_layout.iter();
507 loop {
508 let line = iterator.line_readonly().unwrap();
509
510 let start_offset: usize = line.start_index().try_into().unwrap();
511 let length: usize = line.length().try_into().unwrap();
512 let end_offset = start_offset + length;
513
514 let end_offset = match self.text.as_bytes()[end_offset..] {
516 [b'\r', b'\n', ..] => end_offset + 2,
517 [b'\r', ..] | [b'\n', ..] => end_offset + 1,
518 _ => end_offset,
519 };
520
521 let logical_rect = iterator.line_extents().1;
522 if logical_rect.width() > widest_logical_width {
523 widest_logical_width = logical_rect.width();
524 }
525
526 let line_text = &self.text[start_offset..end_offset];
527 let trimmed_len = line_text.trim_end().len();
528 let trailing_whitespace = line_text[trimmed_len..].len();
529
530 let non_ws_width = if trailing_whitespace != 0 && !self.is_rtl {
532 line.index_to_x((start_offset + trimmed_len) as i32, false)
534 } else {
535 logical_rect.width()
536 };
537 widest_whitespaceless_width = widest_whitespaceless_width.max(non_ws_width);
538
539 x_offsets.push(logical_rect.x());
540 line_metrics.push(LineMetric {
541 start_offset,
542 end_offset,
543 trailing_whitespace,
544 baseline: (iterator.baseline() as f64 / PANGO_SCALE) - y_offset,
545 height: logical_rect.height() as f64 / PANGO_SCALE,
546 y_offset,
547 });
548 y_offset += logical_rect.height() as f64 / PANGO_SCALE;
549
550 if !iterator.next_line() {
551 break;
552 }
553 }
554
555 self.line_metrics = line_metrics.into();
557 self.x_offsets = x_offsets.into();
558
559 let (ink_extent, logical_extent) = self.pango_layout.extents();
560 let ink_extent = to_kurbo_rect(ink_extent);
561 let logical_extent = to_kurbo_rect(logical_extent);
562
563 self.size = Size::new(
564 widest_whitespaceless_width as f64 / PANGO_SCALE,
565 logical_extent.height(),
566 );
567
568 self.ink_rect = ink_extent;
569 self.pango_offset = logical_extent.origin().to_vec2();
570 self.trailing_ws_width = widest_logical_width as f64 / PANGO_SCALE;
571 }
572}
573
574fn to_kurbo_rect(r: pango::Rectangle) -> Rect {
575 Rect::from_origin_size(
576 (r.x() as f64 / PANGO_SCALE, r.y() as f64 / PANGO_SCALE),
577 (
578 r.width() as f64 / PANGO_SCALE,
579 r.height() as f64 / PANGO_SCALE,
580 ),
581 )
582}
583
584impl fmt::Debug for CairoTextLayout {
585 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
586 f.debug_struct("CairoTextLayout").finish()
587 }
588}
589
590#[cfg(test)]
591mod test {
592 use super::*;
593 use piet::TextLayout;
594
595 macro_rules! assert_close {
596 ($val:expr, $target:expr, $tolerance:expr) => {{
597 let min = $target - $tolerance;
598 let max = $target + $tolerance;
599 if $val < min || $val > max {
600 panic!(
601 "value {} outside target {} with tolerance {}",
602 $val, $target, $tolerance
603 );
604 }
605 }};
606
607 ($val:expr, $target:expr, $tolerance:expr,) => {{ assert_close!($val, $target, $tolerance) }};
608 }
609
610 #[test]
611 #[allow(clippy::float_cmp)]
612 fn hit_test_empty_string() {
613 let layout = CairoText::new().new_text_layout("").build().unwrap();
614 let pt = layout.hit_test_point(Point::new(0.0, 0.0));
615 assert_eq!(pt.idx, 0);
616 let pos = layout.hit_test_text_position(0);
617 assert_eq!(pos.point.x, 0.0);
618 assert_close!(pos.point.y, 10.0, 3.0);
619 let line = layout.line_metric(0).unwrap();
620 assert_close!(line.height, 12.0, 3.0);
621 }
622
623 #[test]
624 #[cfg(any(target_os = "linux", target_os = "openbsd"))]
625 fn test_hit_test_point_complex_1() {
626 let input = "tßßypi";
631
632 let mut text_layout = CairoText::new();
633 let layout = text_layout.new_text_layout(input).build().unwrap();
634 println!("text pos 0: {:?}", layout.hit_test_text_position(0)); println!("text pos 1: {:?}", layout.hit_test_text_position(1)); println!("text pos 3: {:?}", layout.hit_test_text_position(3)); println!("text pos 5: {:?}", layout.hit_test_text_position(5)); println!("text pos 6: {:?}", layout.hit_test_text_position(6)); println!("text pos 7: {:?}", layout.hit_test_text_position(7)); println!("text pos 8: {:?}", layout.hit_test_text_position(8)); let pt = layout.hit_test_point(Point::new(27.0, 0.0));
643 assert_eq!(pt.idx, 6);
644 }
645
646 #[test]
647 #[cfg(target_os = "macos")]
648 fn test_hit_test_point_complex_1() {
649 let input = "tßßypi";
654
655 let mut text_layout = CairoText::new();
656 let layout = text_layout.new_text_layout(input).build().unwrap();
657 println!("text pos 0: {:?}", layout.hit_test_text_position(0)); println!("text pos 1: {:?}", layout.hit_test_text_position(1)); println!("text pos 3: {:?}", layout.hit_test_text_position(3)); println!("text pos 5: {:?}", layout.hit_test_text_position(5)); println!("text pos 6: {:?}", layout.hit_test_text_position(6)); println!("text pos 7: {:?}", layout.hit_test_text_position(7)); println!("text pos 8: {:?}", layout.hit_test_text_position(8)); let pt = layout.hit_test_point(Point::new(27.0, 0.0));
666 assert_eq!(pt.idx, 6);
667 }
668}