use crate::brush::Brush;
use crate::color::Color;
use crate::geometry::{Affine, Point, Vec2};
use crate::plot::value::Value;
use crate::primitives::PolylineSampler;
use crate::scene::{Glyph, GlyphRun, SceneBuilder};
use crate::text::{run_layout_glyphs, TextRun, TextStyle};
use super::marks::{build_marks_from_column, unique_values_at_first_rows, MarkSlot};
use super::resolve::{
override_alpha, pt_to_px, resolve_angle_channel, resolve_bool_channel_or,
resolve_color_channel_or_theme, resolve_number_channel, resolve_number_channel_or,
resolve_pick_id, resolve_position, resolve_str_channel_or,
};
use super::state::{finalize_state, require_x_and_siblings, GeomState, KeysStrategy};
use super::{BuildableGeom, Channel, ExpectedOutput, Geom, GeomBuilder, GeomContext, Keys};
use crate::plot::diff::{diff_columns, diff_positional, KeyIndex};
fn default_fill() -> Color {
Color::new([0.0, 0.0, 0.0, 1.0])
}
const CHANNELS: &[(&str, ExpectedOutput)] = &[
("x", ExpectedOutput::Numbers),
("y", ExpectedOutput::Numbers),
("x_offset", ExpectedOutput::Numbers),
("y_offset", ExpectedOutput::Numbers),
("x_band", ExpectedOutput::Numbers),
("y_band", ExpectedOutput::Numbers),
("text", ExpectedOutput::Strings),
("size", ExpectedOutput::Numbers),
("weight", ExpectedOutput::Numbers),
("italic", ExpectedOutput::Any),
("family", ExpectedOutput::Strings),
("letter_spacing", ExpectedOutput::Numbers),
("underline", ExpectedOutput::Any),
("strikethrough", ExpectedOutput::Any),
("text_stroke", ExpectedOutput::Colors),
("text_linewidth", ExpectedOutput::Numbers),
("fill", ExpectedOutput::Colors),
("fill_opacity", ExpectedOutput::Numbers),
("offset", ExpectedOutput::Numbers),
("justify_x", ExpectedOutput::Numbers),
("upright", ExpectedOutput::Any),
("anchor_y", ExpectedOutput::Numbers),
("angle", ExpectedOutput::Numbers),
("pick_id", ExpectedOutput::Numbers),
];
pub struct TextPathGeom {
pub(crate) state: GeomState,
pub(crate) marks: Vec<MarkSlot>,
}
crate::impl_geom_inherents_grouped!(TextPathGeom);
impl TextPathGeom {
pub(crate) fn build_marks(&self) -> Vec<MarkSlot> {
super::marks::build_marks(&self.state.keys)
}
}
impl BuildableGeom for TextPathGeom {
fn build_from(builder: GeomBuilder<Self>) -> Self {
let (keys_opt, channels) = builder.into_parts();
let n = require_x_and_siblings(&channels, &["y"], "TextPathGeom");
if !channels.contains_key("text") {
panic!("TextPathGeom::build: missing required channel \"text\"");
}
let state = finalize_state(
keys_opt,
channels,
n,
KeysStrategy::OneMark,
CHANNELS,
"TextPathGeom",
);
TextPathGeom {
state,
marks: Vec::new(),
}
}
}
impl Geom for TextPathGeom {
fn state(&self) -> &GeomState {
&self.state
}
fn state_mut(&mut self) -> &mut GeomState {
&mut self.state
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn mark_count(&self) -> usize {
if self.marks.is_empty() && !self.is_empty() {
return self.build_marks().len();
}
self.marks.len()
}
fn invalidate_caches(&mut self) {
self.marks.clear();
}
fn rebuild_diff_against_previous(&mut self) {
if !self.state.dirty {
return;
}
let next_marks = self.build_marks();
let prev_marks = match &self.state.prev_keys {
Keys::Explicit(col) if !col.is_empty() => build_marks_from_column(col),
_ => Vec::new(),
};
let (enter, update, exit) = match (&self.state.prev_keys, &self.state.keys) {
(Keys::Explicit(prev_col), Keys::Explicit(next_col)) => {
let prev_unique = unique_values_at_first_rows(
prev_col,
prev_marks.iter().map(|m| m.first_row),
"TextPathGeom",
);
let next_unique = unique_values_at_first_rows(
next_col,
next_marks.iter().map(|m| m.first_row),
"TextPathGeom",
);
let idx = KeyIndex::build(&prev_unique);
diff_columns(&prev_unique, &idx, &next_unique)
}
_ => diff_positional(prev_marks.len(), next_marks.len()),
};
self.state.enter = enter;
self.state.update = update;
self.state.exit = exit;
self.marks = next_marks;
self.state.prev_keys = self.state.keys.clone();
self.state.prev_channels = self.state.channels.clone();
self.state.dirty = false;
}
fn draw(&self, scene: &mut dyn SceneBuilder, ctx: &GeomContext<'_>) {
let panel = ctx.panel_rect;
let panel_w = panel.x1 - panel.x0;
let panel_h = panel.y1 - panel.y0;
if panel_w <= 0.0 || panel_h <= 0.0 {
return;
}
let owned_marks;
let marks: &[MarkSlot] = if self.marks.is_empty() && !self.is_empty() {
owned_marks = self.build_marks();
&owned_marks
} else {
&self.marks
};
if marks.is_empty() {
return;
}
let x_scale_bound = ctx.scale_for("x");
let y_scale_bound = ctx.scale_for("y");
let x_offset_scale = ctx.scale_for("x_offset");
let y_offset_scale = ctx.scale_for("y_offset");
let x_band_scale = ctx.scale_for("x_band");
let y_band_scale = ctx.scale_for("y_band");
let text_scale = ctx.scale_for("text");
let size_scale = ctx.scale_for("size");
let weight_scale = ctx.scale_for("weight");
let italic_scale = ctx.scale_for("italic");
let family_scale = ctx.scale_for("family");
let letter_spacing_scale = ctx.scale_for("letter_spacing");
let underline_scale = ctx.scale_for("underline");
let strikethrough_scale = ctx.scale_for("strikethrough");
let text_stroke_scale = ctx.scale_for("text_stroke");
let text_linewidth_scale = ctx.scale_for("text_linewidth");
let fill_scale = ctx.scale_for("fill");
let fill_opacity_scale = ctx.scale_for("fill_opacity");
let offset_scale = ctx.scale_for("offset");
let hjust_scale = ctx.scale_for("justify_x");
let upright_scale = ctx.scale_for("upright");
let anchor_y_scale = ctx.scale_for("anchor_y");
let angle_scale = ctx.scale_for("angle");
let pick_id_scale = ctx.scale_for("pick_id");
let channels = &self.state.channels;
let (x_col, x_scale) = match channels.get("x") {
Some(Channel::Data(c)) => (c, x_scale_bound),
Some(Channel::RawData(c)) => (c, None),
_ => return,
};
let (y_col, y_scale) = match channels.get("y") {
Some(Channel::Data(c)) => (c, y_scale_bound),
Some(Channel::RawData(c)) => (c, None),
_ => return,
};
let x_offset_ch = channels.get("x_offset");
let y_offset_ch = channels.get("y_offset");
let x_band_ch = channels.get("x_band");
let y_band_ch = channels.get("y_band");
let text_ch = channels.get("text");
let size_ch = channels.get("size");
let weight_ch = channels.get("weight");
let italic_ch = channels.get("italic");
let family_ch = channels.get("family");
let letter_spacing_ch = channels.get("letter_spacing");
let underline_ch = channels.get("underline");
let strikethrough_ch = channels.get("strikethrough");
let text_stroke_ch = channels.get("text_stroke");
let text_linewidth_ch = channels.get("text_linewidth");
let fill_ch = channels.get("fill");
let fill_opacity_ch = channels.get("fill_opacity");
let offset_ch = channels.get("offset");
let hjust_ch = channels.get("justify_x");
let upright_ch = channels.get("upright");
let anchor_y_ch = channels.get("anchor_y");
let angle_ch = channels.get("angle");
let pick_id_ch = channels.get("pick_id");
for mark in marks.iter() {
let i0 = mark.first_row;
let text = resolve_str_channel_or(text_ch, text_scale, i0, "");
if text.is_empty() {
continue;
}
let size_pt = resolve_number_channel_or(
size_ch,
size_scale,
i0,
ctx.theme.geom.text_path.size_pt,
);
if !size_pt.is_finite() || size_pt <= 0.0 {
continue;
}
let weight = resolve_number_channel(weight_ch, weight_scale, i0)
.map(|w| (w.round() as i64).clamp(1, 1000) as u16)
.unwrap_or(ctx.theme.geom.text_path.weight);
let italic = resolve_italic(italic_ch, italic_scale, i0);
let family = resolve_str_opt(family_ch, family_scale, i0);
let letter_spacing_pt = resolve_number_channel_or(
letter_spacing_ch,
letter_spacing_scale,
i0,
ctx.theme.geom.text_path.letter_spacing_pt,
) as f32;
let underline = resolve_bool_channel_or(
underline_ch,
underline_scale,
i0,
ctx.theme.geom.text_path.underline,
);
let strikethrough = resolve_bool_channel_or(
strikethrough_ch,
strikethrough_scale,
i0,
ctx.theme.geom.text_path.strikethrough,
);
let text_stroke_color = resolve_color_channel_or_theme(
text_stroke_ch,
text_stroke_scale,
i0,
ctx.theme.geom.text_path.text_stroke.as_ref(),
&ctx.theme.palette,
);
let text_linewidth_pt = resolve_number_channel_or(
text_linewidth_ch,
text_linewidth_scale,
i0,
ctx.theme.geom.text_path.text_linewidth_pt,
);
let outline_stroke = match (text_stroke_color, text_linewidth_pt) {
(Some(col), pt) if pt > 0.0 => {
let stroke_width_px = pt_to_px(pt, ctx.dpi);
if stroke_width_px > 0.0 {
Some((col, crate::stroke::Stroke::new(stroke_width_px)))
} else {
None
}
}
_ => None,
};
let fill_color = override_alpha(
resolve_color_channel_or_theme(
fill_ch,
fill_scale,
i0,
ctx.theme.geom.text_path.fill.as_ref(),
&ctx.theme.palette,
),
resolve_number_channel(fill_opacity_ch, fill_opacity_scale, i0),
)
.unwrap_or_else(default_fill);
let offset_pt = resolve_number_channel_or(offset_ch, offset_scale, i0, 0.0);
let justify_x = resolve_number_channel_or(hjust_ch, hjust_scale, i0, 0.0);
let upright = resolve_bool_channel_or(upright_ch, upright_scale, i0, false);
let anchor_y = resolve_number_channel_or(anchor_y_ch, anchor_y_scale, i0, 0.5);
let angle_user = resolve_angle_channel(angle_ch, angle_scale, i0);
let pick = resolve_pick_id(pick_id_ch, pick_id_scale, i0);
let is_linear = ctx.projection.is_linear();
let mut interior: Vec<(f64, f64)> = Vec::new();
let mut prev_channels: Option<[f64; 2]> = None;
let mut points: Vec<Point> = Vec::with_capacity(mark.rows.len());
for &i in &mark.rows {
let x_band = resolve_number_channel_or(x_band_ch, x_band_scale, i, 0.0);
let y_band = resolve_number_channel_or(y_band_ch, y_band_scale, i, 0.0);
let x_frac = resolve_position(x_col.get(i), x_scale, x_band);
let y_frac = resolve_position(y_col.get(i), y_scale, y_band);
if !x_frac.is_finite() || !y_frac.is_finite() {
continue;
}
let curr_channels = [x_frac, y_frac];
if !is_linear {
if let Some(prev) = prev_channels {
interior.clear();
ctx.projection.interpolate_segment(
panel,
&prev,
&curr_channels,
&mut interior,
);
for (ipx, ipy) in &interior {
points.push(Point::new(*ipx, *ipy));
}
}
}
let (mut px, mut py) = ctx.projection.project_to_panel_px(panel, &curr_channels);
if let Some(off) = resolve_number_channel(x_offset_ch, x_offset_scale, i) {
px += pt_to_px(off, ctx.dpi);
}
if let Some(off) = resolve_number_channel(y_offset_ch, y_offset_scale, i) {
py -= pt_to_px(off, ctx.dpi);
}
points.push(Point::new(px, py));
prev_channels = Some(curr_channels);
}
if points.len() < 2 {
continue;
}
let sampler = PolylineSampler::from_polyline(&points);
let path_length = sampler.total_length();
if path_length <= 0.0 {
continue;
}
let mut style = TextStyle::new(size_pt as f32)
.weight(weight)
.italic(italic)
.letter_spacing_pt(letter_spacing_pt)
.underline(underline)
.strikethrough(strikethrough);
if let Some(fam) = family {
style = style.family(fam);
}
let run = TextRun::new(&text, &style, ctx.dpi);
let text_w = run.natural_width();
let glyphs = run_layout_glyphs(&run);
if glyphs.is_empty() {
continue;
}
let baseline_ref = glyphs[0].y as f64;
let descent_px = run.last_line_descender();
let ascent_px = run.natural_height() - descent_px;
let offset_px = pt_to_px(offset_pt, ctx.dpi);
let body_h_px = ascent_px + descent_px;
let flipped = if upright {
let natural_shift = justify_x * (path_length - text_w);
let mut upside_down = 0usize;
let mut counted = 0usize;
for g in &glyphs {
let half_advance = g.advance as f64 * 0.5;
let d = offset_px + natural_shift + g.x as f64 + half_advance;
if !d.is_finite() {
continue;
}
let d_clamped = d.clamp(0.0, path_length);
if let Some(s) = sampler.sample_at(d_clamped) {
counted += 1;
if s.tangent.x < 0.0 {
upside_down += 1;
}
}
}
counted > 0 && upside_down * 2 > counted
} else {
false
};
let hjust_shift = if flipped {
(1.0 - justify_x) * (path_length - text_w)
} else {
justify_x * (path_length - text_w)
};
let perp_px = if flipped {
anchor_y * body_h_px - descent_px
} else {
ascent_px - anchor_y * body_h_px
};
let brush = Brush::Solid(fill_color);
for g in &glyphs {
let half_advance = g.advance as f64 * 0.5;
let d_glyph = offset_px + hjust_shift + g.x as f64 + half_advance;
if !d_glyph.is_finite() || d_glyph < 0.0 || d_glyph > path_length {
continue;
}
let d_sample = if flipped {
path_length - d_glyph
} else {
d_glyph
};
let sample = match sampler.sample_at(d_sample) {
Some(s) => s,
None => continue,
};
let tangent = if flipped {
-sample.tangent
} else {
sample.tangent
};
let theta_tangent = tangent.y.atan2(tangent.x);
let theta = theta_tangent + (-angle_user);
let y_above_baseline = g.y as f64 - baseline_ref;
let xform = Affine::translate(Vec2::new(sample.point.x, sample.point.y))
* Affine::rotate(theta)
* Affine::translate(Vec2::new(-half_advance, perp_px + y_above_baseline));
let glyph = Glyph {
id: g.id,
x: 0.0,
y: 0.0,
};
if let Some((stroke_color, stroke)) = &outline_stroke {
let stroke_brush = Brush::Solid(*stroke_color);
let stroke_run = GlyphRun {
font: &g.font,
font_size: g.font_size,
transform: xform,
glyph_transform: None,
brush: &stroke_brush,
brush_alpha: 1.0,
hint: false,
glyphs: std::slice::from_ref(&glyph),
style: Some(stroke),
};
scene.draw_glyphs(&stroke_run, crate::pick::PickId::Skip);
}
let glyph_run = GlyphRun {
font: &g.font,
font_size: g.font_size,
transform: xform,
glyph_transform: None,
brush: &brush,
brush_alpha: 1.0,
hint: false,
glyphs: std::slice::from_ref(&glyph),
style: None,
};
scene.draw_glyphs(&glyph_run, pick);
}
}
}
}
fn resolve_str_opt(
channel: Option<&Channel>,
scale: Option<&crate::plot::scale::Scale>,
i: usize,
) -> Option<String> {
let ch = channel?;
let (raw, bypass) = match ch {
Channel::Constant(v) => (v.clone(), false),
Channel::Data(col) => (col.get(i), false),
Channel::RawConstant(v) => (v.clone(), true),
Channel::RawData(col) => (col.get(i), true),
};
let mapped = match (bypass, scale) {
(true, _) | (false, None) => raw,
(false, Some(s)) => s.map(&raw),
};
mapped.as_str().map(str::to_owned)
}
fn resolve_italic(
channel: Option<&Channel>,
scale: Option<&crate::plot::scale::Scale>,
i: usize,
) -> bool {
let (raw, bypass) = match channel {
None => return false,
Some(Channel::Constant(v)) => (v.clone(), false),
Some(Channel::Data(col)) => (col.get(i), false),
Some(Channel::RawConstant(v)) => (v.clone(), true),
Some(Channel::RawData(col)) => (col.get(i), true),
};
let mapped = match (bypass, scale) {
(true, _) | (false, None) => raw,
(false, Some(s)) => s.map(&raw),
};
match mapped {
Value::Bool(b) => b,
Value::String(s) => matches!(&*s, "italic" | "oblique"),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geometry::Rect;
use crate::plot::geom::{DirectScaleResolver, Raw};
use crate::scene::recording::{Op, RecordingScene};
fn shapes() -> crate::shape::ShapeRegistry {
crate::shape::ShapeRegistry::with_builtins()
}
fn ctx<'a>(
panel: Rect,
registry: &'a crate::shape::ShapeRegistry,
scales: &'a DirectScaleResolver<'a>,
) -> GeomContext<'a> {
GeomContext::new(panel, 96.0, registry, scales)
}
fn drained(g: &TextPathGeom) -> RecordingScene {
let shapes = shapes();
let scales = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 400.0, 400.0), &shapes, &scales),
);
scene
}
fn glyph_ops(scene: &RecordingScene) -> Vec<&crate::scene::recording::OwnedGlyphRun> {
scene
.ops
.iter()
.filter_map(|op| match op {
Op::DrawGlyphs(run) => Some(run),
_ => None,
})
.collect()
}
#[test]
#[should_panic(expected = "missing required channel \"x\"")]
fn builder_missing_x_panics() {
TextPathGeom::builder()
.set("y", vec![0.0_f64, 1.0])
.set("text", "hi")
.build();
}
#[test]
#[should_panic(expected = "missing required channel \"text\"")]
fn builder_missing_text_panics() {
TextPathGeom::builder()
.set("x", vec![0.0_f64, 1.0])
.set("y", vec![0.0_f64, 1.0])
.build();
}
#[test]
#[should_panic(expected = "does not match")]
fn builder_mismatched_xy_panics() {
TextPathGeom::builder()
.set("x", vec![0.0_f64, 1.0])
.set("y", vec![0.0_f64])
.set("text", "hi")
.build();
}
fn horizontal_path_geom(text: &'static str) -> TextPathGeom {
let mut g = TextPathGeom::builder()
.set("x", Raw(vec![0.25_f64, 0.75]))
.set("y", Raw(vec![0.5_f64, 0.5]))
.set("text", text)
.set("size", 20.0_f64)
.build();
g.rebuild_diff_against_previous();
g
}
#[test]
fn fill_opacity_overrides_the_glyph_fill_alpha() {
let mut g = TextPathGeom::builder()
.set("x", Raw(vec![0.25_f64, 0.75]))
.set("y", Raw(vec![0.5_f64, 0.5]))
.set("text", "AB")
.set("size", 20.0_f64)
.set("fill", crate::color::Color::new([1.0, 0.0, 0.0, 1.0]))
.set("fill_opacity", 0.35_f64)
.build();
g.rebuild_diff_against_previous();
let scene = drained(&g);
let runs = glyph_ops(&scene);
assert!(!runs.is_empty(), "expected glyph runs");
for run in runs {
let crate::brush::Brush::Solid(c) = run.brush else {
panic!("expected a solid glyph brush");
};
assert!((c.components[3] - 0.35).abs() < 1e-6, "fill alpha {c:?}");
}
}
#[test]
fn single_glyph_anchor_on_horizontal_path() {
let g = horizontal_path_geom("A");
let scene = drained(&g);
let runs = glyph_ops(&scene);
assert_eq!(runs.len(), 1);
let coeffs = runs[0].transform.as_coeffs();
assert!(coeffs[0] > 0.99, "cos(theta) = {}", coeffs[0]);
assert!(coeffs[1].abs() < 0.01, "sin(theta) = {}", coeffs[1]);
let ty = coeffs[5];
assert!(
ty > 200.0 && ty < 200.0 + 20.0 * 96.0 / 72.0,
"expected the baseline just below the path at y = 200, got {ty}"
);
let tx = coeffs[4];
assert!(
(tx - 100.0).abs() < 1.0,
"expected left-edge x ~= 100, got {tx}"
);
}
#[test]
fn vertical_path_rotates_glyphs_by_quarter_turn() {
let mut g = TextPathGeom::builder()
.set("x", Raw(vec![0.5_f64, 0.5]))
.set("y", Raw(vec![0.75_f64, 0.25]))
.set("text", "X")
.set("size", 20.0_f64)
.build();
g.rebuild_diff_against_previous();
let scene = drained(&g);
let runs = glyph_ops(&scene);
assert_eq!(runs.len(), 1);
let coeffs = runs[0].transform.as_coeffs();
assert!(coeffs[0].abs() < 0.01, "cos(theta) = {}", coeffs[0]);
assert!((coeffs[1] - 1.0).abs() < 0.01, "sin(theta) = {}", coeffs[1]);
}
#[test]
fn hjust_zero_packs_text_to_start() {
let g = horizontal_path_geom("hello");
let scene = drained(&g);
let runs = glyph_ops(&scene);
assert!(runs.len() >= 5);
let first_tx = runs[0].transform.as_coeffs()[4];
assert!(
(100.0..130.0).contains(&first_tx),
"first glyph tx = {first_tx} (expected ~[100, 130))"
);
}
#[test]
fn hjust_half_centers_text() {
let mut g = TextPathGeom::builder()
.set("x", Raw(vec![0.25_f64, 0.75]))
.set("y", Raw(vec![0.5_f64, 0.5]))
.set("text", "centerme")
.set("size", 20.0_f64)
.set("justify_x", 0.5_f64)
.build();
g.rebuild_diff_against_previous();
let scene = drained(&g);
let runs = glyph_ops(&scene);
assert!(!runs.is_empty());
let mid_idx = runs.len() / 2;
let mid_tx = runs[mid_idx].transform.as_coeffs()[4];
assert!(
(mid_tx - 200.0).abs() < 25.0,
"midpoint glyph tx = {mid_tx} (expected near 200)"
);
}
#[test]
fn hjust_one_packs_text_to_end() {
let mut g = TextPathGeom::builder()
.set("x", Raw(vec![0.25_f64, 0.75]))
.set("y", Raw(vec![0.5_f64, 0.5]))
.set("text", "abc")
.set("size", 20.0_f64)
.set("justify_x", 1.0_f64)
.build();
g.rebuild_diff_against_previous();
let scene = drained(&g);
let runs = glyph_ops(&scene);
assert!(runs.len() >= 3);
let last_tx = runs.last().unwrap().transform.as_coeffs()[4];
assert!(
last_tx > 270.0 && last_tx <= 300.0,
"last glyph tx = {last_tx} (expected near 300)"
);
}
#[test]
fn offset_shifts_layout_along_path() {
let baseline = horizontal_path_geom("ab");
let mut shifted = TextPathGeom::builder()
.set("x", Raw(vec![0.25_f64, 0.75]))
.set("y", Raw(vec![0.5_f64, 0.5]))
.set("text", "ab")
.set("size", 20.0_f64)
.set("offset", 50.0_f64)
.build();
shifted.rebuild_diff_against_previous();
let s0 = drained(&baseline);
let s1 = drained(&shifted);
let tx0 = glyph_ops(&s0)[0].transform.as_coeffs()[4];
let tx1 = glyph_ops(&s1)[0].transform.as_coeffs()[4];
let expected_delta_px = 50.0 * 96.0 / 72.0;
assert!(
(tx1 - tx0 - expected_delta_px).abs() < 1.0,
"expected shift {expected_delta_px}, got {}",
tx1 - tx0
);
}
#[test]
fn anchor_y_moves_the_text_across_the_path_by_its_own_height() {
let baseline_at = |anchor: f64| {
let mut g = TextPathGeom::builder()
.set("x", Raw(vec![0.25_f64, 0.75]))
.set("y", Raw(vec![0.5_f64, 0.5]))
.set("text", "X")
.set("size", 20.0_f64)
.set("anchor_y", anchor)
.build();
g.rebuild_diff_against_previous();
let scene = drained(&g);
glyph_ops(&scene)[0].transform.as_coeffs()[5]
};
let top = baseline_at(0.0);
let mid = baseline_at(0.5);
let bottom = baseline_at(1.0);
assert!(top > 200.0, "top-anchored baseline {top} should sit below");
assert!(
bottom < 200.0,
"bottom-anchored baseline {bottom} should sit above"
);
assert!(
(mid - 0.5 * (top + bottom)).abs() < 0.5,
"centred baseline {mid} should be midway between {top} and {bottom}"
);
}
#[test]
fn upright_reverses_reading_along_path() {
let common = |upright: bool| -> RecordingScene {
let mut g = TextPathGeom::builder()
.set("x", Raw(vec![0.75_f64, 0.25]))
.set("y", Raw(vec![0.5_f64, 0.5]))
.set("text", "abcde")
.set("size", 20.0_f64)
.set("upright", upright)
.build();
g.rebuild_diff_against_previous();
drained(&g)
};
let s_off = common(false);
let s_on = common(true);
let off = glyph_ops(&s_off);
let on = glyph_ops(&s_on);
assert!(off.len() >= 5 && on.len() >= 5);
let off0 = off[0].transform.as_coeffs();
assert!(off0[0] < -0.95, "without upright cos = {}", off0[0]);
for r in &on {
let c = r.transform.as_coeffs();
assert!(
c[0] > 0.95,
"every upright glyph reads upright: cos = {}",
c[0]
);
}
let off_x = off0[4];
let on_x = on[0].transform.as_coeffs()[4];
let on_last_x = on.last().unwrap().transform.as_coeffs()[4];
assert!(
off_x > 280.0,
"without upright, glyph 0 near start of path: off_x = {off_x}"
);
assert!(
on_x < off_x - 30.0,
"with upright, glyph 0 should land toward the far end of \
the natural text region (smaller x than off_x): \
on_x = {on_x}, off_x = {off_x}"
);
assert!(
on_last_x > on_x + 30.0,
"with upright, glyph N reads further along the reversed \
walk (larger x in world): on_last_x = {on_last_x}, \
on_x = {on_x}"
);
}
#[test]
fn upright_flips_glyphs_on_backwards_tangent() {
let mut without = TextPathGeom::builder()
.set("x", Raw(vec![0.75_f64, 0.25]))
.set("y", Raw(vec![0.5_f64, 0.5]))
.set("text", "X")
.set("size", 20.0_f64)
.build();
without.rebuild_diff_against_previous();
let mut with_ = TextPathGeom::builder()
.set("x", Raw(vec![0.75_f64, 0.25]))
.set("y", Raw(vec![0.5_f64, 0.5]))
.set("text", "X")
.set("size", 20.0_f64)
.set("upright", true)
.build();
with_.rebuild_diff_against_previous();
let s0 = drained(&without);
let s1 = drained(&with_);
let c0 = glyph_ops(&s0)[0].transform.as_coeffs();
let c1 = glyph_ops(&s1)[0].transform.as_coeffs();
assert!(c0[0] < -0.99, "without upright cos = {}", c0[0]);
assert!(c1[0] > 0.99, "with upright cos = {}", c1[0]);
}
#[test]
fn glyphs_outside_path_range_are_dropped() {
let mut g = TextPathGeom::builder()
.set("x", Raw(vec![0.25_f64, 0.275]))
.set("y", Raw(vec![0.5_f64, 0.5]))
.set("text", "this is way too long for that path")
.set("size", 20.0_f64)
.build();
g.rebuild_diff_against_previous();
let scene = drained(&g);
let n_rendered = glyph_ops(&scene).len();
assert!(
n_rendered < 5,
"expected few glyphs to fit in a 10px path; got {n_rendered}"
);
}
#[test]
fn empty_text_skips_mark() {
let mut g = TextPathGeom::builder()
.set("x", Raw(vec![0.25_f64, 0.75]))
.set("y", Raw(vec![0.5_f64, 0.5]))
.set("text", "")
.build();
g.rebuild_diff_against_previous();
let scene = drained(&g);
assert_eq!(glyph_ops(&scene).len(), 0);
}
#[test]
fn single_vertex_mark_skipped() {
let mut g = TextPathGeom::builder()
.keys(vec!["A", "A"])
.set("x", Raw(vec![0.25_f64, 0.25]))
.set("y", Raw(vec![0.5_f64, 0.5]))
.set("text", "label")
.build();
g.rebuild_diff_against_previous();
let scene = drained(&g);
assert_eq!(glyph_ops(&scene).len(), 0);
}
#[test]
fn per_mark_grouping_emits_one_label_per_key() {
let mut g = TextPathGeom::builder()
.keys(vec!["A", "A", "B", "B"])
.set("x", Raw(vec![0.25_f64, 0.5, 0.25, 0.5]))
.set("y", Raw(vec![0.75_f64, 0.75, 0.25, 0.25]))
.set("text", vec!["one", "one", "two", "two"])
.set("size", 16.0_f64)
.build();
g.rebuild_diff_against_previous();
let scene = drained(&g);
assert!(glyph_ops(&scene).len() >= 6);
let ys: Vec<f64> = glyph_ops(&scene)
.iter()
.map(|r| r.transform.as_coeffs()[5])
.collect();
let min_y = ys.iter().cloned().fold(f64::INFINITY, f64::min);
let max_y = ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
assert!(
(max_y - min_y - 200.0).abs() < 5.0,
"expected ~200px y separation between marks, got {}",
max_y - min_y
);
}
#[test]
fn pick_id_propagates_to_all_glyphs() {
let mut g = TextPathGeom::builder()
.set("x", Raw(vec![0.25_f64, 0.75]))
.set("y", Raw(vec![0.5_f64, 0.5]))
.set("text", "abc")
.set("pick_id", 42_i64)
.build();
g.rebuild_diff_against_previous();
let scene = drained(&g);
let runs = glyph_ops(&scene);
assert!(!runs.is_empty());
for r in &runs {
match r.pick_id {
crate::pick::PickId::Id(n) => assert_eq!(n, 42),
other => panic!("expected PickId::Id(42), got {other:?}"),
}
}
}
#[test]
fn declared_channels_alphabetical() {
let g = TextPathGeom::builder()
.set("x", Raw(vec![0.0_f64, 1.0]))
.set("y", Raw(vec![0.0_f64, 1.0]))
.set("text", "x")
.set("justify_x", 0.0_f64)
.set("upright", false)
.build();
let names: Vec<&str> = g.declared_channels().iter().map(|d| d.name).collect();
let mut sorted = names.clone();
sorted.sort();
assert_eq!(names, sorted);
assert!(names.contains(&"text"));
assert!(names.contains(&"justify_x"));
assert!(names.contains(&"upright"));
}
}