1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
//! Free-standing text: [`Text`], placed in figure fractions by
//! [`Figure::text`](crate::figure::Figure::text), and the alignment rule it
//! shares with matplotlib.
//!
//! matplotlib (3.11) aligns a string by a layout box: horizontally from the pen
//! origin to the right edge of the ink, vertically over the ink widened to at
//! least the font's typographic ascender and descender, so strings with and
//! without ascenders or descenders sit on a common line. [`Text`] reproduces
//! that box, rotates it, and aligns the rotated box's bounds to the anchor, as
//! matplotlib's default `rotation_mode` does.
use crate::core::{Affine2D, Bbox, color::Rgba};
use crate::figure::axes::{TextBoxStyle, rounded_rect_path};
use crate::figure::richtext::{RichText, layout_rich_text};
use crate::render::{GraphicsContext, Renderer, TextAnchor};
use crate::text::FontSource;
/// Font size of [`Figure::text`](crate::figure::Figure::text), in px at 100
/// DPI — the size axes text uses.
pub(crate) const DEFAULT_TEXT_SIZE: f64 = 10.0;
/// Horizontal alignment of text about its anchor — matplotlib's `ha`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HorizontalAlignment {
/// The text starts at the anchor (the default).
#[default]
Left,
/// The text is centered on the anchor.
Center,
/// The text ends at the anchor.
Right,
}
/// Vertical alignment of text about its anchor — matplotlib's `va`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VerticalAlignment {
/// The top of the text sits at the anchor.
Top,
/// The text is centered on the anchor.
Center,
/// The baseline sits at the anchor (the default).
#[default]
Baseline,
/// The bottom of the text, below any descenders, sits at the anchor.
Bottom,
}
/// A string placed at an anchor with matplotlib's alignment, rotation and
/// optional background box — what [`Figure::text`](crate::figure::Figure::text)
/// returns for styling.
///
/// `$...$` spans render as mathtext, as in titles and axes text.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Text {
/// The string drawn.
text: String,
/// Anchor position, in the owner's coordinates (figure fractions for
/// figure text).
x: f64,
y: f64,
/// Font size in px at 100 DPI.
size: f64,
/// Ink color.
color: Rgba,
/// Horizontal alignment about the anchor.
ha: HorizontalAlignment,
/// Vertical alignment about the anchor.
va: VerticalAlignment,
/// Counter-clockwise rotation in degrees.
rotation: f64,
/// Optional background box.
bbox: Option<TextBoxStyle>,
}
impl Text {
/// Text `s` anchored at `(x, y)`, left- and baseline-aligned, unrotated,
/// at `size` px (at 100 DPI) in `color`.
pub(crate) fn new(x: f64, y: f64, s: impl Into<String>, size: f64, color: Rgba) -> Self {
Self {
text: s.into(),
x,
y,
size,
color,
ha: HorizontalAlignment::Left,
va: VerticalAlignment::Baseline,
rotation: 0.0,
bbox: None,
}
}
/// The string drawn.
#[must_use]
pub fn text(&self) -> &str {
&self.text
}
/// The anchor position, in the owner's coordinates.
#[must_use]
pub fn position(&self) -> (f64, f64) {
(self.x, self.y)
}
/// Replace the string.
pub fn set_text(&mut self, s: impl Into<String>) -> &mut Self {
self.text = s.into();
self
}
/// Move the anchor, in the owner's coordinates.
pub fn set_position(&mut self, x: f64, y: f64) -> &mut Self {
self.x = x;
self.y = y;
self
}
/// Set the font size, in px at 100 DPI (scaling with the DPI like every
/// other decoration).
pub fn set_size(&mut self, size: f64) -> &mut Self {
self.size = size;
self
}
/// Set the ink color.
pub fn set_color(&mut self, color: Rgba) -> &mut Self {
self.color = color;
self
}
/// Set the horizontal alignment about the anchor — matplotlib's `ha`.
pub fn set_ha(&mut self, ha: HorizontalAlignment) -> &mut Self {
self.ha = ha;
self
}
/// Set the vertical alignment about the anchor — matplotlib's `va`.
pub fn set_va(&mut self, va: VerticalAlignment) -> &mut Self {
self.va = va;
self
}
/// Set the counter-clockwise rotation in degrees. The rotated text's
/// bounds are aligned to the anchor, as in matplotlib's default
/// `rotation_mode`.
pub fn set_rotation(&mut self, degrees: f64) -> &mut Self {
self.rotation = degrees;
self
}
/// Draw a background box behind the text, or remove it with `None`.
pub fn set_bbox(&mut self, bbox: Option<TextBoxStyle>) -> &mut Self {
self.bbox = bbox;
self
}
/// The layout box `(left, bottom, right, top)` in the unrotated baseline
/// frame at `size_px`, plus the laid-out line.
fn layout(&self, font: &FontSource, size_px: f64) -> (Bbox, RichText) {
let rich = layout_rich_text(font, &self.text, size_px);
let ink = rich
.paths
.iter()
.filter(|p| !p.vertices().is_empty())
.map(crate::core::Path::get_extents)
.reduce(|a, b| a.union(&b));
let (right, ymin, ymax) = match ink {
Some(ink) => (ink.xmax(), ink.ymin(), ink.ymax()),
None => (rich.width, 0.0, 0.0),
};
let (ascent, descent) = font.line_box(size_px);
let layout = Bbox::from_extents(0.0, ymin.min(-descent), right, ymax.max(ascent));
(layout, rich)
}
/// The transform from the baseline frame to device pixels, placing the
/// rotated layout box against `anchor` per the alignment.
fn placement(&self, layout: &Bbox, anchor: (f64, f64)) -> Affine2D {
let rotation = Affine2D::from_rotation_deg(self.rotation);
let rotated = layout.transformed(&rotation);
let dx = match self.ha {
HorizontalAlignment::Left => -rotated.xmin(),
HorizontalAlignment::Center => -(rotated.xmin() + rotated.xmax()) / 2.0,
HorizontalAlignment::Right => -rotated.xmax(),
};
let dy = match self.va {
VerticalAlignment::Top => -rotated.ymax(),
VerticalAlignment::Center => -(rotated.ymin() + rotated.ymax()) / 2.0,
VerticalAlignment::Baseline => 0.0,
VerticalAlignment::Bottom => -rotated.ymin(),
};
rotation.then(&Affine2D::from_translation(anchor.0 + dx, anchor.1 + dy))
}
/// The width and height in pixels of the rotated layout box (background
/// box included) at decoration scale `s` — what layout reserves for it.
pub(crate) fn extent(&self, font: &FontSource, s: f64) -> (f64, f64) {
if self.text.is_empty() {
return (0.0, 0.0);
}
let (layout, _) = self.layout(font, self.size * s);
let layout = self.padded(&layout, s);
let rotated = layout.transformed(&Affine2D::from_rotation_deg(self.rotation));
(rotated.width(), rotated.height())
}
/// `layout` grown by the background box's padding, if there is a box.
fn padded(&self, layout: &Bbox, s: f64) -> Bbox {
let pad = self.bbox.map_or(0.0, |style| style.pad.max(0.0) * s);
Bbox::from_extents(
layout.xmin() - pad,
layout.ymin() - pad,
layout.xmax() + pad,
layout.ymax() + pad,
)
}
/// Draw the text (and its box) aligned to `anchor`, in device pixels with
/// y up.
pub(crate) fn draw(&self, renderer: &mut dyn Renderer, font: &FontSource, anchor: (f64, f64)) {
if self.text.is_empty() {
return;
}
let s = renderer.decoration_scale();
let (layout, rich) = self.layout(font, self.size * s);
let place = self.placement(&layout, anchor);
if let Some(style) = self.bbox {
let background =
rounded_rect_path(&self.padded(&layout, s), style.corner_radius.max(0.0) * s);
renderer.draw_path(
&GraphicsContext::new(),
&background,
&place,
Some(style.facecolor),
);
if let Some(edge) = style.edgecolor {
let gc = GraphicsContext::new()
.with_stroke(edge)
.with_line_width(style.linewidth);
renderer.draw_path(&gc, &background, &place, None);
}
}
// Rotated, the alignment names a side of the rotated box rather than
// a point along the run.
let anchor = match self.ha {
_ if self.rotation != 0.0 => TextAnchor::Start,
HorizontalAlignment::Left => TextAnchor::Start,
HorizontalAlignment::Center => TextAnchor::Middle,
HorizontalAlignment::Right => TextAnchor::End,
};
rich.draw(
renderer,
&GraphicsContext::new(),
&place,
false,
self.color,
anchor,
);
}
}