⎇ 1fdbd358c8cd914fb767cbee1483d79935aa1fec
crates/modules/pineal/export/src/svg.rs
Historia
Quién tocó qué
crudo
feat(pineal): cierra stub export — PlanRecorder + exporter SVG
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("&"),
160 '<' => out.push_str("<"),
161 '>' => out.push_str(">"),
162 '"' => out.push_str("""),
163 '\'' => out.push_str("'"),
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<b&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}