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        // per-row justification (R's hjust / vjust aesthetics); a missing
145        // value keeps the geom's own
146        let num = |name: &str, i: usize, dflt: f64| {
147            data.column(name)
148                .and_then(|c| c[i].as_f64())
149                .filter(|v| v.is_finite())
150                .unwrap_or(dflt)
151        };
152
153        let plot_area = backend.plot_area();
154        let xm = super::support::XMapper::new(data, scales);
155        let y_scale = scales.get(&Aesthetic::Y);
156
157        let mut drawn_bboxes: Vec<(f64, f64, f64, f64)> = Vec::new();
158
159        for i in 0..data.nrows() {
160            let (hjust, vjust) = (num("hjust", i, self.hjust), num("vjust", i, self.vjust));
161            // #221: `Aes::class`/`group_wrap`/`data_attr` per row.
162            super::set_row_extras(backend, data, i);
163            // Text geoms don't consume tooltips (the build warns if one is
164            // mapped) — keep the label box `<title>`-free as before.
165            backend.set_tooltip(None);
166            let nx = xm.map(&x_col[i], i);
167            let ny = y_scale.map(|s| s.map(&y_col[i])).unwrap_or(0.0);
168            let (px, py) = coord.transform((nx, ny), &plot_area);
169
170            // #228: a mapped `Aes::fontface` wins over the fixed setting.
171            let face = fontface_col
172                .map(|c| &c[i])
173                .filter(|v| !v.is_na())
174                .and_then(|v| super::parse_fontface(&v.to_group_key()))
175                .unwrap_or(self.fontface);
176
177            let text = label_col[i].to_group_key();
178            // #249: an embedded `"\n"` is a forced line break, the same
179            // `geom_label` behaviour since #229 — split on it *before* any
180            // width-based wrapping, so `"a\nb"` always draws two lines
181            // instead of one `<text>` with a literal raw newline inside it
182            // (SVG has no visual effect for that; most renderers just
183            // collapse it to whitespace). `str::lines()` handles "\r\n"
184            // too; an empty label keeps the single empty line the old code
185            // already drew.
186            let paragraphs: Vec<String> = if text.is_empty() {
187                vec![text]
188            } else {
189                text.lines().map(str::to_string).collect()
190            };
191            let lines = match self.wrap_width {
192                Some(w) => paragraphs
193                    .iter()
194                    .flat_map(|p| wrap_text(p, w, self.size))
195                    .collect::<Vec<_>>(),
196                None => paragraphs,
197            };
198            let lh = self.size * self.line_height;
199            let n = lines.len() as f64;
200            // The backend centres text vertically (dominant-baseline=middle), so
201            // apply vjust as a baseline shift over the whole wrapped block: 0 =
202            // bottom-anchored (block above the point), 0.5 = centred, 1 =
203            // top-anchored (block below). y grows down. An unwrapped single line
204            // (the pre-#176 case, `lines.len() == 1`) keeps the exact pre-#176
205            // `(vjust - 0.5) * size` shift — `line_height` only matters once a
206            // label actually wraps, so an un-wrapped plot's output is unchanged.
207            let block_h = if n <= 1.0 { self.size } else { lh * n };
208            let py_block = py + (vjust - 0.5) * block_h;
209            // hjust outside [0, 1] moves the text by that share of its own
210            // width beyond the anchored edge (R: `hjust = -0.1` sits a tenth
211            // of its width right of the point)
212            let w_text = wrapped_block_width(&lines, self.size);
213            let px = px
214                + if hjust < 0.0 {
215                    -hjust * w_text
216                } else if hjust > 1.0 {
217                    -(hjust - 1.0) * w_text
218                } else {
219                    0.0
220                };
221
222            if self.check_overlap {
223                let w = wrapped_block_width(&lines, self.size);
224                let bbox = (
225                    px - w / 2.0,
226                    py_block - block_h / 2.0,
227                    px + w / 2.0,
228                    py_block + block_h / 2.0,
229                );
230                if bboxes_overlap(&bbox, &drawn_bboxes) {
231                    continue;
232                }
233                drawn_bboxes.push(bbox);
234            }
235
236            let anchor = hjust_to_anchor(hjust);
237            let family = super::resolve_font_family(&self.fontfamily, theme);
238            let top = py_block - block_h / 2.0;
239            for (li, line) in lines.iter().enumerate() {
240                // n == 1 draws at py_block directly (byte-identical to pre-#176
241                // single-line output); n > 1 lays lines out by `lh` within the
242                // block computed above.
243                let line_y = if n <= 1.0 {
244                    py_block
245                } else {
246                    top + lh * (li as f64 + 0.5)
247                };
248                backend.draw_text(
249                    line,
250                    (px, line_y),
251                    &TextStyle {
252                        color: self.color,
253                        size: self.size,
254                        anchor: anchor.clone(),
255                        angle: 0.0,
256                        family: family.clone(),
257                        face,
258                        bg_colour: self.bg_colour,
259                        bg_r: self.bg_r,
260                    },
261                )?;
262            }
263        }
264        super::clear_row_extras(backend);
265
266        Ok(())
267    }
268
269    fn required_aes(&self) -> Vec<Aesthetic> {
270        vec![Aesthetic::X, Aesthetic::Y, Aesthetic::Label]
271    }
272
273    fn default_stat(&self) -> Box<dyn Stat> {
274        Box::new(StatIdentity)
275    }
276    fn default_position(&self) -> Box<dyn Position> {
277        Box::new(PositionIdentity)
278    }
279    fn default_params(&self) -> GeomParams {
280        GeomParams::default()
281    }
282    fn name(&self) -> &str {
283        "text"
284    }
285    fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
286        if let (true, Some(ink)) = (self.color == (0, 0, 0), el.ink) {
287            self.color = ink;
288        }
289    }
290}
291
292/// Label geometry — like text but with a background rectangle.
293pub struct GeomLabel {
294    pub size: f64,
295    pub color: (u8, u8, u8),
296    pub fill: (u8, u8, u8),
297    pub alpha: f64,
298    pub padding: f64,
299    /// Horizontal justification: 0.0 = left, 0.5 = center (default), 1.0 = right.
300    pub hjust: f64,
301    /// Vertical justification: 0.0 = bottom, 0.5 = middle (default), 1.0 = top.
302    pub vjust: f64,
303    /// Font family name (informational; actual rendering depends on backend).
304    pub fontfamily: String,
305    /// When true, skip drawing labels that overlap previously drawn labels.
306    pub check_overlap: bool,
307    /// Word-wrap width in px (ggtext / `str_wrap` behaviour), measured with
308    /// the theme's text metrics ([`crate::render::text::text_width`]). The
309    /// background box grows to fit however many lines result, plus
310    /// `padding`. `None` (the default) never wraps — unchanged from
311    /// pre-#176 behaviour.
312    pub wrap_width: Option<f64>,
313    /// Line height as a multiple of `size`, applied between wrapped lines.
314    /// Only matters when `wrap_width` is set and the label wraps to more
315    /// than one line.
316    pub line_height: f64,
317    /// Corner radius in px for the background box (ggplot2
318    /// `geom_label(label.r)`, #176). `0.0` (the default) is a plain
319    /// rectangle, unchanged from pre-#176 behaviour.
320    pub radius: f64,
321    /// Font face (#228, ggplot2's `fontface`): bold/italic, same semantics
322    /// and enum as [`crate::geom::repel::GeomLabelRepel::fontface`]. A
323    /// row's mapped `Aes::fontface` (if valid) wins over this fixed
324    /// setting.
325    pub fontface: FontFace,
326}
327
328impl GeomLabel {
329    /// Fixed font face (#228); a mapped `Aes::fontface` wins per row.
330    pub fn with_fontface(mut self, face: FontFace) -> Self {
331        self.fontface = face;
332        self
333    }
334    pub fn with_hjust(mut self, hjust: f64) -> Self {
335        self.hjust = hjust;
336        self
337    }
338
339    pub fn with_vjust(mut self, vjust: f64) -> Self {
340        self.vjust = vjust;
341        self
342    }
343
344    pub fn with_fontfamily(mut self, family: &str) -> Self {
345        self.fontfamily = family.to_string();
346        self
347    }
348
349    pub fn with_check_overlap(mut self, check: bool) -> Self {
350        self.check_overlap = check;
351        self
352    }
353
354    /// Word-wrap the label to lines no wider than `width_px`, growing the
355    /// background box to fit (#176).
356    pub fn with_wrap(mut self, width_px: f64) -> Self {
357        self.wrap_width = Some(width_px);
358        self
359    }
360
361    /// Line height as a multiple of `size` for wrapped text (default `1.2`).
362    pub fn with_line_height(mut self, line_height: f64) -> Self {
363        self.line_height = line_height;
364        self
365    }
366
367    /// Corner radius in px for the background box (ggplot2 `label.r`, #176).
368    pub fn with_radius(mut self, radius_px: f64) -> Self {
369        self.radius = radius_px;
370        self
371    }
372}
373
374impl Default for GeomLabel {
375    fn default() -> Self {
376        GeomLabel {
377            size: 10.0,
378            color: (0, 0, 0),
379            fill: (255, 255, 255),
380            alpha: 0.8,
381            padding: 3.0,
382            hjust: 0.5,
383            vjust: 0.5,
384            fontfamily: String::new(),
385            check_overlap: false,
386            wrap_width: None,
387            line_height: 1.2,
388            radius: 0.0,
389            fontface: FontFace::Plain,
390        }
391    }
392}
393
394impl Geom for GeomLabel {
395    fn draw(
396        &self,
397        data: &DataFrame,
398        coord: &dyn Coord,
399        scales: &ScaleSet,
400        theme: &Theme,
401        backend: &mut dyn DrawBackend,
402    ) -> Result<(), RenderError> {
403        let x_col = data
404            .column("x")
405            .ok_or(RenderError::MissingAesthetic("x".into()))?;
406        let y_col = data
407            .column("y")
408            .ok_or(RenderError::MissingAesthetic("y".into()))?;
409        let label_col = data
410            .column("label")
411            .ok_or(RenderError::MissingAesthetic("label".into()))?;
412        let fontface_col = data.column("fontface");
413
414        let plot_area = backend.plot_area();
415        let xm = super::support::XMapper::new(data, scales);
416        let y_scale = scales.get(&Aesthetic::Y);
417
418        let mut drawn_bboxes: Vec<(f64, f64, f64, f64)> = Vec::new();
419
420        for i in 0..data.nrows() {
421            // #221: `Aes::class`/`group_wrap`/`data_attr` per row.
422            super::set_row_extras(backend, data, i);
423            // Text geoms don't consume tooltips (the build warns if one is
424            // mapped) — keep the label box `<title>`-free as before.
425            backend.set_tooltip(None);
426            let nx = xm.map(&x_col[i], i);
427            let ny = y_scale.map(|s| s.map(&y_col[i])).unwrap_or(0.0);
428            let (px, py) = coord.transform((nx, ny), &plot_area);
429
430            // #228: a mapped `Aes::fontface` wins over the fixed setting.
431            let face = fontface_col
432                .map(|c| &c[i])
433                .filter(|v| !v.is_na())
434                .and_then(|v| super::parse_fontface(&v.to_group_key()))
435                .unwrap_or(self.fontface);
436
437            let text = label_col[i].to_group_key();
438            // #229: an embedded `"\n"` is a forced line break (ggplot2's
439            // `geom_label` behaviour) — split on it *before* any
440            // width-based wrapping, so `"a\nb"` always draws two lines
441            // inside one box, never one box with a literal raw newline
442            // sitting unbroken inside the `<text>` (SVG has no visual
443            // effect for that; most renderers just collapse it to
444            // whitespace). `str::lines()` handles "\r\n" too; an empty
445            // label keeps the single empty line the old code already drew.
446            let text_len = text.len();
447            let paragraphs: Vec<String> = if text.is_empty() {
448                vec![text]
449            } else {
450                text.lines().map(str::to_string).collect()
451            };
452            let (lines, half_w) = match self.wrap_width {
453                Some(w) => {
454                    let lines = paragraphs
455                        .iter()
456                        .flat_map(|p| wrap_text(p, w, self.size))
457                        .collect::<Vec<_>>();
458                    let half_w = wrapped_block_width(&lines, self.size) / 2.0 + self.padding;
459                    (lines, half_w)
460                }
461                // No `wrap_width` and no forced break (the overwhelming
462                // common case): keep the exact pre-#176 box width, a
463                // crude char-count estimate, not the real `text_width`
464                // metric `with_wrap` uses below — changing it would shift
465                // every existing single-line `geom_label()`'s box width
466                // with no opt-in.
467                None if paragraphs.len() <= 1 => {
468                    let half_w = text_len as f64 * self.size * 0.6 / 2.0 + self.padding;
469                    (paragraphs, half_w)
470                }
471                // A forced multi-line label with no explicit wrap: there's
472                // no old behaviour to preserve (a `"\n"` here used to
473                // produce one wrongly-sized box with unbroken text, a
474                // bug — see above), so size the box to fit every line
475                // with the real metric, like the `Some(w)` branch.
476                None => {
477                    let half_w = wrapped_block_width(&paragraphs, self.size) / 2.0 + self.padding;
478                    (paragraphs, half_w)
479                }
480            };
481            let lh = self.size * self.line_height;
482            let n = lines.len() as f64;
483            // See GeomText::draw for why n == 1 keeps the exact pre-#176
484            // `size`-based box height and `(vjust - 0.5) * size` shift.
485            let block_h = if n <= 1.0 { self.size } else { lh * n };
486            let py_block = py + (self.vjust - 0.5) * block_h;
487            let half_h = block_h / 2.0 + self.padding;
488
489            if self.check_overlap {
490                let bbox = (
491                    px - half_w,
492                    py_block - half_h,
493                    px + half_w,
494                    py_block + half_h,
495                );
496                if bboxes_overlap(&bbox, &drawn_bboxes) {
497                    continue;
498                }
499                drawn_bboxes.push(bbox);
500            }
501
502            // Background rect, sized to fit the wrapped lines + padding, with
503            // its corners rounded by `radius` (#176's `label.r`).
504            backend.draw_rect(
505                (px - half_w, py_block - half_h),
506                (px + half_w, py_block + half_h),
507                &RectStyle {
508                    fill: Some(self.fill),
509                    stroke: Some(self.color),
510                    stroke_width: 0.5,
511                    alpha: self.alpha,
512                    clip: true,
513                    corner_radius: self.radius,
514                },
515            )?;
516
517            // Text, one draw_text call per wrapped line.
518            let anchor = hjust_to_anchor(self.hjust);
519            let family = super::resolve_font_family(&self.fontfamily, theme);
520            let top = py_block - block_h / 2.0;
521            for (li, line) in lines.iter().enumerate() {
522                let line_y = if n <= 1.0 {
523                    py_block
524                } else {
525                    top + lh * (li as f64 + 0.5)
526                };
527                backend.draw_text(
528                    line,
529                    (px, line_y),
530                    &TextStyle {
531                        color: self.color,
532                        size: self.size,
533                        anchor: anchor.clone(),
534                        angle: 0.0,
535                        family: family.clone(),
536                        face,
537                        bg_colour: None,
538                        bg_r: 0.0,
539                    },
540                )?;
541            }
542        }
543        super::clear_row_extras(backend);
544
545        Ok(())
546    }
547
548    fn required_aes(&self) -> Vec<Aesthetic> {
549        vec![Aesthetic::X, Aesthetic::Y, Aesthetic::Label]
550    }
551
552    fn default_stat(&self) -> Box<dyn Stat> {
553        Box::new(StatIdentity)
554    }
555    fn default_position(&self) -> Box<dyn Position> {
556        Box::new(PositionIdentity)
557    }
558    fn default_params(&self) -> GeomParams {
559        GeomParams::default()
560    }
561    fn name(&self) -> &str {
562        "label"
563    }
564    fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
565        if let (true, Some(ink)) = (self.color == (0, 0, 0), el.ink) {
566            self.color = ink;
567        }
568        if let (true, Some(paper)) = (self.fill == (255, 255, 255), el.paper) {
569            self.fill = paper;
570        }
571    }
572}
573
574/// Map hjust (0.0 = left, 0.5 = center, 1.0 = right) to TextAnchor.
575fn hjust_to_anchor(hjust: f64) -> TextAnchor {
576    if hjust < 0.25 {
577        TextAnchor::Start
578    } else if hjust > 0.75 {
579        TextAnchor::End
580    } else {
581        TextAnchor::Middle
582    }
583}
584
585/// Check if a bbox overlaps any existing bbox.
586fn bboxes_overlap(candidate: &(f64, f64, f64, f64), existing: &[(f64, f64, f64, f64)]) -> bool {
587    for b in existing {
588        // Two rects overlap if they overlap on both axes
589        if candidate.0 < b.2 && candidate.2 > b.0 && candidate.1 < b.3 && candidate.3 > b.1 {
590            return true;
591        }
592    }
593    false
594}