use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use hti_core::*;
pub fn emit_svg(scene: &Scene) -> String {
let vp = scene.viewport;
let mut out = String::with_capacity(64 * 1024);
out.push_str(&format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="{:.2}" height="{:.2}" viewBox="{:.2} {:.2} {:.2} {:.2}">"#,
vp.width, vp.height, vp.x, vp.y, vp.width, vp.height
));
out.push_str("<defs>");
for gdef in &scene.gradient_defs {
let angle_rad = gdef.gradient.angle_deg.to_radians();
let (x1, y1, x2, y2) = gradient_coords(angle_rad);
out.push_str(&format!(
r#"<linearGradient id="{}" x1="{:.4}" y1="{:.4}" x2="{:.4}" y2="{:.4}" gradientUnits="objectBoundingBox">"#,
gdef.id, x1, y1, x2, y2
));
for stop in &gdef.gradient.stops {
out.push_str(&format!(
r#"<stop offset="{:.4}" stop-color="{}" stop-opacity="{:.4}"/>"#,
stop.position,
stop.color.to_hex(),
stop.color.a as f32 / 255.0
));
}
out.push_str("</linearGradient>");
}
for f in &scene.blur_filters {
out.push_str(&format!(
r#"<filter id="{}" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur in="SourceGraphic" stdDeviation="{:.2}"/></filter>"#,
f.id, f.std_deviation
));
}
for node in &scene.nodes {
if let SceneNode::Clip(clip) = node {
out.push_str(&format!(
r#"<clipPath id="clip{}"><rect x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" rx="{:.2}"/></clipPath>"#,
clip.id,
clip.rect.x,
clip.rect.y,
clip.rect.width,
clip.rect.height,
clip.border_radius
));
}
}
let has_backdrop = emit_backdrop_defs(&mut out, scene);
let drawable_count = scene
.nodes
.iter()
.filter(|n| !matches!(n, SceneNode::Clip(_)))
.count();
if has_backdrop {
emit_stack_defs(&mut out, drawable_count);
}
out.push_str("</defs>");
if has_backdrop {
let mut draw_idx = 0usize;
for node in &scene.nodes {
match node {
SceneNode::Clip(_) => {}
SceneNode::Rect(r) => {
if r.backdrop_blur_radius > 0.0 && draw_idx > 0 {
emit_backdrop_layer(&mut out, r, draw_idx);
}
emit_node_with_id(&mut out, node, draw_idx);
draw_idx += 1;
}
SceneNode::Image(_) | SceneNode::Text(_) => {
emit_node_with_id(&mut out, node, draw_idx);
draw_idx += 1;
}
}
}
} else {
for node in &scene.nodes {
match node {
SceneNode::Clip(_) => {} SceneNode::Rect(r) => emit_rect(&mut out, r),
SceneNode::Image(img) => emit_image(&mut out, img),
SceneNode::Text(t) => emit_text(&mut out, t),
}
}
}
out.push_str("</svg>");
out
}
fn emit_backdrop_defs(out: &mut String, scene: &Scene) -> bool {
let mut has_backdrop = false;
let mut draw_idx = 0usize;
for node in &scene.nodes {
match node {
SceneNode::Clip(_) => continue,
SceneNode::Rect(r) => {
if r.backdrop_blur_radius > 0.0 {
has_backdrop = true;
let std_dev = (r.backdrop_blur_radius / 2.0).max(0.01);
out.push_str(&format!(
r#"<filter id="backdrop_blur{}" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur in="SourceGraphic" stdDeviation="{:.2}"/></filter>"#,
draw_idx, std_dev
));
out.push_str(&format!(
r#"<clipPath id="backdrop_clip{}"><rect x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" rx="{:.2}"/></clipPath>"#,
draw_idx,
r.bounds.x,
r.bounds.y,
r.bounds.width,
r.bounds.height,
r.border_radius
));
}
}
SceneNode::Image(_) | SceneNode::Text(_) => {}
}
draw_idx += 1;
}
has_backdrop
}
fn emit_stack_defs(out: &mut String, drawable_count: usize) {
if drawable_count == 0 {
return;
}
out.push_str(r##"<g id="stack0"><use href="#node0"/></g>"##);
for i in 1..drawable_count {
out.push_str(&format!(
r##"<g id="stack{}"><use href="#stack{}"/><use href="#node{}"/></g>"##,
i,
i - 1,
i
));
}
}
fn emit_backdrop_layer(out: &mut String, r: &RectSceneNode, draw_idx: usize) {
if draw_idx == 0 {
return;
}
let parent_clip = clip_attr_str(r.clip_id);
out.push_str(&format!(r#"<g{}>"#, parent_clip));
out.push_str(&format!(
r##"<g clip-path="url(#backdrop_clip{})"><g filter="url(#backdrop_blur{})"><use href="#stack{}"/></g></g>"##,
draw_idx,
draw_idx,
draw_idx - 1
));
out.push_str("</g>\n");
}
fn emit_node_with_id(out: &mut String, node: &SceneNode, draw_idx: usize) {
out.push_str(&format!(r#"<g id="node{}">"#, draw_idx));
match node {
SceneNode::Rect(r) => emit_rect(out, r),
SceneNode::Image(img) => emit_image(out, img),
SceneNode::Text(t) => emit_text(out, t),
SceneNode::Clip(_) => {}
}
out.push_str("</g>\n");
}
fn emit_rect(out: &mut String, r: &RectSceneNode) {
let fill = match &r.background {
Background::None => "none".to_string(),
Background::Color(c) => c.to_hex(),
Background::LinearGradient(_) => {
r.gradient_id
.as_ref()
.map(|id| format!("url(#{})", id))
.unwrap_or_else(|| "none".to_string())
}
};
let clip_attr = clip_attr_str(r.clip_id);
let tf_attr = transform_attr_str(&r.transform);
let filter_attr = r
.filter_id
.as_ref()
.map(|id| format!(r#" filter="url(#{})" "#, id))
.unwrap_or_default();
if r.border_width > 0.0 {
out.push_str(&format!(
r#"<rect x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" rx="{:.2}" fill="{}" stroke="{}" stroke-width="{:.2}" opacity="{:.4}"{}{}{}/>
"#,
r.bounds.x,
r.bounds.y,
r.bounds.width,
r.bounds.height,
r.border_radius,
fill,
r.border_color.to_hex(),
r.border_width,
r.opacity,
clip_attr,
tf_attr,
filter_attr
));
} else {
out.push_str(&format!(
r#"<rect x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" rx="{:.2}" fill="{}" opacity="{:.4}"{}{}{}/>
"#,
r.bounds.x,
r.bounds.y,
r.bounds.width,
r.bounds.height,
r.border_radius,
fill,
r.opacity,
clip_attr,
tf_attr,
filter_attr
));
}
}
fn emit_image(out: &mut String, img: &ImageSceneNode) {
let b64 = BASE64.encode(&img.image_bytes);
let href = format!("data:{};base64,{}", img.image_mime, b64);
let clip_attr = clip_attr_str(img.clip_id);
let tf_attr = transform_attr_str(&img.transform);
let (ix, iy, iw, ih, par, extra_clip) = calc_object_fit(img);
let image_clip = if extra_clip && img.clip_id.is_none() && img.border_radius == 0.0 {
let cid = format!("imgc_{:.0}_{:.0}", img.bounds.x, img.bounds.y);
out.push_str(&format!(
r#"<defs><clipPath id="{cid}"><rect x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}"/></clipPath></defs>
"#,
img.bounds.x, img.bounds.y, img.bounds.width, img.bounds.height
));
format!(r#" clip-path="url(#{cid})""#)
} else {
clip_attr.clone()
};
out.push_str(&format!(
r#"<image x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" href="{}" preserveAspectRatio="{}" opacity="{:.4}"{}{}/>
"#,
ix, iy, iw, ih, href, par, img.opacity, image_clip, tf_attr
));
}
fn calc_object_fit(img: &ImageSceneNode) -> (f32, f32, f32, f32, &'static str, bool) {
let b = img.bounds;
let (iw, ih) = (img.intrinsic_width, img.intrinsic_height);
let (px, py) = img.object_position;
let align = position_to_align(px, py);
match img.object_fit {
ObjectFit::Fill => (b.x, b.y, b.w(), b.h(), "none", false),
ObjectFit::Contain => {
(
b.x,
b.y,
b.w(),
b.h(),
Box::leak(format!("{} meet", align).into_boxed_str()),
false,
)
}
ObjectFit::Cover => {
if iw <= 0.0 || ih <= 0.0 {
(
b.x,
b.y,
b.w(),
b.h(),
Box::leak(format!("{} slice", align).into_boxed_str()),
true,
)
} else {
let scale = f32::max(b.w() / iw, b.h() / ih);
let sw = iw * scale;
let sh = ih * scale;
let x = b.x + (b.w() - sw) * px;
let y = b.y + (b.h() - sh) * py;
(x, y, sw, sh, "none", true)
}
}
}
}
fn position_to_align(px: f32, py: f32) -> &'static str {
match (quantize(px), quantize(py)) {
(0, 0) => "xMinYMin",
(1, 0) => "xMidYMin",
(2, 0) => "xMaxYMin",
(0, 1) => "xMinYMid",
(1, 1) => "xMidYMid",
(2, 1) => "xMaxYMid",
(0, 2) => "xMinYMax",
(1, 2) => "xMidYMax",
_ => "xMaxYMax",
}
}
fn quantize(v: f32) -> u8 {
if v < 0.33 {
0
} else if v < 0.67 {
1
} else {
2
}
}
trait BoundsExt {
fn w(&self) -> f32;
fn h(&self) -> f32;
}
impl BoundsExt for Rect {
fn w(&self) -> f32 {
self.width
}
fn h(&self) -> f32 {
self.height
}
}
fn emit_text(out: &mut String, t: &TextSceneNode) {
let clip_attr = clip_attr_str(t.clip_id);
let tf_attr = transform_attr_str(&t.transform);
let font_weight = match t.font_weight {
FontWeight::Normal => "normal".to_string(),
FontWeight::Bold => "bold".to_string(),
FontWeight::W(w) => w.to_string(),
};
let font_family = t.font_family.as_deref().unwrap_or("Arial");
let (text_anchor, x_offset) = match t.text_align {
TextAlign::Left => ("start", t.bounds.x),
TextAlign::Center => ("middle", t.bounds.x + t.bounds.width / 2.0),
TextAlign::Right => ("end", t.bounds.x + t.bounds.width),
};
out.push_str(&format!(
r#"<text x="{:.2}" font-family="{}" font-size="{:.2}" font-weight="{}" fill="{}" text-anchor="{}" opacity="{:.4}"{}{}>"#,
x_offset,
font_family,
t.font_size,
font_weight,
t.color.to_hex(),
text_anchor,
t.opacity,
clip_attr,
tf_attr
));
let lines = if t.text_overflow == TextOverflow::Ellipsis && t.white_space == WhiteSpace::NoWrap
{
let mut v = t.lines.clone();
if let Some(first) = v.first_mut() {
*first = truncate_ellipsis(first, t.bounds.width, t.font_size);
}
v.truncate(1);
v
} else {
t.lines.clone()
};
for (i, line) in lines.iter().enumerate() {
let escaped = xml_escape(line);
if i == 0 {
let y = t.bounds.y + t.font_size; if t.letter_spacing != 0.0 {
out.push_str(&format!(
r#"<tspan y="{:.2}" letter-spacing="{:.2}">{}</tspan>"#,
y, t.letter_spacing, escaped
));
} else {
out.push_str(&format!(r#"<tspan y="{:.2}">{}</tspan>"#, y, escaped));
}
} else {
let dy = t.line_height_px;
if t.letter_spacing != 0.0 {
out.push_str(&format!(
r#"<tspan x="{:.2}" dy="{:.2}" letter-spacing="{:.2}">{}</tspan>"#,
x_offset, dy, t.letter_spacing, escaped
));
} else {
out.push_str(&format!(
r#"<tspan x="{:.2}" dy="{:.2}">{}</tspan>"#,
x_offset, dy, escaped
));
}
}
}
out.push_str("</text>\n");
}
fn clip_attr_str(clip_id: Option<u32>) -> String {
clip_id
.map(|id| format!(r#" clip-path="url(#clip{})""#, id))
.unwrap_or_default()
}
fn transform_attr_str(t: &Transform) -> String {
let mut parts: Vec<String> = Vec::new();
if t.translate_x != 0.0 || t.translate_y != 0.0 {
parts.push(format!(
"translate({:.2},{:.2})",
t.translate_x, t.translate_y
));
}
if t.scale_x != 1.0 || t.scale_y != 1.0 {
parts.push(format!("scale({:.4},{:.4})", t.scale_x, t.scale_y));
}
if t.rotate_deg != 0.0 {
parts.push(format!("rotate({:.2})", t.rotate_deg));
}
if parts.is_empty() {
String::new()
} else {
format!(r#" transform="{}""#, parts.join(" "))
}
}
fn gradient_coords(angle_rad: f32) -> (f32, f32, f32, f32) {
let x1 = 0.5 - 0.5 * angle_rad.sin();
let y1 = 0.5 + 0.5 * angle_rad.cos();
let x2 = 0.5 + 0.5 * angle_rad.sin();
let y2 = 0.5 - 0.5 * angle_rad.cos();
(x1, y1, x2, y2)
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn truncate_ellipsis(text: &str, max_width: f32, font_size: f32) -> String {
let approx_char_w = font_size * 0.55;
let max_chars = (max_width / approx_char_w).floor() as usize;
let chars: Vec<char> = text.chars().collect();
if chars.len() <= max_chars {
return text.to_string();
}
let s: String = chars[..max_chars.saturating_sub(1)].iter().collect();
format!("{}…", s)
}
trait ColorHex {
fn to_hex(&self) -> String;
}
impl ColorHex for Color {
fn to_hex(&self) -> String {
format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
}
}