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