From bf4b51af43005415deac6ba475992c83b5f2f82e Mon Sep 17 00:00:00 2001 From: Ruben Beltran del Rio Date: Tue, 16 Dec 2025 16:35:08 +0100 Subject: Adapt to edition 2024 --- Cargo.lock | 2 +- Cargo.toml | 2 +- Makefile | 5 +- README.md | 57 +++------------- benches/rendering.rs | 11 ++-- src/configuration.rs | 22 +++---- src/geometry.rs | 63 ++++++++++++------ src/patterns.rs | 8 +-- src/renderer.rs | 165 ++++++++++++++++++++++++++++------------------- src/smart_positioning.rs | 4 ++ src/stage_type.rs | 3 + 11 files changed, 187 insertions(+), 155 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8f06b42..0af0261 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2011,7 +2011,7 @@ checksum = "c5a2c331c28b392f06cd9d81118a6f73b084708c6ff320457f4cbd75912d499b" [[package]] name = "wmap-renderer" -version = "0.1.0" +version = "1.0.0" dependencies = [ "cairo-rs", "criterion", diff --git a/Cargo.toml b/Cargo.toml index 1b5a49d..71d4e45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wmap-renderer" -version = "0.1.0" +version = "1.0.0" edition = "2024" license = "AGPL-3.0-or-later" description = "A parser for wmap formatted Wardley Map files." diff --git a/Makefile b/Makefile index 0d4b7d3..1119493 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,9 @@ build: prepare test: cargo test +examples: + cargo run --example generate_map + coverage: cargo tarpaulin --fail-under 100 @@ -35,4 +38,4 @@ ifneq (,$(findstring refs/tags/,$(GIT_REF))) $(MAKE) release endif -.PHONY: default set_rust prepare build test coverage format lint benchmark release ci +.PHONY: default set_rust prepare build test coverage format lint benchmark release ci examples diff --git a/README.md b/README.md index 889b75e..686d2b3 100644 --- a/README.md +++ b/README.md @@ -15,16 +15,15 @@ Add it to your project with cargo add cargo add wmap-renderer ``` - ## Usage ```rust use wmap_renderer::{render_to_png, render_to_svg, Configuration, StageType}; let map_source = r#" -Tea (90, 50) -Cup (80, 40) -Tea -> Cup +Computer (90, 20) +Silicon Ore (80, 90) +Computer -> Silicon Ore "#; let map = wmap_parser::parse(map_source); @@ -41,14 +40,7 @@ std::fs::write("map.svg", svg_data)?; ## Configuration -The renderer uses the same default configuration as the JavaScript version: - -- **Map Size**: 1300×1000 pixels (with 42px padding) -- **Colors**: #0F261F for axes/vertices/labels, #FA2B00 for inertia, #4F8FE6 for evolution -- **Fonts**: "Helvetica Neue, Helvetica, Arial, sans-serif" -- **Sizes**: 25×25px vertices, 10px arrowheads, 0.5px line width - -All configuration options can be customized: +The renderer lets you configure sizes, colors, and font. ```rust let mut config = Configuration::default(); @@ -57,22 +49,20 @@ config.theme.sizes.vertex_size = 30.0; config.options.show_background = false; ``` -## Running Examples +## Development + +### Running Examples ```bash -cargo run --example generate_map +make examples ``` -This will generate `example.png`, `example.svg`, and `example_cynefin.png` in the project root. - -## Running Tests +### Running Tests ```bash -cargo test +make test ``` -All 22 tests should pass (17 unit tests + 5 integration tests). - ## Performance The drawing is not optimized for live-editing. It's relatively quick to @@ -87,30 +77,3 @@ You can run the benchmarks by using: ```bash make benchmark ``` - -## Dependencies - -- **cairo-rs**: Vector graphics rendering (PNG, SVG support) -- **geo**: Geometric algorithms (concave hull for groups) -- **wmap-parser**: Wardley map source parser (local dependency) -- **thiserror**: Error handling -- **tempfile**: SVG output (temporary file handling) - -## Implementation Status - -### Completed Features -- ✅ All rendering features (axes, vertices, dependencies, notes, inertias, evolutions, groups) -- ✅ Background evolution stage patterns -- ✅ All 21 stage types -- ✅ All 4 vertex shapes -- ✅ PNG and SVG output -- ✅ Concave hull for groups -- ✅ Configuration system matching JS defaults -- ✅ Comprehensive test suite -- ✅ Example programs - -## License - -See LICENSE file. -[wmap-parser-rust]: https://crates.io/crates/wmap-parser -[map]: https://map.tranquil.systems diff --git a/benches/rendering.rs b/benches/rendering.rs index 7fe7877..81b2e44 100644 --- a/benches/rendering.rs +++ b/benches/rendering.rs @@ -1,4 +1,5 @@ -use criterion::{Criterion, black_box, criterion_group, criterion_main}; +use criterion::{Criterion, criterion_group, criterion_main}; +use std::hint::black_box; use std::fs; use wmap_renderer::{Configuration, StageType, render_to_surface}; @@ -19,7 +20,7 @@ fn benchmark_rendering(c: &mut Criterion) { StageType::default(), &configuration, ) - }) + }); }); c.bench_function("render example map (no smart labels))", |b| { b.iter(|| { @@ -28,7 +29,7 @@ fn benchmark_rendering(c: &mut Criterion) { StageType::default(), &configuration_no_smart, ) - }) + }); }); let huge_source = @@ -36,7 +37,7 @@ fn benchmark_rendering(c: &mut Criterion) { let huge_map = wmap_parser::parse(&huge_source); c.bench_function("render huge map", |b| { - b.iter(|| render_to_surface(black_box(&huge_map), StageType::default(), &configuration)) + b.iter(|| render_to_surface(black_box(&huge_map), StageType::default(), &configuration)); }); c.bench_function("render huge map", |b| { b.iter(|| { @@ -45,7 +46,7 @@ fn benchmark_rendering(c: &mut Criterion) { StageType::default(), &configuration_no_smart, ) - }) + }); }); } diff --git a/src/configuration.rs b/src/configuration.rs index bc99781..715e4a7 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -32,19 +32,19 @@ impl Default for Colors { fn default() -> Self { Self { background: (1.0, 1.0, 1.0), - axis: (0.058824, 0.149020, 0.121569), // Deep Slate Green - vertex: (0.058824, 0.149020, 0.121569), - label: (0.058824, 0.149020, 0.121569), - stage_foreground: (0.854901, 0.901960, 0.890196), + axis: (0.058_824, 0.149_020, 0.121_569), // Deep Slate Green + vertex: (0.058_824, 0.149_020, 0.121_569), + label: (0.058_824, 0.149_020, 0.121_569), + stage_foreground: (0.854_901, 0.901_960, 0.890_196), stage_background: (1.0, 1.0, 1.0), - inertia: (0.980392, 0.168627, 0.0), - evolution: (0.309804, 0.560784, 0.901961), + inertia: (0.980_392, 0.168_627, 0.0), + evolution: (0.309_804, 0.560_784, 0.901_961), groups: vec![ - (0.227451, 0.356863, 0.627451), // Olympic Blue - (0.768627, 0.270588, 0.211765), // Jasper Red - (0.560784, 0.745098, 0.572549), // Light Porcelain Green - (0.980392, 0.835294, 0.360784), // Naples Yellow - (0.925490, 0.611765, 0.670588), // Hermosa Pink + (0.227_451, 0.356_863, 0.627_451), // Olympic Blue + (0.768_627, 0.270_588, 0.211_765), // Jasper Red + (0.560_784, 0.745_098, 0.572_549), // Light Porcelain Green + (0.980_392, 0.835_294, 0.360_784), // Naples Yellow + (0.925_490, 0.611_765, 0.670_588), // Hermosa Pink ], } } diff --git a/src/geometry.rs b/src/geometry.rs index 0841183..358620f 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -103,8 +103,12 @@ pub struct SpatialGrid { impl SpatialGrid { /// Creates a new spatial grid for the given dimensions pub fn new(width: f64, height: f64, cell_size: f64) -> Self { - let columns = ((width / cell_size).ceil() as usize).max(1); - let rows = ((height / cell_size).ceil() as usize).max(1); + // Safe cast: width and height are positive map dimensions, divided by cell_size and clamped to at least 1.0 + // The resulting grid size will be reasonable (< millions of cells) for typical map sizes + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let columns = (width / cell_size).ceil().max(1.0) as usize; + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let rows = (height / cell_size).ceil().max(1.0) as usize; let cells = vec![Vec::new(); columns * rows]; Self { @@ -122,10 +126,17 @@ impl SpatialGrid { let min_y = line.start.1.min(line.end.1); let max_y = line.start.1.max(line.end.1); - let start_column = ((min_x / self.cell_size).floor() as usize).min(self.columns - 1); - let end_column = ((max_x / self.cell_size).floor() as usize).min(self.columns - 1); - let start_row = ((min_y / self.cell_size).floor() as usize).min(self.rows - 1); - let end_row = ((max_y / self.cell_size).floor() as usize).min(self.rows - 1); + // Safe cast: coordinates are clamped to [0.0, max] before casting, ensuring valid grid indices + // The .max(0.0) ensures non-negative, and .min(columns/rows - 1) ensures within bounds + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let start_column = + ((min_x / self.cell_size).floor().max(0.0) as usize).min(self.columns - 1); + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let end_column = ((max_x / self.cell_size).floor().max(0.0) as usize).min(self.columns - 1); + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let start_row = ((min_y / self.cell_size).floor().max(0.0) as usize).min(self.rows - 1); + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let end_row = ((max_y / self.cell_size).floor().max(0.0) as usize).min(self.rows - 1); for row in start_row..=end_row { for col in start_column..=end_column { @@ -143,18 +154,34 @@ impl SpatialGrid { let min_y = rect.y; let max_y = rect.y + rect.height; - let start_column = ((min_x / self.cell_size).floor() as isize) - .max(0) - .min(self.columns as isize - 1) as usize; - let end_column = ((max_x / self.cell_size).floor() as isize) - .max(0) - .min(self.columns as isize - 1) as usize; - let start_row = ((min_y / self.cell_size).floor() as isize) - .max(0) - .min(self.rows as isize - 1) as usize; - let end_row = ((max_y / self.cell_size).floor() as isize) - .max(0) - .min(self.rows as isize - 1) as usize; + let max_column_idx = if self.columns > 0 { + self.columns - 1 + } else { + 0 + }; + let max_row_idx = if self.rows > 0 { self.rows - 1 } else { 0 }; + + // Safe cast: grid indices are small (< thousands) for typical map sizes, well within f64 precision + // The coordinates are clamped to valid grid bounds before casting to usize + #[allow(clippy::cast_precision_loss)] + let max_column_f64 = max_column_idx as f64; + #[allow(clippy::cast_precision_loss)] + let max_row_f64 = max_row_idx as f64; + + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let start_column = ((min_x / self.cell_size) + .floor() + .max(0.0) + .min(max_column_f64)) as usize; + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let end_column = ((max_x / self.cell_size) + .floor() + .max(0.0) + .min(max_column_f64)) as usize; + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let start_row = ((min_y / self.cell_size).floor().max(0.0).min(max_row_f64)) as usize; + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let end_row = ((max_y / self.cell_size).floor().max(0.0).min(max_row_f64)) as usize; let mut line_indices = Vec::new(); for row in start_row..=end_row { diff --git a/src/patterns.rs b/src/patterns.rs index 254433f..8fb2918 100644 --- a/src/patterns.rs +++ b/src/patterns.rs @@ -31,13 +31,13 @@ pub fn create_pattern( { let context = Context::new(&surface)?; - for y in 0..8 { - for x in 0..8 { - let value = pattern_data[y * 8 + x]; + for y in 0_u32..8 { + for x in 0_u32..8 { + let value = pattern_data[(y * 8 + x) as usize]; let (r, g, b) = if value == 0 { foreground } else { background }; context.set_source_rgb(r, g, b); - context.rectangle(x as f64, y as f64, 1.0, 1.0); + context.rectangle(f64::from(x), f64::from(y), 1.0, 1.0); context.fill()?; } } diff --git a/src/renderer.rs b/src/renderer.rs index a673bac..69c3f70 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -32,16 +32,26 @@ use crate::utils::percent_to_pixel; /// # Returns /// /// A vector of bytes containing the PNG image data +/// +/// # Errors +/// +/// Returns `RenderError` if the surface creation, rendering, or PNG encoding fails pub fn render_to_png( map: &Map, stage_type: StageType, configuration: &Configuration, ) -> Result, RenderError> { - let total_width = - (configuration.theme.sizes.map_width + configuration.theme.sizes.padding * 2.0) as i32; + // Safe cast: map dimensions are typically in the range of hundreds to thousands of pixels + // Cairo's i32 surface dimensions can handle up to 2^31-1, so truncation is not a concern for typical maps + #[allow(clippy::cast_possible_truncation)] + let total_width = (configuration.theme.sizes.map_width + + configuration.theme.sizes.padding * 2.0) + .round() as i32; + #[allow(clippy::cast_possible_truncation)] let total_height = (configuration.theme.sizes.map_height + configuration.theme.sizes.padding - + configuration.theme.sizes.bottom_padding) as i32; + + configuration.theme.sizes.bottom_padding) + .round() as i32; let surface = ImageSurface::create(Format::ARgb32, total_width, total_height)?; let context = Context::new(&surface)?; @@ -63,17 +73,27 @@ pub fn render_to_png( /// /// # Returns /// -/// A string containing the SVG data +/// An `ImageSurface` containing the rendered map +/// +/// # Errors +/// +/// Returns `RenderError` if the surface creation or rendering fails pub fn render_to_surface( map: &Map, stage_type: StageType, configuration: &Configuration, ) -> Result { - let total_width = - (configuration.theme.sizes.map_width + configuration.theme.sizes.padding * 2.0) as i32; + // Safe cast: map dimensions are typically in the range of hundreds to thousands of pixels + // Cairo's i32 surface dimensions can handle up to 2^31-1, so truncation is not a concern for typical maps + #[allow(clippy::cast_possible_truncation)] + let total_width = (configuration.theme.sizes.map_width + + configuration.theme.sizes.padding * 2.0) + .round() as i32; + #[allow(clippy::cast_possible_truncation)] let total_height = (configuration.theme.sizes.map_height + configuration.theme.sizes.padding - + configuration.theme.sizes.bottom_padding) as i32; + + configuration.theme.sizes.bottom_padding) + .round() as i32; let surface = ImageSurface::create(Format::ARgb32, total_width, total_height)?; let context = Context::new(&surface)?; @@ -94,6 +114,10 @@ pub fn render_to_surface( /// # Returns /// /// A string containing the SVG data +/// +/// # Errors +/// +/// Returns `RenderError` if the surface creation, rendering, or file I/O fails pub fn render_to_svg( map: &Map, stage_type: StageType, @@ -139,7 +163,7 @@ fn render( // Build component map once and reuse it (pre-allocate capacity for better performance) // Use FxHashMap for faster hashing (we don't need cryptographic security) let mut component_map: FxHashMap = - FxHashMap::with_capacity_and_hasher(map.components.len(), Default::default()); + FxHashMap::with_capacity_and_hasher(map.components.len(), rustc_hash::FxBuildHasher); for component in &map.components { let x = percent_to_pixel(component.coordinates.0, configuration.theme.sizes.map_width); let y = percent_to_pixel( @@ -260,8 +284,8 @@ fn draw_axes( let fonts = &configuration.theme.fonts; context.save()?; - let (r, g, b) = configuration.theme.colors.axis; - context.set_source_rgb(r, g, b); + let (red, green, blue) = configuration.theme.colors.axis; + context.set_source_rgb(red, green, blue); context.set_line_width(sizes.line_width * 2.0); // Y-axis and X-axis @@ -271,8 +295,8 @@ fn draw_axes( context.stroke()?; // Text setup - let (r, g, b) = configuration.theme.colors.label; - context.set_source_rgb(r, g, b); + let (red, green, blue) = configuration.theme.colors.label; + context.set_source_rgb(red, green, blue); context.select_font_face( &configuration.theme.fonts.face, FontSlant::Normal, @@ -323,7 +347,7 @@ fn draw_axes( // Draw each stage label with wrapping for (i, &label) in stage_labels.iter().enumerate() { - let x = match i { + let stage_x = match i { 0 => 0.0, 1 => stages[0], 2 => stages[1], @@ -334,8 +358,10 @@ fn draw_axes( let wrapped_lines = wrap_text(context, label, stage_widths[i])?; for (line_index, line) in wrapped_lines.iter().enumerate() { - let y = stage_label_y + line_index as f64 * line_height; - context.move_to(x, y); + // Safe cast: line_index is a small number (typically < 10 for wrapped stage labels) + #[allow(clippy::cast_precision_loss)] + let text_y = stage_label_y + line_index as f64 * line_height; + context.move_to(stage_x, text_y); context.show_text(line)?; } } @@ -383,13 +409,11 @@ fn draw_dependencies( let arrow_angle = PI / 4.0; // 45 degrees for dependency in &map.dependencies { - let from_coords = match component_map.get(&dependency.from.to_lowercase()) { - Some(coords) => coords, - None => continue, + let Some(from_coords) = component_map.get(&dependency.from.to_lowercase()) else { + continue; }; - let to_coords = match component_map.get(&dependency.to.to_lowercase()) { - Some(coords) => coords, - None => continue, + let Some(to_coords) = component_map.get(&dependency.to.to_lowercase()) else { + continue; }; let origin_x = from_coords.0 + half_width; @@ -447,8 +471,11 @@ fn draw_groups( } // Create ONE offscreen surface for ALL groups for opacity handling + // Safe cast: map dimensions plus vertex size are small values (< 10000 typically) + #[allow(clippy::cast_possible_truncation)] let width = (configuration.theme.sizes.map_width + configuration.theme.sizes.vertex_size) as i32; + #[allow(clippy::cast_possible_truncation)] let height = (configuration.theme.sizes.map_height + configuration.theme.sizes.vertex_size) as i32; let offscreen = ImageSurface::create(Format::ARgb32, width, height)?; @@ -482,7 +509,7 @@ fn draw_groups( let segments = 16; let mut circle_coords = Vec::new(); for i in 0..=segments { - let angle = (i as f64 / segments as f64) * 2.0 * PI; + let angle = (f64::from(i) / f64::from(segments)) * 2.0 * PI; circle_coords.push((x + radius * angle.cos(), y + radius * angle.sin())); } circle_coords @@ -541,12 +568,13 @@ fn draw_vertices( configuration: &Configuration, ) -> Result<(), RenderError> { context.save()?; - let (r, g, b) = configuration.theme.colors.vertex; - context.set_source_rgb(r, g, b); + let (red, green, blue) = configuration.theme.colors.vertex; + context.set_source_rgb(red, green, blue); for component in &map.components { - let x = percent_to_pixel(component.coordinates.0, configuration.theme.sizes.map_width); - let y = percent_to_pixel( + let vertex_x = + percent_to_pixel(component.coordinates.0, configuration.theme.sizes.map_width); + let vertex_y = percent_to_pixel( component.coordinates.1, configuration.theme.sizes.map_height, ); @@ -554,8 +582,8 @@ fn draw_vertices( draw_vertex( context, &component.shape, - x, - y, + vertex_x, + vertex_y, configuration.theme.sizes.vertex_size, configuration.theme.sizes.vertex_size, )?; @@ -572,8 +600,8 @@ fn draw_vertex_labels( component_map: &rustc_hash::FxHashMap, ) -> Result<(), RenderError> { context.save()?; - let (r, g, b) = configuration.theme.colors.label; - context.set_source_rgb(r, g, b); + let (red, green, blue) = configuration.theme.colors.label; + context.set_source_rgb(red, green, blue); context.select_font_face( &configuration.theme.fonts.face, FontSlant::Normal, @@ -636,14 +664,14 @@ fn draw_vertex_labels( } else { // Use simple positioning for simple maps for component in &map.components { - let x = + let vertex_x = percent_to_pixel(component.coordinates.0, configuration.theme.sizes.map_width); - let y = percent_to_pixel( + let vertex_y = percent_to_pixel( component.coordinates.1, configuration.theme.sizes.map_height, ); - let label_x = x + configuration.theme.sizes.vertex_size + 5.0; - let label_y = y + let label_x = vertex_x + configuration.theme.sizes.vertex_size + 5.0; + let label_y = vertex_y + configuration.theme.sizes.vertex_size / 2.0 + configuration.theme.fonts.vertex_label / 3.0; @@ -654,13 +682,14 @@ fn draw_vertex_labels( } else { // Simple label positioning (to the right of vertex) for component in &map.components { - let x = percent_to_pixel(component.coordinates.0, configuration.theme.sizes.map_width); - let y = percent_to_pixel( + let vertex_x = + percent_to_pixel(component.coordinates.0, configuration.theme.sizes.map_width); + let vertex_y = percent_to_pixel( component.coordinates.1, configuration.theme.sizes.map_height, ); - let label_x = x + configuration.theme.sizes.vertex_size + 5.0; - let label_y = y + let label_x = vertex_x + configuration.theme.sizes.vertex_size + 5.0; + let label_y = vertex_y + configuration.theme.sizes.vertex_size / 2.0 + configuration.theme.fonts.vertex_label / 3.0; @@ -680,8 +709,8 @@ fn draw_inertias( component_map: &rustc_hash::FxHashMap, ) -> Result<(), RenderError> { context.save()?; - let (r, g, b) = configuration.theme.colors.inertia; - context.set_source_rgb(r, g, b); + let (red, green, blue) = configuration.theme.colors.inertia; + context.set_source_rgb(red, green, blue); let corner_radius = 2.0; let rect_width = configuration.theme.sizes.vertex_size / 2.0; @@ -690,11 +719,18 @@ fn draw_inertias( for inertia in &map.inertias { if let Some(&(origin_x, origin_y)) = component_map.get(&inertia.component.to_lowercase()) { // Position relative to vertex top-left corner - let x = origin_x + 3.0 * configuration.theme.sizes.vertex_size; - let y = origin_y - (configuration.theme.sizes.vertex_size * 2.0) / 3.0; + let inertia_x = origin_x + 3.0 * configuration.theme.sizes.vertex_size; + let inertia_y = origin_y - (configuration.theme.sizes.vertex_size * 2.0) / 3.0; // Draw rounded rectangle - draw_rounded_rect(context, x, y, rect_width, rect_height, corner_radius)?; + draw_rounded_rect( + context, + inertia_x, + inertia_y, + rect_width, + rect_height, + corner_radius, + ); context.fill()?; } } @@ -703,14 +739,7 @@ fn draw_inertias( Ok(()) } -fn draw_rounded_rect( - context: &Context, - x: f64, - y: f64, - width: f64, - height: f64, - radius: f64, -) -> Result<(), Error> { +fn draw_rounded_rect(context: &Context, x: f64, y: f64, width: f64, height: f64, radius: f64) { let r = radius; context.new_path(); context.arc( @@ -742,7 +771,6 @@ fn draw_rounded_rect( std::f64::consts::PI, ); context.close_path(); - Ok(()) } /// Wraps text to fit within a given width, breaking at word boundaries @@ -755,7 +783,7 @@ fn wrap_text(context: &Context, text: &str, max_width: f64) -> Result [&'static str; 4] { match self { -- cgit