use super::shapes::{Shape, ShapeRenderContext, svg_style_from_entries};
use super::{TextVSlot, compute_text_vslots};
use crate::errors::PikruError;
use crate::types::{Length as Inches, Scaler};
use facet_svg::facet_xml::SerializeOptions;
use facet_svg::{Circle as SvgCircle, Points, Polygon, Style, Svg, SvgNode, Text, facet_xml};
use glam::{DVec2, dvec2};
use super::context::RenderContext;
use super::defaults;
use super::eval::{get_length, get_scalar};
use super::types::*;
pub fn color_to_rgb(color: &str) -> String {
color
.parse::<crate::types::Color>()
.unwrap()
.to_rgb_string()
}
pub fn color_to_string(color: &str, use_css_vars: bool) -> String {
if use_css_vars {
let name = color.to_lowercase();
let normalized = match name.as_str() {
"rgb(0,0,0)" | "black" => "black",
"rgb(255,255,255)" | "white" => "white",
"rgb(255,0,0)" | "red" => "red",
"rgb(0,128,0)" | "green" => "green",
"rgb(0,0,255)" | "blue" => "blue",
"rgb(255,255,0)" | "yellow" => "yellow",
"rgb(0,255,255)" | "cyan" => "cyan",
"rgb(255,0,255)" | "magenta" => "magenta",
"rgb(255,165,0)" | "orange" => "orange",
"rgb(128,0,128)" | "purple" => "purple",
"rgb(165,42,42)" | "brown" => "brown",
"rgb(255,192,203)" | "pink" => "pink",
"rgb(128,128,128)" | "gray" | "grey" => "gray",
"rgb(211,211,211)" | "lightgray" | "lightgrey" => "lightgray",
"rgb(169,169,169)" | "darkgray" | "darkgrey" => "darkgray",
"rgb(192,192,192)" | "silver" => "silver",
"none" | "off" => return "none".to_string(),
_ => {
return color_to_rgb(color);
}
};
format!("var(--pik-{})", normalized)
} else {
color_to_rgb(color)
}
}
fn process_backslash_escapes(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
let mut j = i;
while j < bytes.len() && bytes[j] != b'\\' {
j += 1;
}
if j > i {
result.push_str(&s[i..j]);
}
if j < bytes.len() {
if j + 1 == bytes.len() {
result.push('\\');
break;
} else if bytes[j + 1] == b'\\' {
result.push('\\');
i = j + 2;
} else {
i = j + 1;
}
} else {
break;
}
}
result
}
fn process_entities_for_svg(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
match c {
b'<' => {
result.push_str("<");
i += 1;
}
b'>' => {
result.push_str(">");
i += 1;
}
b'&' => {
if is_entity_at(bytes, i) {
result.push('&');
i += 1;
} else {
result.push_str("&");
i += 1;
}
}
_ => {
if c < 128 {
result.push(c as char);
i += 1;
} else {
let remaining = &s[i..];
if let Some(ch) = remaining.chars().next() {
result.push(ch);
i += ch.len_utf8();
} else {
i += 1;
}
}
}
}
}
result
}
fn is_entity_at(bytes: &[u8], i: usize) -> bool {
if i >= bytes.len() || bytes[i] != b'&' {
return false;
}
let mut j = i + 1;
if j >= bytes.len() {
return false;
}
if bytes[j] == b'#' {
j += 1;
if j >= bytes.len() {
return false;
}
}
let start = j;
while j < bytes.len() {
let c = bytes[j];
if c == b';' {
return j > start + 1 || (j > start && bytes[i + 1] != b'#');
} else if c.is_ascii_alphanumeric() {
j += 1;
} else {
return false;
}
}
false
}
fn generate_color_css() -> Style {
let colors = [
("black", "rgb(0,0,0)", "rgb(255,255,255)"),
("white", "rgb(255,255,255)", "rgb(0,0,0)"),
("red", "rgb(255,0,0)", "rgb(255,100,100)"),
("green", "rgb(0,128,0)", "rgb(100,255,100)"),
("blue", "rgb(0,0,255)", "rgb(100,100,255)"),
("yellow", "rgb(255,255,0)", "rgb(255,255,150)"),
("cyan", "rgb(0,255,255)", "rgb(150,255,255)"),
("magenta", "rgb(255,0,255)", "rgb(255,150,255)"),
("orange", "rgb(255,165,0)", "rgb(255,200,100)"),
("purple", "rgb(128,0,128)", "rgb(200,100,200)"),
("brown", "rgb(165,42,42)", "rgb(210,150,150)"),
("pink", "rgb(255,192,203)", "rgb(255,220,230)"),
("gray", "rgb(128,128,128)", "rgb(160,160,160)"),
("grey", "rgb(128,128,128)", "rgb(160,160,160)"),
("lightgray", "rgb(211,211,211)", "rgb(100,100,100)"),
("lightgrey", "rgb(211,211,211)", "rgb(100,100,100)"),
("darkgray", "rgb(169,169,169)", "rgb(200,200,200)"),
("darkgrey", "rgb(169,169,169)", "rgb(200,200,200)"),
("silver", "rgb(192,192,192)", "rgb(128,128,128)"),
("none", "none", "none"),
];
let mut css = String::from(":root {\n");
for (name, light, dark) in &colors {
css.push_str(&format!(
" --pik-{}: light-dark({}, {});\n",
name, light, dark
));
}
css.push_str("}\n");
Style {
type_: Some("text/css".to_string()),
content: Some(css),
}
}
pub fn generate_svg(
ctx: &RenderContext,
options: &super::RenderOptions,
) -> Result<String, PikruError> {
let margin_base = get_length(ctx, "margin", defaults::MARGIN);
let left_margin = get_length(ctx, "leftmargin", 0.0);
let right_margin = get_length(ctx, "rightmargin", 0.0);
let top_margin = get_length(ctx, "topmargin", 0.0);
let bottom_margin = get_length(ctx, "bottommargin", 0.0);
let thickness = get_length(ctx, "thickness", defaults::STROKE_WIDTH.raw());
let thickness = thickness.max(0.01);
let margin = margin_base + thickness;
let scale = get_scalar(ctx, "scale", 1.0);
let fontscale = get_scalar(ctx, "fontscale", 1.0);
let r_scale = 144.0;
let scaler = Scaler::try_new(r_scale)
.map_err(|e| PikruError::Generic(format!("invalid scale value {}: {}", r_scale, e)))?;
let arrow_ht = Inches(get_length(ctx, "arrowht", 0.08));
let arrow_wid = Inches(get_length(ctx, "arrowwid", 0.06));
let dashwid = Inches(get_length(ctx, "dashwid", 0.05));
let mut bounds = ctx.bounds;
crate::log::debug!(
sw_x = bounds.min.x.0,
sw_y = bounds.min.y.0,
ne_x = bounds.max.x.0,
ne_y = bounds.max.y.0,
"bbox before margin"
);
bounds.max.x += Inches(margin + right_margin);
bounds.max.y += Inches(margin + top_margin);
bounds.min.x -= Inches(margin + left_margin);
bounds.min.y -= Inches(margin + bottom_margin);
let min_dim = Inches(0.01);
let view_width = bounds.width().max(min_dim);
let view_height = bounds.height().max(min_dim);
let offset_x = -bounds.min.x;
let max_y = bounds.max.y;
crate::log::debug!(
bounds_min_x = bounds.min.x.0,
bounds_min_y = bounds.min.y.0,
bounds_max_x = bounds.max.x.0,
bounds_max_y = bounds.max.y.0,
offset_x = offset_x.0,
max_y = max_y.0,
"generate_svg bounds"
);
let mut svg_children: Vec<SvgNode> = Vec::new();
if options.css_variables {
svg_children.push(SvgNode::Style(generate_color_css()));
}
let viewbox_width = scaler.px(view_width);
let viewbox_height = scaler.px(view_height);
let viewbox = format!("0 0 {} {}", fmt_num(viewbox_width), fmt_num(viewbox_height));
let mut svg = Svg {
width: None,
height: None,
view_box: Some(viewbox),
children: Vec::new(),
};
let is_scaled = !(0.99..=1.01).contains(&scale);
if is_scaled {
let viewbox_width_int = viewbox_width as i32;
let viewbox_height_int = viewbox_height as i32;
let display_width = ((viewbox_width_int as f64) * scale) as i32;
let display_height = ((viewbox_height_int as f64) * scale) as i32;
svg.width = Some(display_width.to_string());
svg.height = Some(display_height.to_string());
}
#[allow(clippy::too_many_arguments)]
fn render_object_text(
obj: &RenderedObject,
scaler: &Scaler,
offset_x: Inches,
max_y: Inches,
charht: f64,
charwid: f64,
_thickness: f64,
fontscale: f64,
use_css_vars: bool,
svg_children: &mut Vec<SvgNode>,
) {
let center = obj.center().to_svg(scaler, offset_x, max_y);
if !obj.text().is_empty() {
let texts = obj.text();
let y_base = if let super::shapes::ShapeEnum::Cylinder(cyl) = &obj.shape {
if cyl.ellipse_rad.0 > 0.0 {
-0.75 * cyl.ellipse_rad.0
} else {
0.0
}
} else {
0.0
};
let slots = compute_text_vslots(texts);
let is_line = matches!(
obj.class(),
ClassName::Line | ClassName::Arrow | ClassName::Spline | ClassName::Move
);
let obj_sw = obj.style().stroke_width.raw().max(0.0);
let mut ha2: f64 = 0.0;
let mut ha1: f64 = 0.0;
let mut hc: f64 = if is_line { obj_sw * 1.5 } else { 0.0 };
let mut hb1: f64 = 0.0;
let mut hb2: f64 = 0.0;
for (text, slot) in texts.iter().zip(slots.iter()) {
let h = text.font_scale() * charht;
match slot {
TextVSlot::Above2 => ha2 = ha2.max(h),
TextVSlot::Above => ha1 = ha1.max(h),
TextVSlot::Center => hc = hc.max(h),
TextVSlot::Below => hb1 = hb1.max(h),
TextVSlot::Below2 => hb2 = hb2.max(h),
}
}
let text_color = if obj.style().stroke == "black" || obj.style().stroke == "none" {
color_to_string("black", use_css_vars)
} else {
color_to_string(&obj.style().stroke, use_css_vars)
};
for (positioned_text, slot) in texts.iter().zip(slots.iter()) {
let mut y_offset = y_base;
match slot {
TextVSlot::Above2 => y_offset += 0.5 * hc + ha1 + 0.5 * ha2,
TextVSlot::Above => y_offset += 0.5 * hc + 0.5 * ha1,
TextVSlot::Center => {}
TextVSlot::Below => y_offset -= 0.5 * hc + 0.5 * hb1,
TextVSlot::Below2 => y_offset -= 0.5 * hc + hb1 + 0.5 * hb2,
}
let svg_y_offset = scaler.px(Inches::inches(-y_offset));
let uses_box_justification = matches!(
obj.class(),
ClassName::Box | ClassName::Cylinder | ClassName::File | ClassName::Oval
);
let jw_inches = if uses_box_justification {
0.5 * (obj.width().0 - 0.5 * (charwid + obj_sw))
} else {
0.0
};
let jw = scaler.px(Inches(jw_inches));
let (anchor, text_x) = if positioned_text.rjust {
("end", center.x + jw)
} else if positioned_text.ljust {
("start", center.x - jw)
} else {
("middle", center.x)
};
let font_family = if positioned_text.mono {
Some("monospace".to_string())
} else {
None
};
let font_style = if positioned_text.italic {
Some("italic".to_string())
} else {
None
};
let font_weight = if positioned_text.bold {
Some("bold".to_string())
} else {
None
};
let total_font_scale = fontscale * positioned_text.font_scale();
let font_size = if (total_font_scale - 1.0).abs() > 0.001 {
let percent = total_font_scale * 100.0;
Some(fmt_num(percent) + "%")
} else {
None
};
let transform = if positioned_text.aligned {
if let Some(waypoints) = obj.waypoints() {
if waypoints.len() >= 2 {
let n = waypoints.len();
let dx = waypoints[n - 1].x.raw() - waypoints[0].x.raw();
let dy = waypoints[n - 1].y.raw() - waypoints[0].y.raw();
if dx != 0.0 || dy != 0.0 {
let angle = dy.atan2(dx) * -180.0 / std::f64::consts::PI;
Some(format!(
"rotate({} {},{})",
fmt_num_hi(angle),
fmt_num(text_x),
fmt_num(center.y)
))
} else {
None
}
} else {
None
}
} else {
None
}
} else {
None
};
let text_element = Text {
x: Some(text_x),
y: Some(center.y + svg_y_offset),
transform,
fill: Some(text_color.clone()),
stroke: None,
stroke_width: None,
style: None,
font_family,
font_style,
font_weight,
font_size,
text_anchor: Some(anchor.to_string()),
dominant_baseline: Some("central".to_string()),
content: Some({
let text = process_backslash_escapes(&positioned_text.value);
let text = process_entities_for_svg(&text);
text.replace(' ', "\u{00A0}")
}),
};
svg_children.push(SvgNode::Text(text_element));
}
}
if let Some(children) = obj.shape.children() {
for child in children {
render_object_text(
child,
scaler,
offset_x,
max_y,
charht,
charwid,
_thickness,
fontscale,
use_css_vars,
svg_children,
);
}
}
}
let mut sorted_objects: Vec<_> = ctx.object_list.iter().collect();
sorted_objects.sort_by_key(|obj| obj.layer);
#[allow(clippy::too_many_arguments)]
fn render_object_full(
obj: &RenderedObject,
scaler: &Scaler,
offset_x: Inches,
max_y: Inches,
dashwid: Inches,
arrow_ht: Inches,
arrow_wid: Inches,
charht: f64,
charwid: f64,
thickness: f64,
fontscale: f64,
use_css_vars: bool,
svg_children: &mut Vec<SvgNode>,
) {
if let Some(children) = obj.children() {
let mut sorted_children: Vec<_> = children.iter().collect();
sorted_children.sort_by_key(|c| c.layer);
for child in sorted_children {
render_object_full(
child,
scaler,
offset_x,
max_y,
dashwid,
arrow_ht,
arrow_wid,
charht,
charwid,
thickness,
fontscale,
use_css_vars,
svg_children,
);
}
} else {
if !obj.style().invisible {
let shape = &obj.shape;
let ctx = ShapeRenderContext {
scaler,
offset_x,
max_y,
dashwid,
arrow_len: arrow_ht,
arrow_wid,
thickness: Inches(thickness),
use_css_vars,
};
let shape_nodes = shape.render_svg(obj, &ctx);
svg_children.extend(shape_nodes);
}
render_object_text(
obj,
scaler,
offset_x,
max_y,
charht,
charwid,
thickness,
fontscale,
use_css_vars,
svg_children,
);
}
}
let charht = get_length(ctx, "charht", 0.14) * fontscale;
let charwid = get_length(ctx, "charwid", 0.08) * fontscale;
for obj in sorted_objects.iter() {
render_object_full(
obj,
&scaler,
offset_x,
max_y,
dashwid,
arrow_ht,
arrow_wid,
charht,
charwid,
thickness,
fontscale,
options.css_variables,
&mut svg_children,
);
}
if let Some(crate::types::EvalValue::Color(color_val)) = ctx.variables.get("debug_label_color")
{
let r = ((*color_val >> 16) & 0xFF) as u8;
let g = ((*color_val >> 8) & 0xFF) as u8;
let b = (*color_val & 0xFF) as u8;
let color_str = format!("rgb({},{},{})", r, g, b);
let dot_rad = 0.015; let dot_rad_px = scaler.px(Inches(dot_rad));
let sw_px = fmt_num(scaler.px(Inches(0.015)));
let mut render_debug_label = |name: &str, center: DVec2| {
let circle = SvgCircle {
cx: Some(center.x),
cy: Some(center.y),
r: Some(dot_rad_px),
fill: Some(color_str.clone()),
stroke: Some(color_str.clone()),
stroke_width: Some(sw_px.clone()),
stroke_dasharray: None,
style: None,
};
svg_children.push(SvgNode::Circle(circle));
let text_y = center.y - scaler.px(Inches(charht * 0.5));
let text_element = Text {
x: Some(center.x),
y: Some(text_y),
transform: None,
fill: Some(color_str.clone()),
stroke: None,
stroke_width: None,
style: None,
font_family: None,
font_style: None,
font_weight: None,
font_size: None,
text_anchor: Some("middle".to_string()),
dominant_baseline: Some("auto".to_string()),
content: Some(name.to_string()),
};
svg_children.push(SvgNode::Text(text_element));
};
for obj in sorted_objects.iter() {
if let Some(ref name) = obj.name
&& obj.name_is_explicit
{
let center = obj.center().to_svg(&scaler, offset_x, max_y);
render_debug_label(name, center);
}
}
for (name, pos) in ctx.named_positions.iter() {
let center = pos.to_svg(&scaler, offset_x, max_y);
render_debug_label(name, center);
}
}
svg.children = svg_children;
fn format_float(value: f64, writer: &mut dyn std::io::Write) -> Result<(), std::io::Error> {
write!(writer, "{}", fmt_num(value))
}
let options_ser = SerializeOptions {
float_formatter: Some(format_float),
preserve_entities: true,
..Default::default()
};
facet_xml::to_string_with_options(&svg, &options_ser)
.map_err(|e| PikruError::Generic(format!("XML serialization error: {}", e)))
}
pub fn render_arrowhead_dom(
start: DVec2,
end: DVec2,
style: &ObjectStyle,
arrow_len: f64,
arrow_width: f64,
use_css_vars: bool,
) -> Option<Polygon> {
let delta = end - start;
let len = delta.length();
if len < 0.001 {
return None; }
let unit = delta / len;
let perp = dvec2(-unit.y, unit.x);
let base = end - unit * arrow_len;
let half_width = arrow_width / 2.0;
let p1 = base + perp * half_width;
let p2 = base - perp * half_width;
let points = Points::new()
.push(end.x, end.y)
.push(p1.x, p1.y)
.push(p2.x, p2.y);
let fill_color = color_to_string(&style.stroke, use_css_vars);
Some(Polygon {
points,
fill: None,
stroke: None,
stroke_width: None,
stroke_dasharray: None,
style: Some(svg_style_from_entries(vec![("fill", fill_color)])),
})
}
pub(crate) fn fmt_num(value: f64) -> String {
fmt_num_precision(value, 6)
}
pub(crate) fn fmt_num_hi(value: f64) -> String {
fmt_num_precision(value, 10)
}
fn fmt_num_precision(value: f64, sig_figs: i32) -> String {
if value == 0.0 {
return "0".to_string();
}
let abs_val = value.abs();
let magnitude = abs_val.log10().floor() as i32;
let scale = 10_f64.powi(sig_figs - 1 - magnitude);
let rounded = (value * scale).round() / scale;
let decimals = (sig_figs - 1 - magnitude).max(0) as usize;
let s = format!("{:.prec$}", rounded, prec = decimals);
let s = s.trim_end_matches('0');
let s = s.trim_end_matches('.');
s.to_string()
}