gioser

tawasuyu/brahman

Rust 483TOML 191Markdown 50JSON 176777 Definiciones

⎇ 1fdbd358c8cd914fb767cbee1483d79935aa1fec crates/modules/pineal/export/src/svg.rs Rust · 223 líneas · 7.2 KB Historia Quién tocó qué crudo

feat(pineal): cierra stub export — PlanRecorder + exporter SVG b75e22fa9 · sergio ·

1//! Exporter SVG: un [`RenderPlan`] → documento SVG completo.
2//!
3//! El mismo painter que dibuja en pantalla (vía el trait `Canvas`) se
4//! graba con un `PlanRecorder` y el plan resultante se vuelca acá. Un
5//! solo camino de código para screen y export.
6//!
7//! v1: los comandos de clip (`PushClip`/`PopClip`) se ignoran — el
8//! recorte no es crítico para la mayoría de exports y SVG `clipPath`
9//! agrega complejidad de IDs. Se puede agregar después sin romper API.
10
11use pineal_render::{Color, RenderCmd, RenderPlan};
12use std::fmt::Write;
13
14/// Convierte un `RenderPlan` a un documento SVG de `width × height`.
15pub fn to_svg(plan: &RenderPlan, width: f32, height: f32) -> String {
16    let mut s = String::with_capacity(256 + plan.cmds.len() * 80);
17    let _ = write!(
18        s,
19        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{width}\" \
20         height=\"{height}\" viewBox=\"0 0 {width} {height}\">"
21    );
22    for cmd in &plan.cmds {
23        emit_cmd(&mut s, cmd);
24    }
25    s.push_str("</svg>");
26    s
27}
28
29fn emit_cmd(s: &mut String, cmd: &RenderCmd) {
30    match cmd {
31        // v1: clips ignorados (ver doc del módulo).
32        RenderCmd::PushClip(_) | RenderCmd::PopClip => {}
33
34        RenderCmd::FillRect { rect, color } => {
35            let (c, a) = svg_color(*color);
36            let _ = write!(
37                s,
38                "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" \
39                 fill=\"{c}\" fill-opacity=\"{a}\"/>",
40                rect.x, rect.y, rect.w, rect.h
41            );
42        }
43
44        RenderCmd::StrokeRect { rect, stroke } => {
45            let (c, a) = svg_color(stroke.color);
46            let _ = write!(
47                s,
48                "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" \
49                 fill=\"none\" stroke=\"{c}\" stroke-opacity=\"{a}\" \
50                 stroke-width=\"{}\"/>",
51                rect.x, rect.y, rect.w, rect.h, stroke.width
52            );
53        }
54
55        RenderCmd::StrokeLine { a: p0, b: p1, stroke } => {
56            let (c, alpha) = svg_color(stroke.color);
57            let _ = write!(
58                s,
59                "<line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" \
60                 stroke=\"{c}\" stroke-opacity=\"{alpha}\" stroke-width=\"{}\"/>",
61                p0.x, p0.y, p1.x, p1.y, stroke.width
62            );
63        }
64
65        RenderCmd::StrokePolyline { coords, stroke } => {
66            let (c, alpha) = svg_color(stroke.color);
67            s.push_str("<polyline points=\"");
68            emit_points(s, coords);
69            let _ = write!(
70                s,
71                "\" fill=\"none\" stroke=\"{c}\" stroke-opacity=\"{alpha}\" \
72                 stroke-width=\"{}\"/>",
73                stroke.width
74            );
75        }
76
77        RenderCmd::FillTriangleStrip { coords, colors } => {
78            emit_triangle_strip(s, coords, colors);
79        }
80
81        RenderCmd::DrawText { p, text, color, size_px } => {
82            let (c, a) = svg_color(*color);
83            let _ = write!(
84                s,
85                "<text x=\"{}\" y=\"{}\" fill=\"{c}\" fill-opacity=\"{a}\" \
86                 font-size=\"{size_px}\">{}</text>",
87                p.x, p.y, escape_xml(text)
88            );
89        }
90    }
91}
92
93/// Emite `x0,y0 x1,y1 …` para el atributo `points` de polyline/polygon.
94fn emit_points(s: &mut String, coords: &[f32]) {
95    for (i, pair) in coords.chunks_exact(2).enumerate() {
96        if i > 0 {
97            s.push(' ');
98        }
99        let _ = write!(s, "{},{}", pair[0], pair[1]);
100    }
101}
102
103/// Un triangle strip de N vértices = N-2 triángulos. Cada triángulo se
104/// emite como `<polygon>` con el color promedio de sus 3 vértices (SVG
105/// no tiene gradient por-vértice trivial).
106fn emit_triangle_strip(s: &mut String, coords: &[f32], colors: &[Color]) {
107    let n = coords.len() / 2;
108    if n < 3 {
109        return;
110    }
111    for t in 0..n - 2 {
112        let (i0, i1, i2) = (t, t + 1, t + 2);
113        let avg = avg_color(&[
114            colors.get(i0).copied(),
115            colors.get(i1).copied(),
116            colors.get(i2).copied(),
117        ]);
118        let (c, a) = svg_color(avg);
119        let _ = write!(
120            s,
121            "<polygon points=\"{},{} {},{} {},{}\" fill=\"{c}\" fill-opacity=\"{a}\"/>",
122            coords[i0 * 2], coords[i0 * 2 + 1],
123            coords[i1 * 2], coords[i1 * 2 + 1],
124            coords[i2 * 2], coords[i2 * 2 + 1],
125        );
126    }
127}
128
129fn avg_color(cs: &[Option<Color>]) -> Color {
130    let mut acc = Color::rgba(0.0, 0.0, 0.0, 0.0);
131    let mut n = 0.0;
132    for c in cs.iter().flatten() {
133        acc.r += c.r;
134        acc.g += c.g;
135        acc.b += c.b;
136        acc.a += c.a;
137        n += 1.0;
138    }
139    if n == 0.0 {
140        return Color::TRANSPARENT;
141    }
142    Color::rgba(acc.r / n, acc.g / n, acc.b / n, acc.a / n)
143}
144
145/// `Color` f32 → (`rgb(R,G,B)` con enteros 0-255, alpha 0-1).
146fn svg_color(c: Color) -> (String, f32) {
147    let to255 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
148    (
149        format!("rgb({},{},{})", to255(c.r), to255(c.g), to255(c.b)),
150        c.a.clamp(0.0, 1.0),
151    )
152}
153
154/// Escapa los 5 caracteres especiales de XML en contenido de texto.
155fn escape_xml(text: &str) -> String {
156    let mut out = String::with_capacity(text.len());
157    for ch in text.chars() {
158        match ch {
159            '&' => out.push_str("&amp;"),
160            '<' => out.push_str("&lt;"),
161            '>' => out.push_str("&gt;"),
162            '"' => out.push_str("&quot;"),
163            '\'' => out.push_str("&apos;"),
164            c => out.push(c),
165        }
166    }
167    out
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use pineal_render::{Canvas, Point, Rect, StrokeStyle};
174
175    fn sample_plan() -> RenderPlan {
176        let mut rec = pineal_render::PlanRecorder::new();
177        rec.fill_rect(Rect::new(1.0, 2.0, 30.0, 40.0), Color::from_hex(0xff0000));
178        rec.stroke_line(
179            Point::new(0.0, 0.0),
180            Point::new(100.0, 50.0),
181            StrokeStyle::new(2.0, Color::BLACK),
182        );
183        rec.draw_text(Point::new(5.0, 10.0), "a<b&c", Color::WHITE, 12.0);
184        rec.into_plan()
185    }
186
187    #[test]
188    fn emits_well_formed_svg() {
189        let svg = to_svg(&sample_plan(), 200.0, 100.0);
190        assert!(svg.starts_with("<svg xmlns=\"http://www.w3.org/2000/svg\""));
191        assert!(svg.ends_with("</svg>"));
192        assert!(svg.contains("width=\"200\""));
193        assert!(svg.contains("viewBox=\"0 0 200 100\""));
194    }
195
196    #[test]
197    fn emits_each_primitive() {
198        let svg = to_svg(&sample_plan(), 200.0, 100.0);
199        assert!(svg.contains("<rect "));
200        assert!(svg.contains("fill=\"rgb(255,0,0)\""));
201        assert!(svg.contains("<line "));
202        assert!(svg.contains("<text "));
203    }
204
205    #[test]
206    fn escapes_xml_in_text() {
207        let svg = to_svg(&sample_plan(), 200.0, 100.0);
208        assert!(svg.contains("a&lt;b&amp;c"));
209        assert!(!svg.contains("a<b&c"));
210    }
211
212    #[test]
213    fn triangle_strip_becomes_polygons() {
214        let mut rec = pineal_render::PlanRecorder::new();
215        rec.fill_triangle_strip(
216            &[0.0, 0.0, 10.0, 0.0, 5.0, 10.0, 15.0, 10.0],
217            &[Color::WHITE, Color::WHITE, Color::WHITE, Color::WHITE],
218        );
219        let svg = to_svg(&rec.into_plan(), 20.0, 20.0);
220        // 4 vértices → 2 triángulos → 2 polígonos.
221        assert_eq!(svg.matches("<polygon").count(), 2);
222    }
223}