use super::render::{EM_RATIO, FONT_FAMILY};
use super::text::{
BundledFont, Horizontal, InkBox, LINE_WHITESPACE, Missing, Segment, Vertical, bundled_font,
whole_pixels,
};
use crate::error::GraphError;
use serde::Serialize;
const TEXT_OPEN: &str = "<text";
const TEXT_CLOSE: &str = "</text>";
const DECIMALS: usize = 2;
const ENTITIES: [(&str, char); 5] = [
("<", '<'),
(">", '>'),
("&", '&'),
(""", '"'),
("'", '\''),
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ChartText {
pub title: String,
pub description: String,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub(crate) struct Label {
pub text: String,
pub left: f64,
pub top: f64,
pub right: f64,
pub bottom: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Outlined {
pub document: String,
pub labels: Vec<Label>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Anchor {
Start,
Middle,
End,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Baseline {
Top,
Center,
Bottom,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Rotation {
Quarter,
Half,
ThreeQuarters,
}
#[derive(Debug, Clone, PartialEq)]
struct TextElement<'a> {
x: i32,
y: i32,
anchor: Anchor,
baseline: Baseline,
css_size: f64,
fill: &'a str,
opacity: &'a str,
rotation: Option<(Rotation, &'a str)>,
text: String,
}
fn rotated(ink: InkBox, rotation: Option<Rotation>, cx: f64, cy: f64) -> InkBox {
let turn = |(x, y): (f64, f64)| {
let (dx, dy) = (x - cx, y - cy);
match rotation {
None => (x, y),
Some(Rotation::Quarter) => (cx - dy, cy + dx),
Some(Rotation::Half) => (cx - dx, cy - dy),
Some(Rotation::ThreeQuarters) => (cx + dy, cy - dx),
}
};
let (x0, y0) = turn((ink.left, ink.top));
let (x1, y1) = turn((ink.right, ink.bottom));
InkBox {
left: x0.min(x1),
top: y0.min(y1),
right: x0.max(x1),
bottom: y0.max(y1),
}
}
fn capacity(parts: &[usize]) -> usize {
parts
.iter()
.try_fold(0_usize, |total, part| total.checked_add(*part))
.unwrap_or_default()
}
#[cold]
fn unsupported(reason: impl Into<String>) -> GraphError {
GraphError::UnsupportedText(reason.into())
}
pub(crate) fn outline_text(document: &str, chart: &ChartText) -> Result<Outlined, GraphError> {
let font = bundled_font()?;
let mut converted = String::with_capacity(document.len());
let mut labels = Vec::new();
let mut rest = document;
while let Some(start) = rest.find(TEXT_OPEN) {
let (before, element) = rest
.split_at_checked(start)
.ok_or_else(|| unsupported("text element at an invalid offset"))?;
converted.push_str(before);
let (element, after) = split_element(element)?;
if let Some((markup, label)) = outline_element(&element, font)? {
converted.push_str(&markup);
labels.push(label);
}
rest = after;
}
converted.push_str(rest);
let document = with_chart_text(&converted, chart)?;
Ok(Outlined { document, labels })
}
fn split_element(source: &str) -> Result<(TextElement<'_>, &str), GraphError> {
let after_name = source
.strip_prefix(TEXT_OPEN)
.ok_or_else(|| unsupported("expected a text element"))?;
if !after_name.starts_with(' ') {
return Err(unsupported(
"an element whose name starts with \"text\" but is not <text>",
));
}
let (attributes, after_tag) = after_name
.split_once('>')
.ok_or_else(|| unsupported("unterminated <text> tag"))?;
if attributes.ends_with('/') {
return Err(unsupported("an empty <text/> element"));
}
let (content, after) = after_tag
.split_once(TEXT_CLOSE)
.ok_or_else(|| unsupported("a <text> element without </text>"))?;
Ok((parse_element(attributes, content)?, after))
}
fn parse_attributes(source: &str) -> Result<Vec<(&str, &str)>, GraphError> {
let mut attributes = Vec::new();
let mut rest = source;
while !rest.is_empty() {
let unspaced = rest
.strip_prefix(' ')
.ok_or_else(|| unsupported(format!("unexpected text {rest:?} in a <text> tag")))?;
let (name, value_and_rest) = unspaced
.split_once("=\"")
.ok_or_else(|| unsupported(format!("malformed attribute in {unspaced:?}")))?;
if name.is_empty() || !name.chars().all(|c| c.is_ascii_lowercase() || c == '-') {
return Err(unsupported(format!("attribute name {name:?}")));
}
let (value, after) = value_and_rest
.split_once('"')
.ok_or_else(|| unsupported(format!("unterminated value of {name:?}")))?;
attributes.push((name, value));
rest = after;
}
Ok(attributes)
}
fn set_once<T>(slot: &mut Option<T>, name: &str, value: T) -> Result<(), GraphError> {
match slot.replace(value) {
Some(_) => Err(unsupported(format!("duplicate attribute {name:?}"))),
None => Ok(()),
}
}
fn required<T>(slot: Option<T>, name: &str) -> Result<T, GraphError> {
slot.ok_or_else(|| unsupported(format!("missing attribute {name:?}")))
}
fn parse_coordinate(name: &str, value: &str) -> Result<i32, GraphError> {
value
.parse()
.map_err(|_| unsupported(format!("{name}={value:?} is not an integer pixel")))
}
fn parse_unit_interval(name: &str, value: &str) -> Result<f64, GraphError> {
match value.parse::<f64>() {
Ok(number) if (0.0..=1.0).contains(&number) => Ok(number),
_ => Err(unsupported(format!("{name}={value:?} is not in 0..=1"))),
}
}
fn parse_fill(value: &str) -> Result<&str, GraphError> {
let digits = value.strip_prefix('#').unwrap_or_default();
if digits.len() == 6 && digits.chars().all(|c| c.is_ascii_hexdigit()) {
Ok(value)
} else {
Err(unsupported(format!("fill={value:?} is not #RRGGBB")))
}
}
fn parse_rotation(value: &str, x: i32, y: i32) -> Result<Rotation, GraphError> {
let invalid = || unsupported(format!("transform={value:?}"));
let arguments = value
.strip_prefix("rotate(")
.and_then(|rest| rest.strip_suffix(')'))
.ok_or_else(invalid)?;
let mut parts = arguments.split(", ");
let (Some(angle), Some(cx), Some(cy), None) =
(parts.next(), parts.next(), parts.next(), parts.next())
else {
return Err(invalid());
};
if cx.parse::<i32>().ok() != Some(x) || cy.parse::<i32>().ok() != Some(y) {
return Err(invalid());
}
match angle {
"90" => Ok(Rotation::Quarter),
"180" => Ok(Rotation::Half),
"270" => Ok(Rotation::ThreeQuarters),
_ => Err(invalid()),
}
}
fn parse_element<'a>(attributes: &'a str, content: &str) -> Result<TextElement<'a>, GraphError> {
let (mut x, mut y, mut baseline, mut anchor, mut family) = (None, None, None, None, None);
let (mut size, mut opacity, mut fill, mut transform) = (None, None, None, None);
for (name, value) in parse_attributes(attributes)? {
match name {
"x" => set_once(&mut x, name, parse_coordinate(name, value)?)?,
"y" => set_once(&mut y, name, parse_coordinate(name, value)?)?,
"dy" => {
let parsed = match value {
"0.76em" => Baseline::Top,
"0.5ex" => Baseline::Center,
"-0.5ex" => Baseline::Bottom,
other => return Err(unsupported(format!("dy={other:?}"))),
};
set_once(&mut baseline, name, parsed)?;
}
"text-anchor" => {
let parsed = match value {
"start" => Anchor::Start,
"middle" => Anchor::Middle,
"end" => Anchor::End,
other => return Err(unsupported(format!("text-anchor={other:?}"))),
};
set_once(&mut anchor, name, parsed)?;
}
"font-family" if value == FONT_FAMILY => set_once(&mut family, name, value)?,
"font-size" => {
let parsed = match value.parse::<f64>() {
Ok(number) if number.is_finite() && number > 0.0 => number,
_ => return Err(unsupported(format!("font-size={value:?}"))),
};
set_once(&mut size, name, parsed)?;
}
"opacity" => {
parse_unit_interval(name, value)?;
set_once(&mut opacity, name, value)?;
}
"fill" => set_once(&mut fill, name, parse_fill(value)?)?,
"transform" => set_once(&mut transform, name, value)?,
other => return Err(unsupported(format!("attribute {other}={value:?}"))),
}
}
let (x, y) = (required(x, "x")?, required(y, "y")?);
required(family, "font-family")?;
let rotation = match transform {
Some(value) => Some((parse_rotation(value, x, y)?, value)),
None => None,
};
let escaped = content
.strip_prefix('\n')
.and_then(|inner| inner.strip_suffix('\n'))
.ok_or_else(|| unsupported("text content not in plotters-svg's layout"))?;
Ok(TextElement {
x,
y,
anchor: required(anchor, "text-anchor")?,
baseline: required(baseline, "dy")?,
css_size: required(size, "font-size")?,
fill: required(fill, "fill")?,
opacity: required(opacity, "opacity")?,
rotation,
text: unescape(escaped)?,
})
}
fn unescape(escaped: &str) -> Result<String, GraphError> {
let mut text = String::with_capacity(escaped.len());
let mut rest = escaped;
loop {
let (plain, entity) = match rest.find('&') {
Some(at) => rest
.split_at_checked(at)
.ok_or_else(|| unsupported("entity at an invalid offset"))?,
None => (rest, ""),
};
if plain.contains('<') {
return Err(unsupported("markup inside a <text> element"));
}
text.push_str(plain);
if entity.is_empty() {
return Ok(text);
}
let (name, character) = ENTITIES
.iter()
.find(|(name, _)| entity.starts_with(name))
.ok_or_else(|| unsupported(format!("entity at {entity:?}")))?;
text.push(*character);
rest = entity
.get(name.len()..)
.ok_or_else(|| unsupported("entity at an invalid offset"))?;
}
}
fn is_xml_char(character: char) -> bool {
matches!(character, '\t' | '\n' | '\r' | '\u{20}'..='\u{FFFD}' | '\u{10000}'..)
}
fn ensure_xml_text(text: &str) -> Result<(), GraphError> {
match text.chars().find(|c| !is_xml_char(*c)) {
Some(character) => Err(GraphError::NotXmlText {
character,
code: u32::from(character),
text: text.to_string(),
}),
None => Ok(()),
}
}
fn push_escaped(out: &mut String, text: &str) {
for character in text.chars() {
match ENTITIES.iter().find(|(_, escaped)| *escaped == character) {
Some((entity, _)) => out.push_str(entity),
None => out.push(character),
}
}
}
fn push_number(out: &mut String, value: f64) {
let text = format!("{value:.DECIMALS$}");
let trimmed = text.trim_end_matches('0').trim_end_matches('.');
out.push_str(if trimmed == "-0" { "0" } else { trimmed });
}
fn push_point(out: &mut String, command: char, (x, y): (f64, f64)) {
out.push(command);
push_number(out, x);
out.push(' ');
push_number(out, y);
}
fn push_segment(out: &mut String, segment: Segment) {
match segment {
Segment::Move(p) => push_point(out, 'M', p),
Segment::Line(p) => push_point(out, 'L', p),
Segment::Quad(c, p) => {
push_point(out, 'Q', c);
out.push(' ');
push_number_pair(out, p);
}
Segment::Cubic(c0, c1, p) => {
push_point(out, 'C', c0);
out.push(' ');
push_number_pair(out, c1);
out.push(' ');
push_number_pair(out, p);
}
Segment::Close => out.push('Z'),
}
}
fn push_number_pair(out: &mut String, (x, y): (f64, f64)) {
push_number(out, x);
out.push(' ');
push_number(out, y);
}
fn layout(
element: &TextElement<'_>,
font: &BundledFont,
) -> Result<Option<(String, InkBox)>, GraphError> {
let layout = font
.layout(&element.text, element.css_size * EM_RATIO, Missing::Error)?
.with_height(whole_pixels(element.css_size, "the font size")?);
let dx = layout.dx(match element.anchor {
Anchor::Start => Horizontal::Start,
Anchor::Middle => Horizontal::Middle,
Anchor::End => Horizontal::End,
})?;
let dy = layout.dy(match element.baseline {
Baseline::Top => Vertical::Top,
Baseline::Center => Vertical::Center,
Baseline::Bottom => Vertical::Bottom,
})?;
let origin = (
f64::from(element.x) + f64::from(dx),
f64::from(element.y) + f64::from(dy),
);
let mut path = String::new();
let ink = font.trace(&layout, origin, |segment| push_segment(&mut path, segment))?;
Ok(ink.map(|ink| (path, ink)))
}
fn outline_element(
element: &TextElement<'_>,
font: &BundledFont,
) -> Result<Option<(String, Label)>, GraphError> {
let Some((path, ink)) = layout(element, font)? else {
return Ok(None);
};
let text: String = element
.text
.chars()
.map(|c| if LINE_WHITESPACE.contains(&c) { ' ' } else { c })
.collect();
ensure_xml_text(&text)?;
let mut markup = String::with_capacity(capacity(&[path.len(), text.len(), 128]));
markup.push_str("<g role=\"img\" aria-label=\"");
push_escaped(&mut markup, &text);
markup.push_str("\" fill=\"");
markup.push_str(element.fill);
markup.push_str("\" opacity=\"");
markup.push_str(element.opacity);
markup.push('"');
if let Some((_, transform)) = element.rotation {
markup.push_str(" transform=\"");
markup.push_str(transform);
markup.push('"');
}
markup.push_str("><path d=\"");
markup.push_str(&path);
markup.push_str("\"/></g>");
let ink = rotated(
ink,
element.rotation.map(|(rotation, _)| rotation),
f64::from(element.x),
f64::from(element.y),
);
Ok(Some((
markup,
Label {
text,
left: ink.left,
top: ink.top,
right: ink.right,
bottom: ink.bottom,
},
)))
}
fn with_chart_text(document: &str, chart: &ChartText) -> Result<String, GraphError> {
ensure_xml_text(&chart.title)?;
ensure_xml_text(&chart.description)?;
let tag_end = document
.find("<svg")
.and_then(|start| {
let end = document.get(start..)?.find('>')?;
start.checked_add(end)?.checked_add(1)
})
.ok_or_else(|| unsupported("a document without an <svg> element"))?;
let (head, tail) = document
.split_at_checked(tag_end)
.ok_or_else(|| unsupported("<svg> tag at an invalid offset"))?;
let mut out = String::with_capacity(capacity(&[
document.len(),
chart.title.len(),
chart.description.len(),
64,
]));
out.push_str(head);
out.push_str("<title>");
push_escaped(&mut out, &chart.title);
out.push_str("</title><desc>");
push_escaped(&mut out, &chart.description);
out.push_str("</desc>");
out.push_str(tail);
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
const SVG_OPEN: &str = "<svg width=\"200\" height=\"100\" viewBox=\"0 0 200 100\" xmlns=\"http://www.w3.org/2000/svg\">\n";
fn text_element(attributes: &str, content: &str) -> String {
format!(
"<text x=\"20\" y=\"30\" dy=\"0.5ex\" text-anchor=\"start\" font-family=\"Open Sans\" \
font-size=\"12\" opacity=\"1\" fill=\"#2A3F5F\"{attributes}>\n{content}\n</text>\n"
)
}
fn document(elements: &str) -> String {
format!("{SVG_OPEN}{elements}</svg>\n")
}
fn chart() -> ChartText {
ChartText {
title: "Title & <name>".to_string(),
description: "Two \"series\"".to_string(),
}
}
fn convert(elements: &str) -> Result<Outlined, GraphError> {
outline_text(&document(elements), &chart())
}
fn single_label(outlined: &Outlined) -> &Label {
assert_eq!(outlined.labels.len(), 1, "{:?}", outlined.labels);
let Some(label) = outlined.labels.first() else {
panic!("no label");
};
label
}
#[test]
fn test_outline_text_replaces_text_with_a_labelled_path() -> Result<(), GraphError> {
let outlined = convert(&text_element("", "Strike"))?;
assert!(!outlined.document.contains("<text"));
assert!(!outlined.document.contains("font-family"));
assert!(outlined.document.contains(
"<g role=\"img\" aria-label=\"Strike\" fill=\"#2A3F5F\" opacity=\"1\"><path d=\"M"
));
assert_eq!(single_label(&outlined).text, "Strike");
Ok(())
}
#[test]
fn test_outline_text_escapes_the_label_and_adds_title_and_desc() -> Result<(), GraphError> {
let outlined = convert(&text_element("", "& < > " ' $"))?;
assert_eq!(single_label(&outlined).text, "& < > \" ' $");
assert!(
outlined
.document
.contains("aria-label=\"& < > " ' $\"")
);
assert!(outlined.document.contains(
"xmlns=\"http://www.w3.org/2000/svg\"><title>Title & <name></title>\
<desc>Two "series"</desc>\n"
));
Ok(())
}
#[test]
fn test_outline_text_rejects_a_missing_glyph() {
let result = convert(&text_element("", "IV 中"));
assert!(matches!(
result,
Err(GraphError::MissingGlyph { character: '中', code: 0x4E2D, ref text }) if text == "IV 中"
));
}
#[test]
fn test_outline_text_rejects_constructs_outside_the_subset() {
let cases = [
text_element(" font-weight=\"bold\"", "a"),
text_element(" transform=\"rotate(45, 20, 30)\"", "a"),
text_element(" transform=\"rotate(90, 0, 0)\"", "a"),
text_element("", "<tspan>a</tspan>"),
text_element("", " "),
text_element(" x=\"1\"", "a"),
"<text x=\"1\" y=\"2\">a</text>".to_string(),
"<textPath>a</textPath>".to_string(),
text_element("", "a").replace("Open Sans", "Arial"),
text_element("", "a").replace("\n</text>", "</text>"),
text_element("", "a").replace("</text>", ""),
];
for case in cases {
let result = convert(&case);
assert!(
matches!(result, Err(GraphError::UnsupportedText(_))),
"{case:?} gave {result:?}"
);
}
}
#[test]
fn test_spaces_keep_their_advance_and_blank_labels_paint_nothing() -> Result<(), GraphError> {
let plain = convert(&text_element("", "AV"))?;
let padded = convert(&text_element("", " AV"))?;
let (plain, padded) = (single_label(&plain), single_label(&padded));
let shift = padded.left - plain.left;
assert!(shift > 4.0 && shift < 7.0, "shift {shift}");
assert!((padded.right - plain.right - shift).abs() < 1e-9);
let blank = convert(&format!(
"{}{}",
text_element("", " "),
text_element("", "")
))?;
assert!(blank.labels.is_empty());
assert!(!blank.document.contains("<g role"));
Ok(())
}
#[test]
fn test_metadata_xml_cannot_carry_is_an_error() {
for bad in ['\0', '\u{B}', '\u{1F}', '\u{FFFE}', '\u{FFFF}'] {
let chart = ChartText {
title: "Chart".to_string(),
description: format!("hidden{bad}series"),
};
let result = outline_text(&document(&text_element("", "a")), &chart);
assert!(
matches!(result, Err(GraphError::NotXmlText { character, .. }) if character == bad),
"{bad:?}: {result:?}"
);
}
let chart = ChartText {
title: "Tab\tand\nbreaks\r are XML".to_string(),
description: "\u{10FFFF} \u{FFFD}".to_string(),
};
assert!(outline_text(&document(&text_element("", "a")), &chart).is_ok());
}
#[test]
fn test_line_whitespace_advances_like_a_space() -> Result<(), GraphError> {
let spaced = convert(&text_element("", "Strategy: Underlying"))?;
let broken = convert(&text_element("", "Strategy: \n\tUnderlying"))?;
let (spaced, broken) = (single_label(&spaced), single_label(&broken));
assert_eq!(broken.text, "Strategy: Underlying");
assert!((spaced.left - broken.left).abs() < 1e-9);
assert!((spaced.right - broken.right).abs() < 1e-9);
let bell = convert(&text_element("", "a\u{7}b"));
assert!(matches!(
bell,
Err(GraphError::MissingGlyph {
character: '\u{7}',
..
})
));
Ok(())
}
#[test]
fn test_anchors_place_the_layout_box_like_plotters() -> Result<(), GraphError> {
let start = convert(&text_element("", "Strike"))?;
let middle = convert(&text_element("", "Strike").replace("\"start\"", "\"middle\""))?;
let end = convert(&text_element("", "Strike").replace("\"start\"", "\"end\""))?;
let (start, middle, end) = (
single_label(&start),
single_label(&middle),
single_label(&end),
);
let width = start.left - end.left;
assert!(width > 20.0 && width < 40.0, "width {width}");
let half = start.left - middle.left;
assert!((half - (width / 2.0).trunc()).abs() <= 1.0, "half {half}");
Ok(())
}
#[test]
fn test_baselines_follow_plotters_vertical_anchors() -> Result<(), GraphError> {
let top = convert(&text_element("", "H").replace("0.5ex", "0.76em"))?;
let center = convert(&text_element("", "H"))?;
let bottom = convert(&text_element("", "H").replace("0.5ex", "-0.5ex"))?;
let (top, center, bottom) = (
single_label(&top),
single_label(¢er),
single_label(&bottom),
);
assert!((top.top - center.top - 6.0).abs() < 1e-9);
assert!((top.top - bottom.top - 12.0).abs() < 1e-9);
assert!(top.top > 29.0 && top.top < 31.0, "top {}", top.top);
Ok(())
}
#[test]
fn test_rotation_is_kept_and_rotates_the_ink_box() -> Result<(), GraphError> {
let flat = convert(&text_element("", "Value"))?;
let turned = convert(&text_element(" transform=\"rotate(270, 20, 30)\"", "Value"))?;
assert!(
turned
.document
.contains(" transform=\"rotate(270, 20, 30)\"><path")
);
let (flat, turned) = (single_label(&flat), single_label(&turned));
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
assert!(close(flat.right - flat.left, turned.bottom - turned.top));
assert!(close(flat.bottom - flat.top, turned.right - turned.left));
assert!(close(turned.bottom, 30.0 - (flat.left - 20.0)));
Ok(())
}
#[test]
fn test_pairs_are_not_kerned() -> Result<(), GraphError> {
let (Some(one), Some(two)) = (
convert(&text_element("", "A"))?.labels.first().cloned(),
convert(&text_element("", "AV"))?.labels.first().cloned(),
) else {
panic!("labels");
};
let font = bundled_font()?;
let v = font.layout("V", 12.0 * EM_RATIO, Missing::Error)?;
let pair = font.layout("AV", 12.0 * EM_RATIO, Missing::Error)?;
let a = font.layout("A", 12.0 * EM_RATIO, Missing::Error)?;
assert!((pair.width - a.width - v.width).abs() <= 1, "{pair:?}");
assert!(two.right > one.right);
Ok(())
}
#[test]
fn test_push_number_trims_and_rounds() {
let mut out = String::new();
for value in [1.0, -0.001, 12.345_678, -3.1, 0.105] {
push_number(&mut out, value);
out.push(' ');
}
assert_eq!(out, "1 0 12.35 -3.1 0.1 ");
}
#[test]
fn test_unescape_and_escape_round_trip() -> Result<(), GraphError> {
let text = "a & b < c > d \" e ' f";
let mut escaped = String::new();
push_escaped(&mut escaped, text);
assert_eq!(unescape(&escaped)?, text);
Ok(())
}
}