Skip to main content

ggplot_rs/geom/
text.rs

1use crate::aes::Aesthetic;
2use crate::coord::Coord;
3use crate::data::DataFrame;
4use crate::position::identity::PositionIdentity;
5use crate::position::Position;
6use crate::render::backend::{DrawBackend, FontFace, RectStyle, TextAnchor, TextStyle};
7use crate::render::text::{wrap_text, wrapped_block_width};
8use crate::render::RenderError;
9use crate::scale::ScaleSet;
10use crate::stat::identity::StatIdentity;
11use crate::stat::Stat;
12use crate::theme::Theme;
13
14use super::{Geom, GeomParams};
15
16/// Text geometry — draws text labels at data positions.
17pub struct GeomText {
18    pub size: f64,
19    pub color: (u8, u8, u8),
20    pub alpha: f64,
21    /// Horizontal justification: 0.0 = left, 0.5 = center (default), 1.0 = right.
22    pub hjust: f64,
23    /// Vertical justification: 0.0 = bottom, 0.5 = middle (default), 1.0 = top.
24    pub vjust: f64,
25    /// Font family name (informational; actual rendering depends on backend).
26    pub fontfamily: String,
27    /// When true, skip drawing labels that overlap previously drawn labels.
28    pub check_overlap: bool,
29    /// Word-wrap width in px (ggtext / `str_wrap` behaviour), measured with
30    /// the theme's text metrics ([`crate::render::text::text_width`]).
31    /// `None` (the default) never wraps — unchanged from pre-#176 behaviour.
32    pub wrap_width: Option<f64>,
33    /// Line height as a multiple of `size`, applied between wrapped lines
34    /// (CSS-style; ggplot2's `lineheight` is the same idea). Only matters
35    /// when `wrap_width` is set and the label wraps to more than one line.
36    pub line_height: f64,
37    /// Font face (#228, ggplot2's `fontface`): bold/italic, same semantics
38    /// and enum as [`crate::geom::repel::GeomTextRepel::fontface`]. A row's
39    /// mapped `Aes::fontface` (if valid) wins over this fixed setting.
40    pub fontface: FontFace,
41    /// Text halo colour (#229, ggrepel's `bg.colour`/`bg.r`; `shadowtext`'s
42    /// `bg.col`/`bg.r`): an outline in this colour behind the text, so a
43    /// label placed directly on a line or a busy background stays legible
44    /// without a box. `None` (the default) draws no halo — set together
45    /// with [`bg_r`](Self::bg_r), which is `0.0` (no halo) until set.
46    pub bg_colour: Option<(u8, u8, u8)>,
47    /// Text halo radius in px (#229, ggrepel's `bg.r`). Only matters once
48    /// [`bg_colour`](Self::bg_colour) is set.
49    pub bg_r: f64,
50}
51
52impl GeomText {
53    /// Fixed font face (#228); a mapped `Aes::fontface` wins per row.
54    pub fn with_fontface(mut self, face: FontFace) -> Self {
55        self.fontface = face;
56        self
57    }
58
59    /// Text halo colour (#229, ggrepel's `bg.colour`) — set
60    /// [`bg_r`](Self::bg_r) too, or the halo has zero width and draws
61    /// nothing.
62    pub fn bg_colour(mut self, colour: (u8, u8, u8)) -> Self {
63        self.bg_colour = Some(colour);
64        self
65    }
66
67    /// Text halo radius in px (#229, ggrepel's `bg.r`).
68    pub fn bg_r(mut self, r: f64) -> Self {
69        self.bg_r = r;
70        self
71    }
72
73    pub fn with_hjust(mut self, hjust: f64) -> Self {
74        self.hjust = hjust;
75        self
76    }
77
78    pub fn with_vjust(mut self, vjust: f64) -> Self {
79        self.vjust = vjust;
80        self
81    }
82
83    pub fn with_fontfamily(mut self, family: &str) -> Self {
84        self.fontfamily = family.to_string();
85        self
86    }
87
88    pub fn with_check_overlap(mut self, check: bool) -> Self {
89        self.check_overlap = check;
90        self
91    }
92
93    /// Word-wrap the label to lines no wider than `width_px` (#176).
94    pub fn with_wrap(mut self, width_px: f64) -> Self {
95        self.wrap_width = Some(width_px);
96        self
97    }
98
99    /// Line height as a multiple of `size` for wrapped text (default `1.2`).
100    pub fn with_line_height(mut self, line_height: f64) -> Self {
101        self.line_height = line_height;
102        self
103    }
104}
105
106impl Default for GeomText {
107    fn default() -> Self {
108        GeomText {
109            size: 10.0,
110            color: (0, 0, 0),
111            alpha: 1.0,
112            hjust: 0.5,
113            vjust: 0.5,
114            fontfamily: String::new(),
115            check_overlap: false,
116            wrap_width: None,
117            line_height: 1.2,
118            fontface: FontFace::Plain,
119            bg_colour: None,
120            bg_r: 0.0,
121        }
122    }
123}
124
125impl Geom for GeomText {
126    fn draw(
127        &self,
128        data: &DataFrame,
129        coord: &dyn Coord,
130        scales: &ScaleSet,
131        theme: &Theme,
132        backend: &mut dyn DrawBackend,
133    ) -> Result<(), RenderError> {
134        let x_col = data
135            .column("x")
136            .ok_or(RenderError::MissingAesthetic("x".into()))?;
137        let y_col = data
138            .column("y")
139            .ok_or(RenderError::MissingAesthetic("y".into()))?;
140        let label_col = data
141            .column("label")
142            .ok_or(RenderError::MissingAesthetic("label".into()))?;
143        let fontface_col = data.column("fontface");
144
145        let plot_area = backend.plot_area();
146        let xm = super::support::XMapper::new(data, scales);
147        let y_scale = scales.get(&Aesthetic::Y);
148
149        let mut drawn_bboxes: Vec<(f64, f64, f64, f64)> = Vec::new();
150
151        for i in 0..data.nrows() {
152            // #221: `Aes::class`/`group_wrap`/`data_attr` per row.
153            super::set_row_extras(backend, data, i);
154            // Text geoms don't consume tooltips (the build warns if one is
155            // mapped) — keep the label box `<title>`-free as before.
156            backend.set_tooltip(None);
157            let nx = xm.map(&x_col[i], i);
158            let ny = y_scale.map(|s| s.map(&y_col[i])).unwrap_or(0.0);
159            let (px, py) = coord.transform((nx, ny), &plot_area);
160
161            // #228: a mapped `Aes::fontface` wins over the fixed setting.
162            let face = fontface_col
163                .map(|c| &c[i])
164                .filter(|v| !v.is_na())
165                .and_then(|v| super::parse_fontface(&v.to_group_key()))
166                .unwrap_or(self.fontface);
167
168            let text = label_col[i].to_group_key();
169            let lines = match self.wrap_width {
170                Some(w) => wrap_text(&text, w, self.size),
171                None => vec![text],
172            };
173            let lh = self.size * self.line_height;
174            let n = lines.len() as f64;
175            // The backend centres text vertically (dominant-baseline=middle), so
176            // apply vjust as a baseline shift over the whole wrapped block: 0 =
177            // bottom-anchored (block above the point), 0.5 = centred, 1 =
178            // top-anchored (block below). y grows down. An unwrapped single line
179            // (the pre-#176 case, `lines.len() == 1`) keeps the exact pre-#176
180            // `(vjust - 0.5) * size` shift — `line_height` only matters once a
181            // label actually wraps, so an un-wrapped plot's output is unchanged.
182            let block_h = if n <= 1.0 { self.size } else { lh * n };
183            let py_block = py + (self.vjust - 0.5) * block_h;
184
185            if self.check_overlap {
186                let w = wrapped_block_width(&lines, self.size);
187                let bbox = (
188                    px - w / 2.0,
189                    py_block - block_h / 2.0,
190                    px + w / 2.0,
191                    py_block + block_h / 2.0,
192                );
193                if bboxes_overlap(&bbox, &drawn_bboxes) {
194                    continue;
195                }
196                drawn_bboxes.push(bbox);
197            }
198
199            let anchor = hjust_to_anchor(self.hjust);
200            let family = super::resolve_font_family(&self.fontfamily, theme);
201            let top = py_block - block_h / 2.0;
202            for (li, line) in lines.iter().enumerate() {
203                // n == 1 draws at py_block directly (byte-identical to pre-#176
204                // single-line output); n > 1 lays lines out by `lh` within the
205                // block computed above.
206                let line_y = if n <= 1.0 {
207                    py_block
208                } else {
209                    top + lh * (li as f64 + 0.5)
210                };
211                backend.draw_text(
212                    line,
213                    (px, line_y),
214                    &TextStyle {
215                        color: self.color,
216                        size: self.size,
217                        anchor: anchor.clone(),
218                        angle: 0.0,
219                        family: family.clone(),
220                        face,
221                        bg_colour: self.bg_colour,
222                        bg_r: self.bg_r,
223                    },
224                )?;
225            }
226        }
227        super::clear_row_extras(backend);
228
229        Ok(())
230    }
231
232    fn required_aes(&self) -> Vec<Aesthetic> {
233        vec![Aesthetic::X, Aesthetic::Y, Aesthetic::Label]
234    }
235
236    fn default_stat(&self) -> Box<dyn Stat> {
237        Box::new(StatIdentity)
238    }
239    fn default_position(&self) -> Box<dyn Position> {
240        Box::new(PositionIdentity)
241    }
242    fn default_params(&self) -> GeomParams {
243        GeomParams::default()
244    }
245    fn name(&self) -> &str {
246        "text"
247    }
248    fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
249        if let (true, Some(ink)) = (self.color == (0, 0, 0), el.ink) {
250            self.color = ink;
251        }
252    }
253}
254
255/// Label geometry — like text but with a background rectangle.
256pub struct GeomLabel {
257    pub size: f64,
258    pub color: (u8, u8, u8),
259    pub fill: (u8, u8, u8),
260    pub alpha: f64,
261    pub padding: f64,
262    /// Horizontal justification: 0.0 = left, 0.5 = center (default), 1.0 = right.
263    pub hjust: f64,
264    /// Vertical justification: 0.0 = bottom, 0.5 = middle (default), 1.0 = top.
265    pub vjust: f64,
266    /// Font family name (informational; actual rendering depends on backend).
267    pub fontfamily: String,
268    /// When true, skip drawing labels that overlap previously drawn labels.
269    pub check_overlap: bool,
270    /// Word-wrap width in px (ggtext / `str_wrap` behaviour), measured with
271    /// the theme's text metrics ([`crate::render::text::text_width`]). The
272    /// background box grows to fit however many lines result, plus
273    /// `padding`. `None` (the default) never wraps — unchanged from
274    /// pre-#176 behaviour.
275    pub wrap_width: Option<f64>,
276    /// Line height as a multiple of `size`, applied between wrapped lines.
277    /// Only matters when `wrap_width` is set and the label wraps to more
278    /// than one line.
279    pub line_height: f64,
280    /// Corner radius in px for the background box (ggplot2
281    /// `geom_label(label.r)`, #176). `0.0` (the default) is a plain
282    /// rectangle, unchanged from pre-#176 behaviour.
283    pub radius: f64,
284    /// Font face (#228, ggplot2's `fontface`): bold/italic, same semantics
285    /// and enum as [`crate::geom::repel::GeomLabelRepel::fontface`]. A
286    /// row's mapped `Aes::fontface` (if valid) wins over this fixed
287    /// setting.
288    pub fontface: FontFace,
289}
290
291impl GeomLabel {
292    /// Fixed font face (#228); a mapped `Aes::fontface` wins per row.
293    pub fn with_fontface(mut self, face: FontFace) -> Self {
294        self.fontface = face;
295        self
296    }
297    pub fn with_hjust(mut self, hjust: f64) -> Self {
298        self.hjust = hjust;
299        self
300    }
301
302    pub fn with_vjust(mut self, vjust: f64) -> Self {
303        self.vjust = vjust;
304        self
305    }
306
307    pub fn with_fontfamily(mut self, family: &str) -> Self {
308        self.fontfamily = family.to_string();
309        self
310    }
311
312    pub fn with_check_overlap(mut self, check: bool) -> Self {
313        self.check_overlap = check;
314        self
315    }
316
317    /// Word-wrap the label to lines no wider than `width_px`, growing the
318    /// background box to fit (#176).
319    pub fn with_wrap(mut self, width_px: f64) -> Self {
320        self.wrap_width = Some(width_px);
321        self
322    }
323
324    /// Line height as a multiple of `size` for wrapped text (default `1.2`).
325    pub fn with_line_height(mut self, line_height: f64) -> Self {
326        self.line_height = line_height;
327        self
328    }
329
330    /// Corner radius in px for the background box (ggplot2 `label.r`, #176).
331    pub fn with_radius(mut self, radius_px: f64) -> Self {
332        self.radius = radius_px;
333        self
334    }
335}
336
337impl Default for GeomLabel {
338    fn default() -> Self {
339        GeomLabel {
340            size: 10.0,
341            color: (0, 0, 0),
342            fill: (255, 255, 255),
343            alpha: 0.8,
344            padding: 3.0,
345            hjust: 0.5,
346            vjust: 0.5,
347            fontfamily: String::new(),
348            check_overlap: false,
349            wrap_width: None,
350            line_height: 1.2,
351            radius: 0.0,
352            fontface: FontFace::Plain,
353        }
354    }
355}
356
357impl Geom for GeomLabel {
358    fn draw(
359        &self,
360        data: &DataFrame,
361        coord: &dyn Coord,
362        scales: &ScaleSet,
363        theme: &Theme,
364        backend: &mut dyn DrawBackend,
365    ) -> Result<(), RenderError> {
366        let x_col = data
367            .column("x")
368            .ok_or(RenderError::MissingAesthetic("x".into()))?;
369        let y_col = data
370            .column("y")
371            .ok_or(RenderError::MissingAesthetic("y".into()))?;
372        let label_col = data
373            .column("label")
374            .ok_or(RenderError::MissingAesthetic("label".into()))?;
375        let fontface_col = data.column("fontface");
376
377        let plot_area = backend.plot_area();
378        let xm = super::support::XMapper::new(data, scales);
379        let y_scale = scales.get(&Aesthetic::Y);
380
381        let mut drawn_bboxes: Vec<(f64, f64, f64, f64)> = Vec::new();
382
383        for i in 0..data.nrows() {
384            // #221: `Aes::class`/`group_wrap`/`data_attr` per row.
385            super::set_row_extras(backend, data, i);
386            // Text geoms don't consume tooltips (the build warns if one is
387            // mapped) — keep the label box `<title>`-free as before.
388            backend.set_tooltip(None);
389            let nx = xm.map(&x_col[i], i);
390            let ny = y_scale.map(|s| s.map(&y_col[i])).unwrap_or(0.0);
391            let (px, py) = coord.transform((nx, ny), &plot_area);
392
393            // #228: a mapped `Aes::fontface` wins over the fixed setting.
394            let face = fontface_col
395                .map(|c| &c[i])
396                .filter(|v| !v.is_na())
397                .and_then(|v| super::parse_fontface(&v.to_group_key()))
398                .unwrap_or(self.fontface);
399
400            let text = label_col[i].to_group_key();
401            // #229: an embedded `"\n"` is a forced line break (ggplot2's
402            // `geom_label` behaviour) — split on it *before* any
403            // width-based wrapping, so `"a\nb"` always draws two lines
404            // inside one box, never one box with a literal raw newline
405            // sitting unbroken inside the `<text>` (SVG has no visual
406            // effect for that; most renderers just collapse it to
407            // whitespace). `str::lines()` handles "\r\n" too; an empty
408            // label keeps the single empty line the old code already drew.
409            let text_len = text.len();
410            let paragraphs: Vec<String> = if text.is_empty() {
411                vec![text]
412            } else {
413                text.lines().map(str::to_string).collect()
414            };
415            let (lines, half_w) = match self.wrap_width {
416                Some(w) => {
417                    let lines = paragraphs
418                        .iter()
419                        .flat_map(|p| wrap_text(p, w, self.size))
420                        .collect::<Vec<_>>();
421                    let half_w = wrapped_block_width(&lines, self.size) / 2.0 + self.padding;
422                    (lines, half_w)
423                }
424                // No `wrap_width` and no forced break (the overwhelming
425                // common case): keep the exact pre-#176 box width, a
426                // crude char-count estimate, not the real `text_width`
427                // metric `with_wrap` uses below — changing it would shift
428                // every existing single-line `geom_label()`'s box width
429                // with no opt-in.
430                None if paragraphs.len() <= 1 => {
431                    let half_w = text_len as f64 * self.size * 0.6 / 2.0 + self.padding;
432                    (paragraphs, half_w)
433                }
434                // A forced multi-line label with no explicit wrap: there's
435                // no old behaviour to preserve (a `"\n"` here used to
436                // produce one wrongly-sized box with unbroken text, a
437                // bug — see above), so size the box to fit every line
438                // with the real metric, like the `Some(w)` branch.
439                None => {
440                    let half_w = wrapped_block_width(&paragraphs, self.size) / 2.0 + self.padding;
441                    (paragraphs, half_w)
442                }
443            };
444            let lh = self.size * self.line_height;
445            let n = lines.len() as f64;
446            // See GeomText::draw for why n == 1 keeps the exact pre-#176
447            // `size`-based box height and `(vjust - 0.5) * size` shift.
448            let block_h = if n <= 1.0 { self.size } else { lh * n };
449            let py_block = py + (self.vjust - 0.5) * block_h;
450            let half_h = block_h / 2.0 + self.padding;
451
452            if self.check_overlap {
453                let bbox = (
454                    px - half_w,
455                    py_block - half_h,
456                    px + half_w,
457                    py_block + half_h,
458                );
459                if bboxes_overlap(&bbox, &drawn_bboxes) {
460                    continue;
461                }
462                drawn_bboxes.push(bbox);
463            }
464
465            // Background rect, sized to fit the wrapped lines + padding, with
466            // its corners rounded by `radius` (#176's `label.r`).
467            backend.draw_rect(
468                (px - half_w, py_block - half_h),
469                (px + half_w, py_block + half_h),
470                &RectStyle {
471                    fill: Some(self.fill),
472                    stroke: Some(self.color),
473                    stroke_width: 0.5,
474                    alpha: self.alpha,
475                    clip: true,
476                    corner_radius: self.radius,
477                },
478            )?;
479
480            // Text, one draw_text call per wrapped line.
481            let anchor = hjust_to_anchor(self.hjust);
482            let family = super::resolve_font_family(&self.fontfamily, theme);
483            let top = py_block - block_h / 2.0;
484            for (li, line) in lines.iter().enumerate() {
485                let line_y = if n <= 1.0 {
486                    py_block
487                } else {
488                    top + lh * (li as f64 + 0.5)
489                };
490                backend.draw_text(
491                    line,
492                    (px, line_y),
493                    &TextStyle {
494                        color: self.color,
495                        size: self.size,
496                        anchor: anchor.clone(),
497                        angle: 0.0,
498                        family: family.clone(),
499                        face,
500                        bg_colour: None,
501                        bg_r: 0.0,
502                    },
503                )?;
504            }
505        }
506        super::clear_row_extras(backend);
507
508        Ok(())
509    }
510
511    fn required_aes(&self) -> Vec<Aesthetic> {
512        vec![Aesthetic::X, Aesthetic::Y, Aesthetic::Label]
513    }
514
515    fn default_stat(&self) -> Box<dyn Stat> {
516        Box::new(StatIdentity)
517    }
518    fn default_position(&self) -> Box<dyn Position> {
519        Box::new(PositionIdentity)
520    }
521    fn default_params(&self) -> GeomParams {
522        GeomParams::default()
523    }
524    fn name(&self) -> &str {
525        "label"
526    }
527    fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
528        if let (true, Some(ink)) = (self.color == (0, 0, 0), el.ink) {
529            self.color = ink;
530        }
531        if let (true, Some(paper)) = (self.fill == (255, 255, 255), el.paper) {
532            self.fill = paper;
533        }
534    }
535}
536
537/// Map hjust (0.0 = left, 0.5 = center, 1.0 = right) to TextAnchor.
538fn hjust_to_anchor(hjust: f64) -> TextAnchor {
539    if hjust < 0.25 {
540        TextAnchor::Start
541    } else if hjust > 0.75 {
542        TextAnchor::End
543    } else {
544        TextAnchor::Middle
545    }
546}
547
548/// Check if a bbox overlaps any existing bbox.
549fn bboxes_overlap(candidate: &(f64, f64, f64, f64), existing: &[(f64, f64, f64, f64)]) -> bool {
550    for b in existing {
551        // Two rects overlap if they overlap on both axes
552        if candidate.0 < b.2 && candidate.2 > b.0 && candidate.1 < b.3 && candidate.3 > b.1 {
553            return true;
554        }
555    }
556    false
557}