mod filters;
mod gradients;
mod links;
pub(crate) mod markers; mod primitives;
mod rounding;
mod rules;
mod style_block;
mod used_vars;
pub(crate) mod values;
mod wavy;
use crate::Options;
use crate::layout::{LaidOut, PlacedNode};
use crate::resolve::{AttrMap, ShapeKind, VarTable};
use filters::FilterTable;
pub(crate) use gradients::lower as lower_gradients;
use rules::RuleSet;
use values::{escape_xml, format_value, num};
pub fn render(laid_out: &LaidOut, opts: &Options) -> String {
let mut out = String::with_capacity(2048);
let vb = &laid_out.viewbox;
use std::fmt::Write;
writeln!(
out,
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{} {} {} {}" width="{}" height="{}" class="lini">"#,
num(vb.x),
num(vb.y),
num(vb.w),
num(vb.h),
num(vb.w),
num(vb.h),
)
.unwrap();
let ruleset = rules::build(laid_out, opts);
let used = used_vars::referenced(laid_out, &ruleset);
style_block::emit(&mut out, &laid_out.vars, &ruleset, &used, opts);
let filters = FilterTable::collect(&laid_out.nodes, &laid_out.vars, opts);
if filters.is_empty() && laid_out.gradients.is_empty() {
out.push_str(" <defs/>\n");
} else {
out.push_str(" <defs>\n");
filters.emit_defs(&mut out, &laid_out.vars, opts);
gradients::emit_defs(laid_out, &mut out, opts);
out.push_str(" </defs>\n");
}
let canvas_style = match &laid_out.canvas_fill {
Some(fill) => format!(
r#" style="fill: {}""#,
format_value(fill, &laid_out.vars, opts)
),
None => String::new(),
};
writeln!(
out,
r#" <rect class="lini-canvas" x="{}" y="{}" width="{}" height="{}"{}/>"#,
num(vb.x),
num(vb.y),
num(vb.w),
num(vb.h),
canvas_style,
)
.unwrap();
out.push_str(" <g class=\"lini-scene\">\n");
for node in in_layer_order(&laid_out.nodes) {
render_node(&mut out, node, 2, &laid_out.vars, &ruleset, &filters, opts);
}
out.push_str(" </g>\n");
if laid_out.links.is_empty() && laid_out.strays.is_empty() {
out.push_str(" <g class=\"lini-links\"/>\n");
} else {
out.push_str(" <g class=\"lini-links\">\n");
let polys: Vec<&[(f64, f64)]> = laid_out.links.iter().map(|w| &w.path[..]).collect();
let caps: Vec<f64> = laid_out.links.iter().map(links::radius_cap).collect();
let targets = links::fillet_targets(&polys, &caps);
let label_size = laid_out
.sheet
.link_defaults
.number("font-size")
.unwrap_or(11.0);
for (idx, (link, targets)) in laid_out.links.iter().zip(&targets).enumerate() {
links::render_link(
&mut out,
idx,
link,
targets,
label_size,
&laid_out.vars,
&ruleset,
opts,
);
}
for air in &laid_out.strays {
links::render_stray(&mut out, air, &laid_out.vars, opts);
}
out.push_str(" </g>\n");
}
out.push_str("</svg>\n");
out
}
fn render_node(
out: &mut String,
n: &PlacedNode,
depth: usize,
vars: &VarTable,
ruleset: &RuleSet,
filters: &FilterTable,
opts: &Options,
) {
use std::fmt::Write;
if n.shape == ShapeKind::Text {
render_text(out, n, depth, vars, opts);
return;
}
let href = match n.attrs.get("href") {
Some(crate::resolve::ResolvedValue::String(s)) => Some(s.clone()),
_ => None,
};
let depth = if let Some(url) = &href {
writeln!(
out,
r#"{}<a href="{}">"#,
" ".repeat(depth),
escape_xml(url)
)
.unwrap();
depth + 1
} else {
depth
};
let indent = " ".repeat(depth);
let class_list = values::class_list(n.shape.as_str(), &n.type_chain, &n.applied_styles);
let classes = class_list.join(" ");
let transform = if n.rotation != 0.0 {
format!(
r#" transform="translate({},{}) rotate({})""#,
num(n.cx),
num(n.cy),
num(n.rotation)
)
} else {
format!(r#" transform="translate({},{})""#, num(n.cx), num(n.cy))
};
let id_attr = match &n.id {
Some(id) => format!(r#" data-id="{}""#, escape_xml(id)),
None => String::new(),
};
let style_attr = node_style_attr(n, &class_list, ruleset, vars, opts);
writeln!(
out,
r#"{}<g class="{}"{}{}{}>"#,
indent, classes, id_attr, style_attr, transform
)
.unwrap();
if let Some(crate::resolve::ResolvedValue::String(title)) = n.attrs.get("title") {
writeln!(out, "{} <title>{}</title>", indent, escape_xml(title)).unwrap();
}
primitives::render_geometry(out, n, depth + 1, vars, ruleset, filters, opts);
for child in in_layer_order(&n.children) {
render_node(out, child, depth + 1, vars, ruleset, filters, opts);
}
writeln!(out, "{}</g>", indent).unwrap();
if href.is_some() {
writeln!(out, "{}</a>", " ".repeat(depth - 1)).unwrap();
}
}
fn render_text(out: &mut String, n: &PlacedNode, depth: usize, vars: &VarTable, opts: &Options) {
use std::fmt::Write;
let indent = " ".repeat(depth);
let label = n.label.as_deref().unwrap_or("");
let (x, y) = (num(n.cx), num(n.cy));
let lines: Vec<&str> = label.split('\n').collect();
let ls = n.attrs.number("letter-spacing").unwrap_or(0.0);
let style = text_style_attr(&n.own_style, vars, opts);
let xform = if n.rotation != 0.0 {
format!(r#" transform="rotate({} {} {})""#, num(n.rotation), x, y)
} else {
String::new()
};
if lines.len() <= 1 {
writeln!(
out,
r#"{}<text class="lini-text" x="{}" y="{}"{}{}{}>{}</text>"#,
indent,
x,
y,
dx_attr(label, ls),
style,
xform,
escape_xml(label)
)
.unwrap();
return;
}
let size = n.attrs.number("font-size").unwrap_or(0.0);
let spacing = size * 1.2 + n.attrs.number("line-spacing").unwrap_or(0.0);
let top = n.cy - spacing * (lines.len() as f64 - 1.0) / 2.0;
write!(
out,
r#"{}<text class="lini-text" x="{}" y="{}"{}{}>"#,
indent, x, y, style, xform
)
.unwrap();
for (i, line) in lines.iter().enumerate() {
if i == 0 {
write!(
out,
r#"<tspan x="{}" y="{}"{}>{}</tspan>"#,
x,
num(top),
dx_attr(line, ls),
escape_xml(line)
)
.unwrap();
} else {
write!(
out,
r#"<tspan x="{}" dy="{}"{}>{}</tspan>"#,
x,
num(spacing),
dx_attr(line, ls),
escape_xml(line)
)
.unwrap();
}
}
writeln!(out, "</text>").unwrap();
}
fn text_style_attr(own: &AttrMap, vars: &VarTable, opts: &Options) -> String {
let mut decls: Vec<String> = Vec::new();
if let Some(v) = own.get("fill").or_else(|| own.get("color")) {
decls.push(format!("fill: {}", format_value(v, vars, opts)));
}
for prop in [
"font-family",
"font-weight",
"font-style",
"text-transform",
"text-decoration",
"text-shadow",
"opacity",
] {
if let Some(v) = own.get(prop) {
decls.push(format!("{}: {}", prop, format_value(v, vars, opts)));
}
}
if let Some(v) = own.get("font-size") {
decls.push(format!("font-size: {}px", format_value(v, vars, opts)));
}
if decls.is_empty() {
String::new()
} else {
format!(r#" style="{}""#, decls.join("; "))
}
}
fn dx_attr(line: &str, letter_spacing: f64) -> String {
let count = line.chars().count();
if letter_spacing == 0.0 || count < 2 {
return String::new();
}
let mut s = String::from(r#" dx="0"#);
for _ in 1..count {
s.push(' ');
s.push_str(&num(letter_spacing));
}
s.push('"');
s
}
fn node_style_attr(
n: &PlacedNode,
classes: &[String],
ruleset: &RuleSet,
vars: &VarTable,
opts: &Options,
) -> String {
let mut decls: Vec<(&str, String)> = Vec::new();
for (lini, css) in rules::PAINT_PROPS {
let value = match (*lini, n.shape) {
("fill", ShapeKind::Text) => n.attrs.get("fill").or_else(|| n.attrs.get("color")),
("color", ShapeKind::Text) => None,
_ => n.attrs.get(lini),
};
let Some(v) = value else { continue };
let formatted = values::css_value(lini, v, vars, opts);
if ruleset.provided(classes, css) != Some(formatted.as_str()) {
decls.push((css, formatted));
}
}
if n.attrs.get("stroke-style").is_some() {
let width = n.attrs.number("stroke-width").unwrap_or(0.0);
let dash = values::dasharray_value(&n.attrs, width);
let value = if dash.is_empty() {
"none".to_string()
} else {
dash
};
if ruleset.provided(classes, "stroke-dasharray") != Some(value.as_str()) {
decls.push(("stroke-dasharray", value));
}
}
style_attr_from(&decls)
}
fn in_layer_order(nodes: &[PlacedNode]) -> Vec<&PlacedNode> {
let mut order: Vec<&PlacedNode> = nodes.iter().collect();
order.sort_by(|a, b| eff_layer(a).total_cmp(&eff_layer(b)));
order
}
fn eff_layer(n: &PlacedNode) -> f64 {
n.attrs
.number("layer")
.unwrap_or(if crate::layout::is_pinned(&n.attrs) {
1.0
} else {
0.0
})
}
fn style_attr_from(decls: &[(&str, String)]) -> String {
if decls.is_empty() {
return String::new();
}
let body: Vec<String> = decls.iter().map(|(p, v)| format!("{}: {}", p, v)).collect();
format!(r#" style="{}""#, body.join("; "))
}
#[cfg(test)]
mod tests {
use super::*;
fn svg_for(src: &str) -> String {
let tokens = crate::lexer::lex(src).expect("lex");
let file = crate::syntax::parser::parse(&tokens).expect("parse");
let lowered = crate::desugar::desugar(&file).expect("desugar");
let program = crate::resolve::resolve_with_theme(&lowered, &[]).expect("resolve");
let mut laid = crate::layout::layout(&program).expect("layout");
gradients::lower(&mut laid);
render(&laid, &Options::default())
}
#[test]
fn root_fill_overrides_the_canvas_inline() {
let svg = svg_for("{ fill: #eef; }\nx |box|\n");
assert!(
svg.contains(r#"class="lini-canvas""#) && svg.contains(r##"style="fill: #eef""##),
"{svg}"
);
}
#[test]
fn canvas_rect_defaults_to_the_bg_var() {
let svg = svg_for("x |box|\n");
assert!(svg.contains(r#"<rect class="lini-canvas""#), "{svg}");
assert!(
svg.contains(".lini .lini-canvas { fill: var(--lini-bg); }"),
"{svg}"
);
}
#[test]
fn layer_lifts_a_node_above_later_source_order() {
let svg = svg_for("a |box| { layer: 5; }\nb |box|\n");
let ai = svg.find(r#"data-id="a""#).expect("a");
let bi = svg.find(r#"data-id="b""#).expect("b");
assert!(ai > bi, "a (layer 5) should paint after b: {svg}");
}
#[test]
fn equal_layer_keeps_source_order() {
let svg = svg_for("a |box|\nb |box|\n");
assert!(
svg.find(r#"data-id="a""#).unwrap() < svg.find(r#"data-id="b""#).unwrap(),
"{svg}"
);
}
#[test]
fn title_emits_a_title_child_on_the_node_g() {
let svg = svg_for("x |box| { title: \"a tooltip\"; }\n");
assert!(svg.contains("<title>a tooltip</title>"), "{svg}");
}
#[test]
fn no_title_element_without_a_title_prop() {
let svg = svg_for("x |box|\n");
assert!(!svg.contains("<title>"), "{svg}");
}
#[test]
fn shadow_flood_color_is_a_literal_in_live_mode() {
let svg = svg_for("x |box| { shadow: 4 }\n");
let flood = svg
.split("flood-color=\"")
.nth(1)
.and_then(|s| s.split('"').next())
.expect("a shadow flood-color");
assert!(
!flood.starts_with("var(") && !flood.contains("light-dark"),
"shadow tint must be a literal, got {flood:?}"
);
}
}