diff --git a/crates/modules/pineal/export/src/lib.rs b/crates/modules/pineal/export/src/lib.rs index 3ac171a..0ee9fea 100644 --- a/crates/modules/pineal/export/src/lib.rs +++ b/crates/modules/pineal/export/src/lib.rs @@ -1,23 +1,21 @@ -//! `pineal-export` — exporters. +//! `pineal-export` — exporters de `RenderPlan`. //! -//! Estrategia: implementar `pineal_render::Canvas` con un -//! adapter que emite elementos SVG (o instrucciones PDF). El mismo -//! painter que dibuja en pantalla escribe en el exporter — un sólo -//! camino de código. +//! Estrategia: el painter dibuja contra el trait `Canvas`; un +//! `PlanRecorder` (en `pineal-render`) lo graba como `RenderPlan`; este +//! crate consume el plan y emite el formato destino. Un solo camino de +//! código para screen y export. //! -//! Decimación contextual: -//! ```text -//! target = width_inches × dpi × vertices_per_pixel -//! ``` -//! Print (300 dpi) saca ~3× más vértices que screen (96 dpi) del -//! mismo source data (sección 3.10). -//! -//! - **`svg`** — exporter SVG. -//! - **`pdf`** — placeholder; cuando se implemente, vía `printpdf` -//! sobre el mismo `RenderPlan` que el SVG. +//! - [`svg`] — exporter SVG (implementado). +//! - [`pdf`] — placeholder; cuando se implemente, vía `printpdf` sobre +//! el mismo `RenderPlan`, con decimación contextual por DPI +//! (`target = width_inches × dpi × vertices_per_pixel`). #![forbid(unsafe_code)] -#![allow(dead_code)] -pub mod svg {} +pub mod svg; + +/// Exporter PDF — pendiente. Se implementará sobre `printpdf` +/// consumiendo el mismo `RenderPlan` que `svg`. pub mod pdf {} + +pub use svg::to_svg; diff --git a/crates/modules/pineal/export/src/svg.rs b/crates/modules/pineal/export/src/svg.rs new file mode 100644 index 0000000..93f418b --- /dev/null +++ b/crates/modules/pineal/export/src/svg.rs @@ -0,0 +1,223 @@ +//! Exporter SVG: un [`RenderPlan`] → documento SVG completo. +//! +//! El mismo painter que dibuja en pantalla (vía el trait `Canvas`) se +//! graba con un `PlanRecorder` y el plan resultante se vuelca acá. Un +//! solo camino de código para screen y export. +//! +//! v1: los comandos de clip (`PushClip`/`PopClip`) se ignoran — el +//! recorte no es crítico para la mayoría de exports y SVG `clipPath` +//! agrega complejidad de IDs. Se puede agregar después sin romper API. + +use pineal_render::{Color, RenderCmd, RenderPlan}; +use std::fmt::Write; + +/// Convierte un `RenderPlan` a un documento SVG de `width × height`. +pub fn to_svg(plan: &RenderPlan, width: f32, height: f32) -> String { + let mut s = String::with_capacity(256 + plan.cmds.len() * 80); + let _ = write!( + s, + "" + ); + for cmd in &plan.cmds { + emit_cmd(&mut s, cmd); + } + s.push_str(""); + s +} + +fn emit_cmd(s: &mut String, cmd: &RenderCmd) { + match cmd { + // v1: clips ignorados (ver doc del módulo). + RenderCmd::PushClip(_) | RenderCmd::PopClip => {} + + RenderCmd::FillRect { rect, color } => { + let (c, a) = svg_color(*color); + let _ = write!( + s, + "", + rect.x, rect.y, rect.w, rect.h + ); + } + + RenderCmd::StrokeRect { rect, stroke } => { + let (c, a) = svg_color(stroke.color); + let _ = write!( + s, + "", + rect.x, rect.y, rect.w, rect.h, stroke.width + ); + } + + RenderCmd::StrokeLine { a: p0, b: p1, stroke } => { + let (c, alpha) = svg_color(stroke.color); + let _ = write!( + s, + "", + p0.x, p0.y, p1.x, p1.y, stroke.width + ); + } + + RenderCmd::StrokePolyline { coords, stroke } => { + let (c, alpha) = svg_color(stroke.color); + s.push_str("", + stroke.width + ); + } + + RenderCmd::FillTriangleStrip { coords, colors } => { + emit_triangle_strip(s, coords, colors); + } + + RenderCmd::DrawText { p, text, color, size_px } => { + let (c, a) = svg_color(*color); + let _ = write!( + s, + "{}", + p.x, p.y, escape_xml(text) + ); + } + } +} + +/// Emite `x0,y0 x1,y1 …` para el atributo `points` de polyline/polygon. +fn emit_points(s: &mut String, coords: &[f32]) { + for (i, pair) in coords.chunks_exact(2).enumerate() { + if i > 0 { + s.push(' '); + } + let _ = write!(s, "{},{}", pair[0], pair[1]); + } +} + +/// Un triangle strip de N vértices = N-2 triángulos. Cada triángulo se +/// emite como `` con el color promedio de sus 3 vértices (SVG +/// no tiene gradient por-vértice trivial). +fn emit_triangle_strip(s: &mut String, coords: &[f32], colors: &[Color]) { + let n = coords.len() / 2; + if n < 3 { + return; + } + for t in 0..n - 2 { + let (i0, i1, i2) = (t, t + 1, t + 2); + let avg = avg_color(&[ + colors.get(i0).copied(), + colors.get(i1).copied(), + colors.get(i2).copied(), + ]); + let (c, a) = svg_color(avg); + let _ = write!( + s, + "", + coords[i0 * 2], coords[i0 * 2 + 1], + coords[i1 * 2], coords[i1 * 2 + 1], + coords[i2 * 2], coords[i2 * 2 + 1], + ); + } +} + +fn avg_color(cs: &[Option]) -> Color { + let mut acc = Color::rgba(0.0, 0.0, 0.0, 0.0); + let mut n = 0.0; + for c in cs.iter().flatten() { + acc.r += c.r; + acc.g += c.g; + acc.b += c.b; + acc.a += c.a; + n += 1.0; + } + if n == 0.0 { + return Color::TRANSPARENT; + } + Color::rgba(acc.r / n, acc.g / n, acc.b / n, acc.a / n) +} + +/// `Color` f32 → (`rgb(R,G,B)` con enteros 0-255, alpha 0-1). +fn svg_color(c: Color) -> (String, f32) { + let to255 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + ( + format!("rgb({},{},{})", to255(c.r), to255(c.g), to255(c.b)), + c.a.clamp(0.0, 1.0), + ) +} + +/// Escapa los 5 caracteres especiales de XML en contenido de texto. +fn escape_xml(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for ch in text.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + c => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use pineal_render::{Canvas, Point, Rect, StrokeStyle}; + + fn sample_plan() -> RenderPlan { + let mut rec = pineal_render::PlanRecorder::new(); + rec.fill_rect(Rect::new(1.0, 2.0, 30.0, 40.0), Color::from_hex(0xff0000)); + rec.stroke_line( + Point::new(0.0, 0.0), + Point::new(100.0, 50.0), + StrokeStyle::new(2.0, Color::BLACK), + ); + rec.draw_text(Point::new(5.0, 10.0), "a")); + assert!(svg.contains("width=\"200\"")); + assert!(svg.contains("viewBox=\"0 0 200 100\"")); + } + + #[test] + fn emits_each_primitive() { + let svg = to_svg(&sample_plan(), 200.0, 100.0); + assert!(svg.contains(" Self { + Self::default() + } + + /// Consume el recorder y devuelve el plan acumulado. + pub fn into_plan(self) -> RenderPlan { + self.plan + } + + /// Acceso de sólo-lectura al plan en construcción. + pub fn plan(&self) -> &RenderPlan { + &self.plan + } +} + +impl Canvas for PlanRecorder { + fn push_clip(&mut self, rect: Rect) { + self.plan.push(RenderCmd::PushClip(rect)); + } + + fn pop_clip(&mut self) { + self.plan.push(RenderCmd::PopClip); + } + + fn fill_rect(&mut self, rect: Rect, color: Color) { + self.plan.push(RenderCmd::FillRect { rect, color }); + } + + fn stroke_rect(&mut self, rect: Rect, stroke: StrokeStyle) { + self.plan.push(RenderCmd::StrokeRect { rect, stroke }); + } + + fn stroke_line(&mut self, a: Point, b: Point, stroke: StrokeStyle) { + self.plan.push(RenderCmd::StrokeLine { a, b, stroke }); + } + + fn stroke_polyline(&mut self, coords: &[f32], stroke: StrokeStyle) { + self.plan.push(RenderCmd::StrokePolyline { + coords: coords.to_vec(), + stroke, + }); + } + + fn fill_triangle_strip(&mut self, coords: &[f32], colors: &[Color]) { + self.plan.push(RenderCmd::FillTriangleStrip { + coords: coords.to_vec(), + colors: colors.to_vec(), + }); + } + + fn draw_text(&mut self, p: Point, text: &str, color: Color, size_px: f32) { + self.plan.push(RenderCmd::DrawText { + p, + text: text.to_string(), + color, + size_px, + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn records_calls_in_order() { + let mut rec = PlanRecorder::new(); + rec.fill_rect(Rect::new(0.0, 0.0, 10.0, 10.0), Color::WHITE); + rec.stroke_line( + Point::new(0.0, 0.0), + Point::new(10.0, 10.0), + StrokeStyle::new(1.0, Color::BLACK), + ); + let plan = rec.into_plan(); + assert_eq!(plan.cmds.len(), 2); + assert!(matches!(plan.cmds[0], RenderCmd::FillRect { .. })); + assert!(matches!(plan.cmds[1], RenderCmd::StrokeLine { .. })); + } +}