Skip to main content

hti_svg/
lib.rs

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    // ── Header ────────────────────────────────────────────────────────────────
9    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    // ── Defs ──────────────────────────────────────────────────────────────────
15    out.push_str("<defs>");
16
17    // Gradient defs
18    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    // Blur filter defs (box-shadow)
37    for f in &scene.blur_filters {
38        // Extend filter region to avoid clipping the blur
39        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    // Clip path defs
46    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    // Backdrop blur defs (glass effect).
61    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    // ── Scene nodes ───────────────────────────────────────────────────────────
74    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(_) => {} // already in defs
96                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
107// ─── Rect ─────────────────────────────────────────────────────────────────────
108
109fn 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            // gradient_id luôn có khi background là LinearGradient
191            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
241// ─── Image ────────────────────────────────────────────────────────────────────
242
243fn 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    // Tính placement rect và preserveAspectRatio dựa trên object-fit
251    let (ix, iy, iw, ih, par, extra_clip) = calc_object_fit(img);
252
253    // Nếu cover, cần clip thêm (trừ khi đã có clip_id)
254    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        // Emit inline clipPath — vì đây là per-image, dùng id duy nhất theo vị trí
257        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
274/// Tính (x, y, w, h, preserveAspectRatio, needs_extra_clip) cho image placement.
275fn 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    // Xác định SVG alignment string từ object-position (0.0–1.0)
281    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            // Fit trong bounds, letterbox
288            (
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                // Không biết intrinsic size → dùng slice với bounds
301                (
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                // Tính placement để cover bounds với đúng alignment
311                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
346// Workaround: ObjectFit::Contain/Cover need the PAR string.
347// We use 'static lifetime tricks. For correctness, use the full string.
348trait 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
361// ─── Text ─────────────────────────────────────────────────────────────────────
362
363fn 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; // first line baseline
408            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
435// ─── Helpers ──────────────────────────────────────────────────────────────────
436
437fn 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('&', "&amp;")
474        .replace('<', "&lt;")
475        .replace('>', "&gt;")
476        .replace('"', "&quot;")
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}