feat(pineal): cierra stub export — PlanRecorder + exporter SVG
Fase F: primer stub de pineal cerrado. pineal-render: - PlanRecorder — un Canvas que graba cada llamada como RenderCmd en un RenderPlan. Es el puente painter→backend-diferido y la infraestructura de testing (snapshot de planes). pineal-export: - svg::to_svg(plan, w, h) — RenderPlan → documento SVG completo. Cubre FillRect/StrokeRect/StrokeLine/StrokePolyline/DrawText + FillTriangleStrip (strip→polígonos con color promedio). XML-escape en texto. v1: clips ignorados (documentado). - pdf queda como placeholder documentado. Tests: 1 recorder + 4 svg (well-formed, primitivas, xml-escape, triangle-strip→polygons). cargo check --workspace verde. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,23 +1,21 @@
|
|||||||
//! `pineal-export` — exporters.
|
//! `pineal-export` — exporters de `RenderPlan`.
|
||||||
//!
|
//!
|
||||||
//! Estrategia: implementar `pineal_render::Canvas` con un
|
//! Estrategia: el painter dibuja contra el trait `Canvas`; un
|
||||||
//! adapter que emite elementos SVG (o instrucciones PDF). El mismo
|
//! `PlanRecorder` (en `pineal-render`) lo graba como `RenderPlan`; este
|
||||||
//! painter que dibuja en pantalla escribe en el exporter — un sólo
|
//! crate consume el plan y emite el formato destino. Un solo camino de
|
||||||
//! camino de código.
|
//! código para screen y export.
|
||||||
//!
|
//!
|
||||||
//! Decimación contextual:
|
//! - [`svg`] — exporter SVG (implementado).
|
||||||
//! ```text
|
//! - [`pdf`] — placeholder; cuando se implemente, vía `printpdf` sobre
|
||||||
//! target = width_inches × dpi × vertices_per_pixel
|
//! el mismo `RenderPlan`, con decimación contextual por DPI
|
||||||
//! ```
|
//! (`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.
|
|
||||||
|
|
||||||
#![forbid(unsafe_code)]
|
#![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 mod pdf {}
|
||||||
|
|
||||||
|
pub use svg::to_svg;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{width}\" \
|
||||||
|
height=\"{height}\" viewBox=\"0 0 {width} {height}\">"
|
||||||
|
);
|
||||||
|
for cmd in &plan.cmds {
|
||||||
|
emit_cmd(&mut s, cmd);
|
||||||
|
}
|
||||||
|
s.push_str("</svg>");
|
||||||
|
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=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" \
|
||||||
|
fill=\"{c}\" fill-opacity=\"{a}\"/>",
|
||||||
|
rect.x, rect.y, rect.w, rect.h
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
RenderCmd::StrokeRect { rect, stroke } => {
|
||||||
|
let (c, a) = svg_color(stroke.color);
|
||||||
|
let _ = write!(
|
||||||
|
s,
|
||||||
|
"<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" \
|
||||||
|
fill=\"none\" stroke=\"{c}\" stroke-opacity=\"{a}\" \
|
||||||
|
stroke-width=\"{}\"/>",
|
||||||
|
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,
|
||||||
|
"<line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" \
|
||||||
|
stroke=\"{c}\" stroke-opacity=\"{alpha}\" stroke-width=\"{}\"/>",
|
||||||
|
p0.x, p0.y, p1.x, p1.y, stroke.width
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
RenderCmd::StrokePolyline { coords, stroke } => {
|
||||||
|
let (c, alpha) = svg_color(stroke.color);
|
||||||
|
s.push_str("<polyline points=\"");
|
||||||
|
emit_points(s, coords);
|
||||||
|
let _ = write!(
|
||||||
|
s,
|
||||||
|
"\" fill=\"none\" stroke=\"{c}\" stroke-opacity=\"{alpha}\" \
|
||||||
|
stroke-width=\"{}\"/>",
|
||||||
|
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,
|
||||||
|
"<text x=\"{}\" y=\"{}\" fill=\"{c}\" fill-opacity=\"{a}\" \
|
||||||
|
font-size=\"{size_px}\">{}</text>",
|
||||||
|
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 `<polygon>` 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,
|
||||||
|
"<polygon points=\"{},{} {},{} {},{}\" fill=\"{c}\" fill-opacity=\"{a}\"/>",
|
||||||
|
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>]) -> 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<b&c", Color::WHITE, 12.0);
|
||||||
|
rec.into_plan()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emits_well_formed_svg() {
|
||||||
|
let svg = to_svg(&sample_plan(), 200.0, 100.0);
|
||||||
|
assert!(svg.starts_with("<svg xmlns=\"http://www.w3.org/2000/svg\""));
|
||||||
|
assert!(svg.ends_with("</svg>"));
|
||||||
|
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("<rect "));
|
||||||
|
assert!(svg.contains("fill=\"rgb(255,0,0)\""));
|
||||||
|
assert!(svg.contains("<line "));
|
||||||
|
assert!(svg.contains("<text "));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escapes_xml_in_text() {
|
||||||
|
let svg = to_svg(&sample_plan(), 200.0, 100.0);
|
||||||
|
assert!(svg.contains("a<b&c"));
|
||||||
|
assert!(!svg.contains("a<b&c"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn triangle_strip_becomes_polygons() {
|
||||||
|
let mut rec = pineal_render::PlanRecorder::new();
|
||||||
|
rec.fill_triangle_strip(
|
||||||
|
&[0.0, 0.0, 10.0, 0.0, 5.0, 10.0, 15.0, 10.0],
|
||||||
|
&[Color::WHITE, Color::WHITE, Color::WHITE, Color::WHITE],
|
||||||
|
);
|
||||||
|
let svg = to_svg(&rec.into_plan(), 20.0, 20.0);
|
||||||
|
// 4 vértices → 2 triángulos → 2 polígonos.
|
||||||
|
assert_eq!(svg.matches("<polygon").count(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ pub mod color;
|
|||||||
pub mod geom;
|
pub mod geom;
|
||||||
pub mod canvas;
|
pub mod canvas;
|
||||||
pub mod plan;
|
pub mod plan;
|
||||||
|
pub mod recorder;
|
||||||
|
|
||||||
#[cfg(feature = "gpui")]
|
#[cfg(feature = "gpui")]
|
||||||
pub mod gpui_backend;
|
pub mod gpui_backend;
|
||||||
@@ -31,6 +32,7 @@ pub use color::Color;
|
|||||||
pub use geom::{Point, Rect};
|
pub use geom::{Point, Rect};
|
||||||
pub use canvas::{Canvas, StrokeStyle};
|
pub use canvas::{Canvas, StrokeStyle};
|
||||||
pub use plan::{RenderCmd, RenderPlan};
|
pub use plan::{RenderCmd, RenderPlan};
|
||||||
|
pub use recorder::PlanRecorder;
|
||||||
|
|
||||||
#[cfg(feature = "gpui")]
|
#[cfg(feature = "gpui")]
|
||||||
pub use gpui_backend::WindowCanvas;
|
pub use gpui_backend::WindowCanvas;
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
//! `PlanRecorder` — un [`Canvas`] que graba cada llamada como `RenderCmd`
|
||||||
|
//! en un [`RenderPlan`], en vez de dibujar.
|
||||||
|
//!
|
||||||
|
//! Es el puente entre los painters (que hablan contra `Canvas`) y los
|
||||||
|
//! backends diferidos: `pineal-export` consume el plan grabado y emite
|
||||||
|
//! SVG; los tests de snapshot comparan planes.
|
||||||
|
|
||||||
|
use crate::{Canvas, Color, Point, Rect, RenderCmd, RenderPlan, StrokeStyle};
|
||||||
|
|
||||||
|
/// Canvas que materializa todo lo dibujado en un `RenderPlan`.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct PlanRecorder {
|
||||||
|
plan: RenderPlan,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlanRecorder {
|
||||||
|
pub fn new() -> 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 { .. }));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user