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,
+ "");
+ 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 { .. }));
+ }
+}