piet_cairo/
text.rs

1// Copyright 2019 the Piet Authors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Text functionality for Piet cairo backend
5
6use 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>>, //No range == entire layout
63}
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                /*
71                 * NOTE: If the family fails to resolve we just don't apply the attribute.
72                 * That allows Pango to use its default font of choice to render that text
73                 */
74                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                //This is horrid
84                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; //Random very large value
102                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    /// Create a new factory that satisfies the piet `Text` trait.
156    #[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        //TODO: Verify that a family exists with the requested name
171        Some(FontFamily::new_unchecked(family_name))
172    }
173
174    fn load_font(&mut self, _data: &[u8]) -> Result<FontFamily, Error> {
175        /*
176         * NOTE(ForLoveOfCats): It does not appear that Pango natively supports loading font
177         * data raw. All online resource I've seen so far point to registering fonts with
178         * fontconfig and then letting Pango grab it from there but they all assume you have
179         * a font file path which we do not have here.
180         * See: https://gitlab.freedesktop.org/fontconfig/fontconfig/-/issues/12
181         */
182        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        /*
219         * NOTE: Pango has `auto_dir` enabled by default. This means that
220         * when it encounters a paragraph starting with a left-to-right
221         * character the meanings of `Left` and `Right` are switched for
222         * that paragraph. As a result the meaning of Piet's own `Start`
223         * and `End` are preserved
224         *
225         * See: http://gtk-rs.org/docs/pango/struct.Layout.html#method.set_auto_dir
226         */
227
228        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        // invalid until update_width() is called
343        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            // if we're past the last line, use the last line
405            .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        // in RTL text, pango mishandles the very last position in the layout
459        // https://gitlab.gnome.org/GNOME/pango/-/issues/544
460
461        let hack_around_eol = self.is_rtl && idx == self.text.len();
462        let idx = if hack_around_eol {
463            // pango doesn't care if this is a char boundary
464            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            // Pango likes to give us the line range *without* the newline char(s).
515            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            //HACK: This check for RTL is to work around https://gitlab.gnome.org/GNOME/pango/-/issues/544
531            let non_ws_width = if trailing_whitespace != 0 && !self.is_rtl {
532                //FIXME: this probably isn't correct for RTL
533                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        //NOTE: Pango appears to always give us at least one line even with empty input
556        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        // this input caused an infinite loop in the binary search when test position
627        // > 21.0 && < 28.0
628        //
629        // This corresponds to the char 'y' in the input.
630        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)); // 0.0
635        println!("text pos 1: {:?}", layout.hit_test_text_position(1)); // 5.0
636        println!("text pos 3: {:?}", layout.hit_test_text_position(3)); // 13.0
637        println!("text pos 5: {:?}", layout.hit_test_text_position(5)); // 21.0
638        println!("text pos 6: {:?}", layout.hit_test_text_position(6)); // 28.0
639        println!("text pos 7: {:?}", layout.hit_test_text_position(7)); // 36.0
640        println!("text pos 8: {:?}", layout.hit_test_text_position(8)); // 39.0, end
641
642        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        // this input caused an infinite loop in the binary search when test position
650        // > 21.0 && < 28.0
651        //
652        // This corresponds to the char 'y' in the input.
653        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)); // 0.0
658        println!("text pos 1: {:?}", layout.hit_test_text_position(1)); // 5.0
659        println!("text pos 3: {:?}", layout.hit_test_text_position(3)); // 13.0
660        println!("text pos 5: {:?}", layout.hit_test_text_position(5)); // 21.0
661        println!("text pos 6: {:?}", layout.hit_test_text_position(6)); // 28.0
662        println!("text pos 7: {:?}", layout.hit_test_text_position(7)); // 36.0
663        println!("text pos 8: {:?}", layout.hit_test_text_position(8)); // 39.0, end
664
665        let pt = layout.hit_test_point(Point::new(27.0, 0.0));
666        assert_eq!(pt.idx, 6);
667    }
668}