use super::grid_protocol::{GridError, GridProtocol, GridSpan, PixelBounds};
use super::layout::{LayoutEngine, Viewport, GRID_SIZE};
use super::palette::{Color, MaterialPalette, SovereignPalette};
#[allow(unused_imports)]
pub use super::shapes::Size;
use super::shapes::{ArrowMarker, Circle, Line, Path, Point, Rect, Text};
use super::typography::{MaterialTypography, TextStyle};
#[derive(Debug, Clone)]
pub enum SvgElement {
Rect(Rect),
Circle(Circle),
Line(Line),
Path(Path),
Text(Text),
Group { id: String, elements: Vec<SvgElement> },
}
impl SvgElement {
pub fn to_svg(&self) -> String {
match self {
Self::Rect(r) => r.to_svg(),
Self::Circle(c) => c.to_svg(),
Self::Line(l) => l.to_svg(),
Self::Path(p) => p.to_svg(),
Self::Text(t) => t.to_svg(),
Self::Group { id, elements } => {
let children: String = elements.iter().map(|e| e.to_svg()).collect();
format!("<g id=\"{}\">{}</g>", id, children)
}
}
}
}
#[derive(Debug)]
pub struct SvgBuilder {
viewport: Viewport,
palette: MaterialPalette,
typography: MaterialTypography,
layout: LayoutEngine,
elements: Vec<SvgElement>,
markers: Vec<ArrowMarker>,
styles: Vec<String>,
title: Option<String>,
description: Option<String>,
transparent: bool,
grid: Option<GridProtocol>,
}
impl SvgBuilder {
pub fn new() -> Self {
let viewport = Viewport::presentation();
let palette = MaterialPalette::light();
let typography = MaterialTypography::with_color(palette.on_surface);
Self {
viewport,
palette: palette.clone(),
typography,
layout: LayoutEngine::new(viewport),
elements: Vec::new(),
markers: Vec::new(),
styles: Vec::new(),
title: None,
description: None,
transparent: false,
grid: None,
}
}
pub fn viewport(mut self, viewport: Viewport) -> Self {
self.viewport = viewport;
self.layout = LayoutEngine::new(viewport);
self
}
pub fn size(self, width: f32, height: f32) -> Self {
self.viewport(Viewport::new(width, height))
}
pub fn document(self) -> Self {
self.viewport(Viewport::document())
}
pub fn presentation(self) -> Self {
self.viewport(Viewport::presentation())
}
pub fn palette(mut self, palette: MaterialPalette) -> Self {
self.typography = MaterialTypography::with_color(palette.on_surface);
self.palette = palette;
self
}
pub fn dark_mode(self) -> Self {
self.palette(MaterialPalette::dark())
}
pub fn title(mut self, title: &str) -> Self {
self.title = Some(title.to_string());
self
}
pub fn description(mut self, desc: &str) -> Self {
self.description = Some(desc.to_string());
self
}
pub fn transparent(mut self) -> Self {
self.transparent = true;
self
}
pub fn grid_protocol(mut self) -> Self {
self.viewport = Viewport::presentation();
self.layout = LayoutEngine::new(self.viewport);
self.grid = Some(GridProtocol::new());
self
}
pub fn allocate(&mut self, name: &str, span: GridSpan) -> Result<PixelBounds, GridError> {
match self.grid.as_mut() {
Some(grid) => grid.allocate(name, span),
None => Err(GridError::OutOfBounds { span }),
}
}
pub fn is_grid_mode(&self) -> bool {
self.grid.is_some()
}
pub fn video_styles(self) -> Self {
let css = r".heading { font-family: 'Segoe UI', 'Helvetica Neue', sans-serif; }
.body { font-family: 'Segoe UI', 'Helvetica Neue', sans-serif; }
.mono { font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace; }";
self.add_style(css)
}
pub fn add_style(mut self, css: &str) -> Self {
self.styles.push(css.to_string());
self
}
pub fn rect(mut self, id: &str, x: f32, y: f32, width: f32, height: f32) -> Self {
let rect = Rect::new(x, y, width, height)
.with_fill(self.palette.surface)
.with_stroke(self.palette.outline, 1.0);
if self.layout.add(id, rect.clone()) {
self.elements.push(SvgElement::Rect(rect));
}
self
}
#[allow(clippy::too_many_arguments)]
pub fn rect_styled(
mut self,
id: &str,
x: f32,
y: f32,
width: f32,
height: f32,
fill: Color,
stroke: Option<(Color, f32)>,
radius: f32,
) -> Self {
let mut rect = Rect::new(x, y, width, height).with_fill(fill).with_radius(radius);
if let Some((color, width)) = stroke {
rect = rect.with_stroke(color, width);
}
if self.layout.add(id, rect.clone()) {
self.elements.push(SvgElement::Rect(rect));
}
self
}
pub fn circle(mut self, id: &str, cx: f32, cy: f32, r: f32) -> Self {
let circle = Circle::new(cx, cy, r)
.with_fill(self.palette.primary)
.with_stroke(self.palette.outline, 1.0);
let bounds = circle.bounds();
if self.layout.add(id, bounds) {
self.elements.push(SvgElement::Circle(circle));
}
self
}
pub fn circle_styled(
mut self,
id: &str,
cx: f32,
cy: f32,
r: f32,
fill: Color,
stroke: Option<(Color, f32)>,
) -> Self {
let mut circle = Circle::new(cx, cy, r).with_fill(fill);
if let Some((color, width)) = stroke {
circle = circle.with_stroke(color, width);
}
let bounds = circle.bounds();
if self.layout.add(id, bounds) {
self.elements.push(SvgElement::Circle(circle));
}
self
}
pub fn line(mut self, x1: f32, y1: f32, x2: f32, y2: f32) -> Self {
let line = Line::new(x1, y1, x2, y2).with_stroke(self.palette.outline);
self.elements.push(SvgElement::Line(line));
self
}
pub fn line_styled(
mut self,
x1: f32,
y1: f32,
x2: f32,
y2: f32,
color: Color,
width: f32,
) -> Self {
let line = Line::new(x1, y1, x2, y2).with_stroke(color).with_stroke_width(width);
self.elements.push(SvgElement::Line(line));
self
}
pub fn text(mut self, x: f32, y: f32, content: &str) -> Self {
let text = Text::new(x, y, content).with_style(self.typography.body_medium.clone());
self.elements.push(SvgElement::Text(text));
self
}
pub fn text_styled(mut self, x: f32, y: f32, content: &str, style: TextStyle) -> Self {
let text = Text::new(x, y, content).with_style(style);
self.elements.push(SvgElement::Text(text));
self
}
pub fn heading(mut self, x: f32, y: f32, content: &str) -> Self {
let text = Text::new(x, y, content).with_style(self.typography.headline_medium.clone());
self.elements.push(SvgElement::Text(text));
self
}
pub fn label(mut self, x: f32, y: f32, content: &str) -> Self {
let text = Text::new(x, y, content).with_style(self.typography.label_medium.clone());
self.elements.push(SvgElement::Text(text));
self
}
pub fn path(mut self, path: Path) -> Self {
self.elements.push(SvgElement::Path(path));
self
}
pub fn add_arrow_marker(mut self, id: &str, color: Color) -> Self {
self.markers.push(ArrowMarker::new(id, color));
self
}
pub fn element(mut self, element: SvgElement) -> Self {
self.elements.push(element);
self
}
pub fn group(mut self, id: &str, elements: Vec<SvgElement>) -> Self {
self.elements.push(SvgElement::Group { id: id.to_string(), elements });
self
}
pub fn get_palette(&self) -> &MaterialPalette {
&self.palette
}
pub fn get_typography(&self) -> &MaterialTypography {
&self.typography
}
pub fn get_layout(&self) -> &LayoutEngine {
&self.layout
}
pub fn get_layout_mut(&mut self) -> &mut LayoutEngine {
&mut self.layout
}
pub fn validate(&self) -> Vec<String> {
let mut errors = Vec::new();
for error in self.layout.validate() {
errors.push(error.to_string());
}
errors
}
pub fn estimate_size(&self) -> usize {
let base = 500; let per_element = 100; let marker_overhead = self.markers.len() * 200;
let style_overhead: usize = self.styles.iter().map(|s| s.len()).sum();
base + self.elements.len() * per_element + marker_overhead + style_overhead
}
pub fn build(self) -> String {
let mut svg = String::new();
svg.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
svg.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"{}\" width=\"{}\" height=\"{}\">\n",
self.viewport.view_box(),
self.viewport.width,
self.viewport.height
));
if let Some(grid) = &self.grid {
svg.push_str(&format!(" {}\n", grid.manifest()));
}
if let Some(title) = &self.title {
svg.push_str(&format!(" <title>{}</title>\n", title));
}
if let Some(desc) = &self.description {
svg.push_str(&format!(" <desc>{}</desc>\n", desc));
}
if !self.styles.is_empty() {
svg.push_str(" <style>\n");
for style in &self.styles {
svg.push_str(&format!(" {}\n", style));
}
svg.push_str(" </style>\n");
}
if !self.markers.is_empty() {
svg.push_str(" <defs>\n");
for marker in &self.markers {
svg.push_str(&format!(" {}\n", marker.to_svg_def()));
}
svg.push_str(" </defs>\n");
}
if !self.transparent {
svg.push_str(&format!(
" <rect width=\"100%\" height=\"100%\" fill=\"{}\"/>\n",
self.palette.background.to_css_hex()
));
}
for element in &self.elements {
svg.push_str(&format!(" {}\n", element.to_svg()));
}
svg.push_str("</svg>\n");
svg
}
}
impl Default for SvgBuilder {
fn default() -> Self {
Self::new()
}
}
pub struct ComponentDiagramBuilder {
builder: SvgBuilder,
palette: SovereignPalette,
}
impl ComponentDiagramBuilder {
pub fn new() -> Self {
Self { builder: SvgBuilder::new().presentation(), palette: SovereignPalette::light() }
}
pub fn component(mut self, id: &str, x: f32, y: f32, name: &str, component_type: &str) -> Self {
let width = 160.0;
let height = 80.0;
let color = self.palette.component_color(component_type);
self.builder = self.builder.rect_styled(
id,
x,
y,
width,
height,
color.lighten(0.8),
Some((color, 2.0)),
GRID_SIZE,
);
let text_style = self
.builder
.get_typography()
.title_small
.clone()
.with_color(self.palette.material.on_surface);
self.builder =
self.builder.text_styled(x + width / 2.0, y + height / 2.0 + 5.0, name, text_style);
self
}
pub fn connect(mut self, from: Point, to: Point) -> Self {
self.builder = self.builder.line_styled(
from.x,
from.y,
to.x,
to.y,
self.palette.material.outline,
2.0,
);
self
}
pub fn build(self) -> String {
self.builder.build()
}
}
impl Default for ComponentDiagramBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_svg_builder_creation() {
let builder = SvgBuilder::new();
assert_eq!(builder.viewport.width, 1920.0);
assert_eq!(builder.viewport.height, 1080.0);
}
#[test]
fn test_svg_builder_viewport() {
let builder = SvgBuilder::new().document();
assert_eq!(builder.viewport.width, 800.0);
assert_eq!(builder.viewport.height, 600.0);
}
#[test]
fn test_svg_builder_rect() {
let svg = SvgBuilder::new().size(200.0, 200.0).rect("test", 10.0, 10.0, 50.0, 50.0).build();
assert!(svg.contains("<rect"));
assert!(svg.contains("width=\"50\""));
}
#[test]
fn test_svg_builder_circle() {
let svg = SvgBuilder::new().size(200.0, 200.0).circle("test", 50.0, 50.0, 25.0).build();
assert!(svg.contains("<circle"));
assert!(svg.contains("r=\"25\""));
}
#[test]
fn test_svg_builder_text() {
let svg = SvgBuilder::new().size(200.0, 200.0).text(10.0, 20.0, "Hello").build();
assert!(svg.contains("<text"));
assert!(svg.contains("Hello"));
}
#[test]
fn test_svg_builder_title() {
let svg = SvgBuilder::new().title("Test Diagram").description("A test").build();
assert!(svg.contains("<title>Test Diagram</title>"));
assert!(svg.contains("<desc>A test</desc>"));
}
#[test]
fn test_svg_builder_dark_mode() {
let builder = SvgBuilder::new().dark_mode();
assert_eq!(
builder.palette.surface.to_css_hex(),
MaterialPalette::dark().surface.to_css_hex()
);
}
#[test]
fn test_svg_builder_validation() {
let builder = SvgBuilder::new().size(200.0, 200.0).rect("r1", 24.0, 24.0, 48.0, 48.0);
let errors = builder.validate();
assert!(errors.is_empty(), "Unexpected errors: {:?}", errors);
}
#[test]
fn test_svg_builder_estimate_size() {
let builder =
SvgBuilder::new().rect("r1", 0.0, 0.0, 50.0, 50.0).rect("r2", 60.0, 0.0, 50.0, 50.0);
let size = builder.estimate_size();
assert!(size > 0);
assert!(size < 10000); }
#[test]
fn test_svg_element_group() {
let group = SvgElement::Group {
id: "test-group".to_string(),
elements: vec![
SvgElement::Rect(Rect::new(0.0, 0.0, 10.0, 10.0)),
SvgElement::Circle(Circle::new(5.0, 5.0, 2.0)),
],
};
let svg = group.to_svg();
assert!(svg.contains("id=\"test-group\""));
assert!(svg.contains("<rect"));
assert!(svg.contains("<circle"));
}
#[test]
fn test_component_diagram_builder() {
let svg = ComponentDiagramBuilder::new()
.component("trueno", 100.0, 100.0, "Trueno", "trueno")
.component("aprender", 300.0, 100.0, "Aprender", "aprender")
.connect(Point::new(260.0, 140.0), Point::new(300.0, 140.0))
.build();
assert!(svg.contains("<svg"));
assert!(svg.contains("Trueno"));
assert!(svg.contains("Aprender"));
}
#[test]
fn test_svg_output_size() {
let svg = SvgBuilder::new()
.size(800.0, 600.0)
.title("Test")
.rect("r1", 10.0, 10.0, 100.0, 100.0)
.circle("c1", 200.0, 200.0, 30.0)
.text(50.0, 50.0, "Hello World")
.build();
assert!(svg.len() < 100_000, "SVG too large: {} bytes", svg.len());
}
#[test]
fn test_svg_builder_custom_viewport() {
let vp = Viewport::new(400.0, 300.0);
let builder = SvgBuilder::new().viewport(vp);
let svg = builder.build();
assert!(svg.contains("viewBox=\"0 0 400 300\""));
}
#[test]
fn test_svg_builder_document() {
let builder = SvgBuilder::new().document();
let svg = builder.build();
assert!(svg.contains("viewBox=\"0 0 800 600\""));
}
#[test]
fn test_svg_builder_presentation() {
let builder = SvgBuilder::new().presentation();
let svg = builder.build();
assert!(svg.contains("viewBox=\"0 0 1920 1080\""));
}
#[test]
fn test_svg_builder_palette() {
let palette = MaterialPalette::dark();
let builder = SvgBuilder::new().palette(palette);
assert_eq!(builder.get_palette().surface.to_css_hex(), "#1C1B1F");
}
#[test]
fn test_svg_builder_add_style() {
let svg = SvgBuilder::new().add_style(".my-class { fill: red; }").build();
assert!(svg.contains(".my-class { fill: red; }"));
}
#[test]
fn test_svg_builder_rect_styled() {
let svg = SvgBuilder::new()
.size(200.0, 200.0)
.rect_styled(
"styled",
10.0,
10.0,
50.0,
50.0,
Color::rgb(255, 0, 0),
Some((Color::rgb(0, 0, 0), 2.0)),
5.0,
)
.build();
assert!(svg.contains("fill=\"#FF0000\""));
assert!(svg.contains("stroke=\"#000000\""));
assert!(svg.contains("rx=\"5\""));
}
#[test]
fn test_svg_builder_circle_styled() {
let svg = SvgBuilder::new()
.size(200.0, 200.0)
.circle_styled(
"styled",
50.0,
50.0,
25.0,
Color::rgb(0, 255, 0),
Some((Color::rgb(0, 0, 0), 3.0)),
)
.build();
assert!(svg.contains("fill=\"#00FF00\""));
}
#[test]
fn test_svg_builder_line() {
let svg = SvgBuilder::new().size(200.0, 200.0).line(0.0, 0.0, 100.0, 100.0).build();
assert!(svg.contains("<line"));
assert!(svg.contains("x1=\"0\""));
assert!(svg.contains("x2=\"100\""));
}
#[test]
fn test_svg_builder_line_styled() {
let svg = SvgBuilder::new()
.size(200.0, 200.0)
.line_styled(0.0, 0.0, 100.0, 100.0, Color::rgb(255, 0, 0), 5.0)
.build();
assert!(svg.contains("stroke=\"#FF0000\""));
assert!(svg.contains("stroke-width=\"5\""));
}
#[test]
fn test_svg_builder_text_styled() {
use crate::oracle::svg::typography::{FontWeight, TextStyle};
let style = TextStyle::new(20.0, FontWeight::Bold);
let svg = SvgBuilder::new()
.size(200.0, 200.0)
.text_styled(10.0, 30.0, "Styled Text", style)
.build();
assert!(svg.contains("font-size=\"20\""));
assert!(svg.contains("font-weight=\"700\""));
}
#[test]
fn test_svg_builder_heading() {
let svg = SvgBuilder::new().size(200.0, 200.0).heading(10.0, 30.0, "Heading").build();
assert!(svg.contains("Heading"));
}
#[test]
fn test_svg_builder_label() {
let svg = SvgBuilder::new().size(200.0, 200.0).label(10.0, 30.0, "Label").build();
assert!(svg.contains("Label"));
}
#[test]
fn test_svg_builder_path() {
use crate::oracle::svg::shapes::Path;
let path = Path::new().move_to(0.0, 0.0).line_to(100.0, 100.0).close();
let svg = SvgBuilder::new().size(200.0, 200.0).path(path).build();
assert!(svg.contains("<path"));
assert!(svg.contains("M 0 0"));
}
#[test]
fn test_svg_builder_add_arrow_marker() {
let svg = SvgBuilder::new()
.size(200.0, 200.0)
.add_arrow_marker("arrow1", Color::rgb(0, 0, 255))
.build();
assert!(svg.contains("<marker"));
assert!(svg.contains("id=\"arrow1\""));
}
#[test]
fn test_svg_builder_element() {
use crate::oracle::svg::shapes::Rect;
let rect = Rect::new(10.0, 10.0, 50.0, 50.0);
let svg = SvgBuilder::new().size(200.0, 200.0).element(SvgElement::Rect(rect)).build();
assert!(svg.contains("<rect"));
}
#[test]
fn test_svg_builder_group() {
use crate::oracle::svg::shapes::{Circle, Rect};
let elements = vec![
SvgElement::Rect(Rect::new(0.0, 0.0, 10.0, 10.0)),
SvgElement::Circle(Circle::new(5.0, 5.0, 3.0)),
];
let svg = SvgBuilder::new().size(200.0, 200.0).group("my-group", elements).build();
assert!(svg.contains("<g id=\"my-group\""));
}
#[test]
fn test_svg_builder_get_typography() {
let builder = SvgBuilder::new();
let typo = builder.get_typography();
assert_eq!(typo.body_medium.size, 14.0);
}
#[test]
fn test_svg_builder_get_layout() {
let builder = SvgBuilder::new().size(200.0, 200.0);
let layout = builder.get_layout();
assert!(layout.is_empty());
}
#[test]
fn test_svg_builder_get_layout_mut() {
let mut builder = SvgBuilder::new().size(200.0, 200.0);
let layout = builder.get_layout_mut();
layout.clear();
assert!(layout.is_empty());
}
#[test]
fn test_svg_builder_default() {
let builder = SvgBuilder::default();
let svg = builder.build();
assert!(svg.contains("<svg"));
}
#[test]
fn test_svg_element_to_svg_variants() {
use crate::oracle::svg::shapes::{Line, Path, Text};
let line = SvgElement::Line(Line::new(0.0, 0.0, 10.0, 10.0));
assert!(line.to_svg().contains("<line"));
let text = SvgElement::Text(Text::new(0.0, 10.0, "Test"));
assert!(text.to_svg().contains("<text"));
let path = SvgElement::Path(Path::new().move_to(0.0, 0.0).line_to(10.0, 10.0));
assert!(path.to_svg().contains("<path"));
}
#[test]
fn test_component_diagram_builder_new() {
let builder = ComponentDiagramBuilder::new();
assert!(builder.builder.elements.is_empty());
}
#[test]
fn test_svg_builder_get_palette() {
let builder = SvgBuilder::new();
let palette = builder.get_palette();
assert_eq!(palette.primary.to_css_hex(), MaterialPalette::light().primary.to_css_hex());
}
#[test]
fn test_component_diagram_builder_component() {
let builder = ComponentDiagramBuilder::new().component(
"c1",
100.0,
100.0,
"Test Component",
"Service",
);
assert!(!builder.builder.elements.is_empty());
}
#[test]
fn test_component_diagram_builder_connect() {
let builder =
ComponentDiagramBuilder::new().connect(Point::new(0.0, 0.0), Point::new(100.0, 100.0));
assert!(!builder.builder.elements.is_empty());
}
#[test]
fn test_component_diagram_builder_build() {
let svg =
ComponentDiagramBuilder::new().component("c1", 50.0, 50.0, "Service", "API").build();
assert!(svg.contains("<svg"));
assert!(svg.contains("Service"));
}
#[test]
fn test_component_diagram_builder_default() {
let builder = ComponentDiagramBuilder::default();
assert!(builder.builder.elements.is_empty());
}
#[test]
fn test_svg_builder_transparent() {
let svg = SvgBuilder::new().size(200.0, 200.0).transparent().build();
assert!(!svg.contains("width=\"100%\" height=\"100%\""));
assert!(svg.contains("<svg"));
}
#[test]
fn test_svg_builder_opaque_has_background() {
let svg = SvgBuilder::new().size(200.0, 200.0).build();
assert!(svg.contains("width=\"100%\" height=\"100%\""));
}
#[test]
fn test_svg_builder_grid_protocol() {
let mut builder = SvgBuilder::new().grid_protocol();
assert!(builder.is_grid_mode());
let result = builder
.allocate("header", crate::oracle::svg::grid_protocol::GridSpan::new(0, 0, 15, 1));
assert!(result.is_ok());
}
#[test]
fn test_svg_builder_grid_protocol_manifest_in_output() {
let mut builder = SvgBuilder::new().grid_protocol();
builder
.allocate("header", crate::oracle::svg::grid_protocol::GridSpan::new(0, 0, 15, 1))
.expect("unexpected failure");
let svg = builder.build();
assert!(svg.contains("GRID PROTOCOL MANIFEST"));
assert!(svg.contains("\"header\""));
assert!(svg.contains("viewBox=\"0 0 1920 1080\""));
}
#[test]
fn test_svg_builder_grid_protocol_overlap_rejected() {
let mut builder = SvgBuilder::new().grid_protocol();
builder
.allocate("a", crate::oracle::svg::grid_protocol::GridSpan::new(0, 0, 7, 4))
.expect("unexpected failure");
let result =
builder.allocate("b", crate::oracle::svg::grid_protocol::GridSpan::new(5, 3, 10, 6));
assert!(result.is_err());
}
#[test]
fn test_svg_builder_allocate_without_grid_mode() {
let mut builder = SvgBuilder::new();
assert!(!builder.is_grid_mode());
let result = builder
.allocate("header", crate::oracle::svg::grid_protocol::GridSpan::new(0, 0, 15, 1));
assert!(result.is_err());
}
#[test]
fn test_svg_builder_video_styles() {
let svg = SvgBuilder::new().video_styles().build();
assert!(svg.contains("Segoe UI"));
assert!(svg.contains("Cascadia Code"));
assert!(svg.contains(".heading"));
assert!(svg.contains(".body"));
assert!(svg.contains(".mono"));
}
#[test]
fn test_svg_builder_no_manifest_without_grid_mode() {
let svg = SvgBuilder::new().build();
assert!(!svg.contains("GRID PROTOCOL MANIFEST"));
}
}