use super::intern::IdTable;
use super::values::{format_value, num};
use crate::Options;
use crate::layout::{GradientDef, GradientKind, HatchDef, LaidOut, PlacedNode};
use crate::ledger::consts::{HATCH_LINE_WIDTH, HATCH_PITCH};
use crate::resolve::{AttrMap, ResolvedValue, VarTable};
use std::fmt::Write;
fn parse(v: &ResolvedValue) -> Option<GradientDef> {
let ResolvedValue::Call(c) = v else {
return None;
};
let (kind, stops) = match c.name.as_str() {
"gradient" => (GradientKind::Linear(135.0), c.args.clone()),
"linear-gradient" => {
let angle = c.args.first()?.as_number()?;
(GradientKind::Linear(angle), c.args[1..].to_vec())
}
"radial-gradient" => (GradientKind::Radial, c.args.clone()),
_ => return None,
};
(stops.len() >= 2).then_some(GradientDef { kind, stops })
}
fn key(g: &GradientDef) -> String {
let kind = match g.kind {
GradientKind::Linear(a) => format!("L{}", num(a)),
GradientKind::Radial => "R".to_string(),
};
format!("{kind}|{:?}", g.stops)
}
#[derive(Default)]
struct Interner {
gradients: IdTable<String, GradientDef>,
hatches: IdTable<String, HatchDef>,
clips: Vec<f64>,
}
pub(crate) fn lower(laid: &mut LaidOut) {
let mut it = Interner::default();
for node in &mut laid.nodes {
lower_node(node, &mut it);
}
for link in &mut laid.links {
lower_attrs(&mut link.attrs, &mut it);
}
for (_, attrs) in &mut laid.sheet.class_rules {
lower_attrs(attrs, &mut it);
}
lower_attrs(&mut laid.sheet.link_defaults, &mut it);
if let Some(fill) = &mut laid.canvas_fill {
rewrite(fill, &mut it);
}
laid.gradients = it.gradients.into_values();
laid.hatches = it.hatches.into_values();
laid.clips = it.clips;
}
fn lower_node(node: &mut PlacedNode, it: &mut Interner) {
lower_attrs(&mut node.attrs, it);
lower_clip(&mut node.attrs, it);
for child in &mut node.children {
lower_node(child, it);
}
}
fn lower_clip(attrs: &mut AttrMap, it: &mut Interner) {
let Some(ResolvedValue::Number(r)) = attrs.get("clip") else {
return;
};
let r = *r;
let idx = it
.clips
.iter()
.position(|e| (e - r).abs() < 1e-9)
.unwrap_or_else(|| {
it.clips.push(r);
it.clips.len() - 1
});
attrs.insert(
"clip",
ResolvedValue::RawCss(format!("url(#lini-clip-{})", idx + 1)),
);
}
fn lower_attrs(attrs: &mut AttrMap, it: &mut Interner) {
for value in attrs.map.values_mut() {
rewrite(value, it);
}
}
fn rewrite(value: &mut ResolvedValue, it: &mut Interner) {
if let Some(g) = parse(value) {
let idx = it.gradients.intern(key(&g), || g);
*value = ResolvedValue::RawCss(format!("url(#lini-gradient-{})", idx + 1));
return;
}
if let Some(h) = parse_hatch(value) {
let idx = it.hatches.intern(hatch_key(&h), || h);
*value = ResolvedValue::RawCss(format!("url(#lini-hatch-{})", idx + 1));
}
}
fn parse_hatch(v: &ResolvedValue) -> Option<HatchDef> {
let ResolvedValue::Call(c) = v else {
return None;
};
if c.name != "hatch" {
return None;
}
let angles: Vec<f64> = match c.args.first()? {
ResolvedValue::Number(a) => vec![*a],
ResolvedValue::Tuple(xs) => xs.iter().filter_map(ResolvedValue::as_number).collect(),
_ => return None,
};
if angles.is_empty() {
return None;
}
let pitch = c
.args
.get(1)
.and_then(ResolvedValue::as_number)
.filter(|p| *p > 0.0)
.unwrap_or(HATCH_PITCH);
let color = c.args.get(2).cloned().unwrap_or(ResolvedValue::LiveVar {
name: "stroke".into(),
raw: false,
});
Some(HatchDef {
angles,
pitch,
color,
})
}
fn hatch_key(h: &HatchDef) -> String {
format!("{:?}|{}|{:?}", h.angles, num(h.pitch), h.color)
}
pub(crate) fn emit_defs(laid: &LaidOut, out: &mut String, opts: &Options) {
emit_gradients(laid, out, opts);
emit_hatches(laid, out, opts);
emit_clips(laid, out);
}
fn emit_clips(laid: &LaidOut, out: &mut String) {
for (i, r) in laid.clips.iter().enumerate() {
let _ = writeln!(
out,
r#" <clipPath id="lini-clip-{}" clipPathUnits="userSpaceOnUse"><circle r="{}"/></clipPath>"#,
i + 1,
num(*r)
);
}
}
fn emit_hatches(laid: &LaidOut, out: &mut String, opts: &Options) {
for (i, h) in laid.hatches.iter().enumerate() {
let p = h.pitch;
let lw = num(HATCH_LINE_WIDTH);
let color = format_value(&h.color, &laid.vars, opts);
let paint = if opts.static_mode {
format!(r#"stroke="{color}""#)
} else {
format!(r#"style="stroke: {color}""#)
};
let mut lines = String::new();
let base = h.angles.first().copied().unwrap_or(45.0);
for a in &h.angles {
let d = (a - base).rem_euclid(180.0);
if d < 1e-6 || (d - 180.0).abs() < 1e-6 {
write!(
lines,
r#"<line x1="{m}" y1="0" x2="{m}" y2="{p}"/>"#,
m = num(p / 2.0),
p = num(p),
)
.unwrap();
} else if (d - 90.0).abs() < 1e-6 {
write!(
lines,
r#"<line x1="0" y1="{m}" x2="{p}" y2="{m}"/>"#,
m = num(p / 2.0),
p = num(p),
)
.unwrap();
} else {
let rad = d.to_radians();
let (dx, dy) = (rad.sin() * p, -rad.cos() * p);
write!(
lines,
r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}"/>"#,
x1 = num(p / 2.0 - dx),
y1 = num(p / 2.0 - dy),
x2 = num(p / 2.0 + dx),
y2 = num(p / 2.0 + dy),
)
.unwrap();
}
}
writeln!(
out,
r#" <pattern id="lini-hatch-{}" patternUnits="userSpaceOnUse" width="{}" height="{}" patternTransform="rotate({})"><g {paint} stroke-width="{lw}">{}</g></pattern>"#,
i + 1,
num(p),
num(p),
num(base),
lines,
)
.unwrap();
}
}
fn emit_gradients(laid: &LaidOut, out: &mut String, opts: &Options) {
for (i, g) in laid.gradients.iter().enumerate() {
let id = format!("lini-gradient-{}", i + 1);
let stops = stops_svg(g, &laid.vars, opts);
match g.kind {
GradientKind::Linear(angle) => writeln!(
out,
r#" <linearGradient id="{}" gradientTransform="rotate({} 0.5 0.5)">{}</linearGradient>"#,
id,
num(angle - 90.0),
stops,
),
GradientKind::Radial => {
writeln!(out, r#" <radialGradient id="{}">{}</radialGradient>"#, id, stops)
}
}
.unwrap();
}
}
fn stops_svg(g: &GradientDef, vars: &VarTable, opts: &Options) -> String {
let n = g.stops.len();
let mut out = String::new();
for (i, stop) in g.stops.iter().enumerate() {
let offset = if n <= 1 {
0.0
} else {
i as f64 / (n as f64 - 1.0) * 100.0
};
let color = format_value(stop, vars, opts);
if opts.static_mode {
write!(
out,
r#"<stop offset="{}%" stop-color="{}"/>"#,
num(offset),
color
)
} else {
write!(
out,
r#"<stop offset="{}%" style="stop-color: {}"/>"#,
num(offset),
color
)
}
.unwrap();
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::resolve::ResolvedCall;
fn grad(name: &str, args: Vec<ResolvedValue>) -> ResolvedValue {
ResolvedValue::Call(ResolvedCall {
name: name.into(),
args,
})
}
fn hue(name: &str) -> ResolvedValue {
ResolvedValue::LiveVar {
name: name.into(),
raw: false,
}
}
#[test]
fn gradient_defaults_to_135_linear() {
let g = parse(&grad("gradient", vec![hue("teal"), hue("sky")])).unwrap();
assert!(matches!(g.kind, GradientKind::Linear(a) if a == 135.0));
assert_eq!(g.stops.len(), 2);
}
#[test]
fn linear_gradient_takes_a_leading_angle() {
let g = parse(&grad(
"linear-gradient",
vec![ResolvedValue::Number(60.0), hue("rose"), hue("amber")],
))
.unwrap();
assert!(matches!(g.kind, GradientKind::Linear(a) if a == 60.0));
assert_eq!(g.stops.len(), 2);
}
#[test]
fn radial_and_multi_stop() {
let g = parse(&grad(
"radial-gradient",
vec![hue("rose"), hue("amber"), hue("sky")],
))
.unwrap();
assert!(matches!(g.kind, GradientKind::Radial));
assert_eq!(g.stops.len(), 3);
}
#[test]
fn fewer_than_two_stops_is_not_a_gradient() {
assert!(parse(&grad("gradient", vec![hue("teal")])).is_none());
}
#[test]
fn non_gradient_call_is_ignored() {
assert!(parse(&grad("rgb", vec![ResolvedValue::Number(1.0)])).is_none());
}
#[test]
fn hatch_parses_its_forms_and_dedups() {
let h = parse_hatch(&grad("hatch", vec![ResolvedValue::Number(45.0)])).unwrap();
assert_eq!((h.angles.as_slice(), h.pitch), ([45.0].as_slice(), 6.0));
assert!(matches!(&h.color, ResolvedValue::LiveVar { name, .. } if name == "stroke"));
let cross = parse_hatch(&grad(
"hatch",
vec![
ResolvedValue::Tuple(vec![
ResolvedValue::Number(45.0),
ResolvedValue::Number(-45.0),
]),
ResolvedValue::Number(4.0),
],
))
.unwrap();
assert_eq!(cross.angles, vec![45.0, -45.0]);
assert_eq!(cross.pitch, 4.0);
let mut it = Interner::default();
let mut a = grad("hatch", vec![ResolvedValue::Number(45.0)]);
let mut b = grad("hatch", vec![ResolvedValue::Number(45.0)]);
let mut c = grad(
"hatch",
vec![ResolvedValue::Number(45.0), ResolvedValue::Number(4.0)],
);
rewrite(&mut a, &mut it);
rewrite(&mut b, &mut it);
rewrite(&mut c, &mut it);
assert_eq!(
it.hatches.values().len(),
2,
"45/6 dedups; 45/4 is distinct"
);
assert!(matches!(&a, ResolvedValue::RawCss(s) if s == "url(#lini-hatch-1)"));
assert!(matches!(&b, ResolvedValue::RawCss(s) if s == "url(#lini-hatch-1)"));
assert!(matches!(&c, ResolvedValue::RawCss(s) if s == "url(#lini-hatch-2)"));
}
#[test]
fn identical_gradients_share_one_id() {
let mut it = Interner::default();
let mut a = grad("gradient", vec![hue("teal"), hue("sky")]);
let mut b = grad("gradient", vec![hue("teal"), hue("sky")]);
rewrite(&mut a, &mut it);
rewrite(&mut b, &mut it);
assert_eq!(
it.gradients.values().len(),
1,
"identical gradients must dedup"
);
assert!(matches!(&a, ResolvedValue::RawCss(s) if s == "url(#lini-gradient-1)"));
assert!(matches!(&b, ResolvedValue::RawCss(s) if s == "url(#lini-gradient-1)"));
}
}