1use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
2use hti_core::*;
3
4pub fn emit_svg(scene: &Scene) -> String {
5 let vp = scene.viewport;
6 let mut out = String::with_capacity(64 * 1024);
7
8 out.push_str(&format!(
10 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}">"#,
11 vp.width, vp.height, vp.x, vp.y, vp.width, vp.height
12 ));
13
14 out.push_str("<defs>");
16
17 for gdef in &scene.gradient_defs {
19 let angle_rad = gdef.gradient.angle_deg.to_radians();
20 let (x1, y1, x2, y2) = gradient_coords(angle_rad);
21 out.push_str(&format!(
22 r#"<linearGradient id="{}" x1="{:.4}" y1="{:.4}" x2="{:.4}" y2="{:.4}" gradientUnits="objectBoundingBox">"#,
23 gdef.id, x1, y1, x2, y2
24 ));
25 for stop in &gdef.gradient.stops {
26 out.push_str(&format!(
27 r#"<stop offset="{:.4}" stop-color="{}" stop-opacity="{:.4}"/>"#,
28 stop.position,
29 stop.color.to_hex(),
30 stop.color.a as f32 / 255.0
31 ));
32 }
33 out.push_str("</linearGradient>");
34 }
35
36 for f in &scene.blur_filters {
38 out.push_str(&format!(
40 r#"<filter id="{}" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur in="SourceGraphic" stdDeviation="{:.2}"/></filter>"#,
41 f.id, f.std_deviation
42 ));
43 }
44
45 for node in &scene.nodes {
47 if let SceneNode::Clip(clip) = node {
48 out.push_str(&format!(
49 r#"<clipPath id="clip{}"><rect x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" rx="{:.2}"/></clipPath>"#,
50 clip.id,
51 clip.rect.x,
52 clip.rect.y,
53 clip.rect.width,
54 clip.rect.height,
55 clip.border_radius
56 ));
57 }
58 }
59
60 let has_backdrop = emit_backdrop_defs(&mut out, scene);
62 let drawable_count = scene
63 .nodes
64 .iter()
65 .filter(|n| !matches!(n, SceneNode::Clip(_)))
66 .count();
67 if has_backdrop {
68 emit_stack_defs(&mut out, drawable_count);
69 }
70
71 out.push_str("</defs>");
72
73 if has_backdrop {
75 let mut draw_idx = 0usize;
76 for node in &scene.nodes {
77 match node {
78 SceneNode::Clip(_) => {}
79 SceneNode::Rect(r) => {
80 if r.backdrop_blur_radius > 0.0 && draw_idx > 0 {
81 emit_backdrop_layer(&mut out, r, draw_idx);
82 }
83 emit_node_with_id(&mut out, node, draw_idx);
84 draw_idx += 1;
85 }
86 SceneNode::Image(_) | SceneNode::Text(_) => {
87 emit_node_with_id(&mut out, node, draw_idx);
88 draw_idx += 1;
89 }
90 }
91 }
92 } else {
93 for node in &scene.nodes {
94 match node {
95 SceneNode::Clip(_) => {} SceneNode::Rect(r) => emit_rect(&mut out, r),
97 SceneNode::Image(img) => emit_image(&mut out, img),
98 SceneNode::Text(t) => emit_text(&mut out, t),
99 }
100 }
101 }
102
103 out.push_str("</svg>");
104 out
105}
106
107fn emit_backdrop_defs(out: &mut String, scene: &Scene) -> bool {
110 let mut has_backdrop = false;
111 let mut draw_idx = 0usize;
112
113 for node in &scene.nodes {
114 match node {
115 SceneNode::Clip(_) => continue,
116 SceneNode::Rect(r) => {
117 if r.backdrop_blur_radius > 0.0 {
118 has_backdrop = true;
119 let std_dev = (r.backdrop_blur_radius / 2.0).max(0.01);
120 out.push_str(&format!(
121 r#"<filter id="backdrop_blur{}" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur in="SourceGraphic" stdDeviation="{:.2}"/></filter>"#,
122 draw_idx, std_dev
123 ));
124 out.push_str(&format!(
125 r#"<clipPath id="backdrop_clip{}"><rect x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" rx="{:.2}"/></clipPath>"#,
126 draw_idx,
127 r.bounds.x,
128 r.bounds.y,
129 r.bounds.width,
130 r.bounds.height,
131 r.border_radius
132 ));
133 }
134 }
135 SceneNode::Image(_) | SceneNode::Text(_) => {}
136 }
137 draw_idx += 1;
138 }
139
140 has_backdrop
141}
142
143fn emit_stack_defs(out: &mut String, drawable_count: usize) {
144 if drawable_count == 0 {
145 return;
146 }
147 out.push_str(r##"<g id="stack0"><use href="#node0"/></g>"##);
148 for i in 1..drawable_count {
149 out.push_str(&format!(
150 r##"<g id="stack{}"><use href="#stack{}"/><use href="#node{}"/></g>"##,
151 i,
152 i - 1,
153 i
154 ));
155 }
156}
157
158fn emit_backdrop_layer(out: &mut String, r: &RectSceneNode, draw_idx: usize) {
159 if draw_idx == 0 {
160 return;
161 }
162
163 let parent_clip = clip_attr_str(r.clip_id);
164 out.push_str(&format!(r#"<g{}>"#, parent_clip));
165 out.push_str(&format!(
166 r##"<g clip-path="url(#backdrop_clip{})"><g filter="url(#backdrop_blur{})"><use href="#stack{}"/></g></g>"##,
167 draw_idx,
168 draw_idx,
169 draw_idx - 1
170 ));
171 out.push_str("</g>\n");
172}
173
174fn emit_node_with_id(out: &mut String, node: &SceneNode, draw_idx: usize) {
175 out.push_str(&format!(r#"<g id="node{}">"#, draw_idx));
176 match node {
177 SceneNode::Rect(r) => emit_rect(out, r),
178 SceneNode::Image(img) => emit_image(out, img),
179 SceneNode::Text(t) => emit_text(out, t),
180 SceneNode::Clip(_) => {}
181 }
182 out.push_str("</g>\n");
183}
184
185fn emit_rect(out: &mut String, r: &RectSceneNode) {
186 let fill = match &r.background {
187 Background::None => "none".to_string(),
188 Background::Color(c) => c.to_hex(),
189 Background::LinearGradient(_) => {
190 r.gradient_id
192 .as_ref()
193 .map(|id| format!("url(#{})", id))
194 .unwrap_or_else(|| "none".to_string())
195 }
196 };
197
198 let clip_attr = clip_attr_str(r.clip_id);
199 let tf_attr = transform_attr_str(&r.transform);
200 let filter_attr = r
201 .filter_id
202 .as_ref()
203 .map(|id| format!(r#" filter="url(#{})" "#, id))
204 .unwrap_or_default();
205
206 if r.border_width > 0.0 {
207 out.push_str(&format!(
208 r#"<rect x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" rx="{:.2}" fill="{}" stroke="{}" stroke-width="{:.2}" opacity="{:.4}"{}{}{}/>
209"#,
210 r.bounds.x,
211 r.bounds.y,
212 r.bounds.width,
213 r.bounds.height,
214 r.border_radius,
215 fill,
216 r.border_color.to_hex(),
217 r.border_width,
218 r.opacity,
219 clip_attr,
220 tf_attr,
221 filter_attr
222 ));
223 } else {
224 out.push_str(&format!(
225 r#"<rect x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" rx="{:.2}" fill="{}" opacity="{:.4}"{}{}{}/>
226"#,
227 r.bounds.x,
228 r.bounds.y,
229 r.bounds.width,
230 r.bounds.height,
231 r.border_radius,
232 fill,
233 r.opacity,
234 clip_attr,
235 tf_attr,
236 filter_attr
237 ));
238 }
239}
240
241fn emit_image(out: &mut String, img: &ImageSceneNode) {
244 let b64 = BASE64.encode(&img.image_bytes);
245 let href = format!("data:{};base64,{}", img.image_mime, b64);
246
247 let clip_attr = clip_attr_str(img.clip_id);
248 let tf_attr = transform_attr_str(&img.transform);
249
250 let (ix, iy, iw, ih, par, extra_clip) = calc_object_fit(img);
252
253 let image_clip = if extra_clip && img.clip_id.is_none() && img.border_radius == 0.0 {
255 let cid = format!("imgc_{:.0}_{:.0}", img.bounds.x, img.bounds.y);
256 out.push_str(&format!(
258 r#"<defs><clipPath id="{cid}"><rect x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}"/></clipPath></defs>
259"#,
260 img.bounds.x, img.bounds.y, img.bounds.width, img.bounds.height
261 ));
262 format!(r#" clip-path="url(#{cid})""#)
263 } else {
264 clip_attr.clone()
265 };
266
267 out.push_str(&format!(
268 r#"<image x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" href="{}" preserveAspectRatio="{}" opacity="{:.4}"{}{}/>
269"#,
270 ix, iy, iw, ih, href, par, img.opacity, image_clip, tf_attr
271 ));
272}
273
274fn calc_object_fit(img: &ImageSceneNode) -> (f32, f32, f32, f32, &'static str, bool) {
276 let b = img.bounds;
277 let (iw, ih) = (img.intrinsic_width, img.intrinsic_height);
278 let (px, py) = img.object_position;
279
280 let align = position_to_align(px, py);
282
283 match img.object_fit {
284 ObjectFit::Fill => (b.x, b.y, b.w(), b.h(), "none", false),
285
286 ObjectFit::Contain => {
287 (
289 b.x,
290 b.y,
291 b.w(),
292 b.h(),
293 Box::leak(format!("{} meet", align).into_boxed_str()),
294 false,
295 )
296 }
297
298 ObjectFit::Cover => {
299 if iw <= 0.0 || ih <= 0.0 {
300 (
302 b.x,
303 b.y,
304 b.w(),
305 b.h(),
306 Box::leak(format!("{} slice", align).into_boxed_str()),
307 true,
308 )
309 } else {
310 let scale = f32::max(b.w() / iw, b.h() / ih);
312 let sw = iw * scale;
313 let sh = ih * scale;
314 let x = b.x + (b.w() - sw) * px;
315 let y = b.y + (b.h() - sh) * py;
316 (x, y, sw, sh, "none", true)
317 }
318 }
319 }
320}
321
322fn position_to_align(px: f32, py: f32) -> &'static str {
323 match (quantize(px), quantize(py)) {
324 (0, 0) => "xMinYMin",
325 (1, 0) => "xMidYMin",
326 (2, 0) => "xMaxYMin",
327 (0, 1) => "xMinYMid",
328 (1, 1) => "xMidYMid",
329 (2, 1) => "xMaxYMid",
330 (0, 2) => "xMinYMax",
331 (1, 2) => "xMidYMax",
332 _ => "xMaxYMax",
333 }
334}
335
336fn quantize(v: f32) -> u8 {
337 if v < 0.33 {
338 0
339 } else if v < 0.67 {
340 1
341 } else {
342 2
343 }
344}
345
346trait BoundsExt {
349 fn w(&self) -> f32;
350 fn h(&self) -> f32;
351}
352impl BoundsExt for Rect {
353 fn w(&self) -> f32 {
354 self.width
355 }
356 fn h(&self) -> f32 {
357 self.height
358 }
359}
360
361fn emit_text(out: &mut String, t: &TextSceneNode) {
364 let clip_attr = clip_attr_str(t.clip_id);
365 let tf_attr = transform_attr_str(&t.transform);
366
367 let font_weight = match t.font_weight {
368 FontWeight::Normal => "normal".to_string(),
369 FontWeight::Bold => "bold".to_string(),
370 FontWeight::W(w) => w.to_string(),
371 };
372 let font_family = t.font_family.as_deref().unwrap_or("Arial");
373 let (text_anchor, x_offset) = match t.text_align {
374 TextAlign::Left => ("start", t.bounds.x),
375 TextAlign::Center => ("middle", t.bounds.x + t.bounds.width / 2.0),
376 TextAlign::Right => ("end", t.bounds.x + t.bounds.width),
377 };
378
379 out.push_str(&format!(
380 r#"<text x="{:.2}" font-family="{}" font-size="{:.2}" font-weight="{}" fill="{}" text-anchor="{}" opacity="{:.4}"{}{}>"#,
381 x_offset,
382 font_family,
383 t.font_size,
384 font_weight,
385 t.color.to_hex(),
386 text_anchor,
387 t.opacity,
388 clip_attr,
389 tf_attr
390 ));
391
392 let lines = if t.text_overflow == TextOverflow::Ellipsis && t.white_space == WhiteSpace::NoWrap
393 {
394 let mut v = t.lines.clone();
395 if let Some(first) = v.first_mut() {
396 *first = truncate_ellipsis(first, t.bounds.width, t.font_size);
397 }
398 v.truncate(1);
399 v
400 } else {
401 t.lines.clone()
402 };
403
404 for (i, line) in lines.iter().enumerate() {
405 let escaped = xml_escape(line);
406 if i == 0 {
407 let y = t.bounds.y + t.font_size; if t.letter_spacing != 0.0 {
409 out.push_str(&format!(
410 r#"<tspan y="{:.2}" letter-spacing="{:.2}">{}</tspan>"#,
411 y, t.letter_spacing, escaped
412 ));
413 } else {
414 out.push_str(&format!(r#"<tspan y="{:.2}">{}</tspan>"#, y, escaped));
415 }
416 } else {
417 let dy = t.line_height_px;
418 if t.letter_spacing != 0.0 {
419 out.push_str(&format!(
420 r#"<tspan x="{:.2}" dy="{:.2}" letter-spacing="{:.2}">{}</tspan>"#,
421 x_offset, dy, t.letter_spacing, escaped
422 ));
423 } else {
424 out.push_str(&format!(
425 r#"<tspan x="{:.2}" dy="{:.2}">{}</tspan>"#,
426 x_offset, dy, escaped
427 ));
428 }
429 }
430 }
431
432 out.push_str("</text>\n");
433}
434
435fn clip_attr_str(clip_id: Option<u32>) -> String {
438 clip_id
439 .map(|id| format!(r#" clip-path="url(#clip{})""#, id))
440 .unwrap_or_default()
441}
442
443fn transform_attr_str(t: &Transform) -> String {
444 let mut parts: Vec<String> = Vec::new();
445 if t.translate_x != 0.0 || t.translate_y != 0.0 {
446 parts.push(format!(
447 "translate({:.2},{:.2})",
448 t.translate_x, t.translate_y
449 ));
450 }
451 if t.scale_x != 1.0 || t.scale_y != 1.0 {
452 parts.push(format!("scale({:.4},{:.4})", t.scale_x, t.scale_y));
453 }
454 if t.rotate_deg != 0.0 {
455 parts.push(format!("rotate({:.2})", t.rotate_deg));
456 }
457 if parts.is_empty() {
458 String::new()
459 } else {
460 format!(r#" transform="{}""#, parts.join(" "))
461 }
462}
463
464fn gradient_coords(angle_rad: f32) -> (f32, f32, f32, f32) {
465 let x1 = 0.5 - 0.5 * angle_rad.sin();
466 let y1 = 0.5 + 0.5 * angle_rad.cos();
467 let x2 = 0.5 + 0.5 * angle_rad.sin();
468 let y2 = 0.5 - 0.5 * angle_rad.cos();
469 (x1, y1, x2, y2)
470}
471
472fn xml_escape(s: &str) -> String {
473 s.replace('&', "&")
474 .replace('<', "<")
475 .replace('>', ">")
476 .replace('"', """)
477}
478
479fn truncate_ellipsis(text: &str, max_width: f32, font_size: f32) -> String {
480 let approx_char_w = font_size * 0.55;
481 let max_chars = (max_width / approx_char_w).floor() as usize;
482 let chars: Vec<char> = text.chars().collect();
483 if chars.len() <= max_chars {
484 return text.to_string();
485 }
486 let s: String = chars[..max_chars.saturating_sub(1)].iter().collect();
487 format!("{}…", s)
488}
489
490trait ColorHex {
491 fn to_hex(&self) -> String;
492}
493impl ColorHex for Color {
494 fn to_hex(&self) -> String {
495 format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
496 }
497}