diff --git a/Cargo.lock b/Cargo.lock index 0bb3230..559cebc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,5 +3,88 @@ version = 4 [[package]] -name = "canvas-rs" +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "canvas" version = "0.1.0" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "images" +version = "0.1.0" +dependencies = [ + "canvas", + "png", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" diff --git a/Cargo.toml b/Cargo.toml index 2b932bd..b53383c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ -[package] -name = "canvas-rs" -version = "0.1.0" -edition = "2024" - -[dependencies] +[workspace] +members = [ + "canvas", + "images", +] +resolver = "2" diff --git a/canvas/Cargo.toml b/canvas/Cargo.toml new file mode 100644 index 0000000..d7ebe49 --- /dev/null +++ b/canvas/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "canvas" +version = "0.1.0" +edition = "2021" + +[dependencies] diff --git a/src/canvas.rs b/canvas/src/canvas.rs similarity index 92% rename from src/canvas.rs rename to canvas/src/canvas.rs index 0616022..d7b17d2 100644 --- a/src/canvas.rs +++ b/canvas/src/canvas.rs @@ -4,7 +4,6 @@ use std::rc::Rc; use crate::color::{parse_color, Color}; use crate::image::ImageData; use crate::path::{Path, PathCommand}; -use crate::png::{base64_encode, encode_png}; use crate::render::{self, LineCap}; // ── Canvas ─────────────────────────────────────────────────────────────────── @@ -12,13 +11,12 @@ use crate::render::{self, LineCap}; /// A 2-D drawing surface, analogous to the HTML `` element. /// /// ``` -/// use canvas_rs::Canvas; +/// use canvas::Canvas; /// /// let canvas = Canvas::new(100, 100); /// let mut ctx = canvas.get_context("2d").unwrap(); /// ctx.set_fill_style("red"); /// ctx.fill_rect(0.0, 0.0, 100.0, 100.0); -/// let _url = canvas.to_data_url(); /// ``` pub struct Canvas { pub(crate) width: u32, @@ -56,25 +54,6 @@ impl Canvas { }) } - /// Encode the canvas contents as a `data:image/png;base64,...` URL. - /// - /// The optional `type_` parameter is accepted for API compatibility but - /// only `"image/png"` is supported. The `quality` parameter is ignored - /// for PNG. - pub fn to_data_url(&self) -> String { - self.to_data_url_with_options("image/png", 1.0) - } - - pub fn to_data_url_with_type(&self, type_: &str) -> String { - self.to_data_url_with_options(type_, 1.0) - } - - pub fn to_data_url_with_options(&self, _type_: &str, _quality: f64) -> String { - let buf = self.buffer.borrow(); - let png = encode_png(self.width, self.height, &buf); - format!("data:image/png;base64,{}", base64_encode(&png)) - } - /// Return the canvas width in pixels. pub fn width(&self) -> u32 { self.width @@ -89,6 +68,11 @@ impl Canvas { pub fn get_image_data(&self) -> ImageData { ImageData::from_rgba(self.width, self.height, self.buffer.borrow().clone()) } + + /// Borrow the raw RGBA pixel buffer. + pub fn pixels(&self) -> std::cell::Ref<'_, Vec> { + self.buffer.borrow() + } } // ── Context2D ──────────────────────────────────────────────────────────────── diff --git a/src/color.rs b/canvas/src/color.rs similarity index 100% rename from src/color.rs rename to canvas/src/color.rs diff --git a/src/image.rs b/canvas/src/image.rs similarity index 100% rename from src/image.rs rename to canvas/src/image.rs diff --git a/src/lib.rs b/canvas/src/lib.rs similarity index 77% rename from src/lib.rs rename to canvas/src/lib.rs index 85efc2a..90c1fbf 100644 --- a/src/lib.rs +++ b/canvas/src/lib.rs @@ -1,11 +1,13 @@ //! Pure-Rust 2-D drawing library with a web-canvas-like API. //! -//! No external dependencies are required. +//! No external dependencies are required. All drawing operations work on an +//! in-memory RGBA pixel buffer. To encode or decode PNG images, use the +//! companion `images` crate. //! //! # Quick start //! //! ``` -//! use canvas_rs::Canvas; +//! use canvas::Canvas; //! //! // Create a 200×100 canvas. //! let canvas = Canvas::new(200, 100); @@ -20,17 +22,12 @@ //! ctx.begin_path(); //! ctx.arc(100.0, 50.0, 40.0, 0.0, std::f64::consts::PI * 2.0, false); //! ctx.fill(); -//! -//! // Export as data URL. -//! let url = canvas.to_data_url(); -//! assert!(url.starts_with("data:image/png;base64,")); //! ``` pub mod canvas; pub mod color; pub mod image; pub mod path; -pub mod png; pub mod render; pub use canvas::{Canvas, Context2D}; diff --git a/src/path.rs b/canvas/src/path.rs similarity index 95% rename from src/path.rs rename to canvas/src/path.rs index 1266444..51f7379 100644 --- a/src/path.rs +++ b/canvas/src/path.rs @@ -77,10 +77,10 @@ impl Path { } current.clear(); // pen stays at the start of the closed sub-path (web spec) - if let Some(sp) = sub_paths.last() - && let Some(&p) = sp.first() - { - pen = p; + if let Some(sp) = sub_paths.last() { + if let Some(&p) = sp.first() { + pen = p; + } } } } diff --git a/src/render.rs b/canvas/src/render.rs similarity index 99% rename from src/render.rs rename to canvas/src/render.rs index e648547..4eb40fd 100644 --- a/src/render.rs +++ b/canvas/src/render.rs @@ -25,10 +25,10 @@ pub fn put_pixel( return; } let idx = (y as u32 * width + x as u32) as usize; - if let Some(mask) = clip - && !mask[idx] - { - return; + if let Some(mask) = clip { + if !mask[idx] { + return; + } } let base = idx * 4; let dst = Color::rgba(buf[base], buf[base + 1], buf[base + 2], buf[base + 3]); diff --git a/images/Cargo.toml b/images/Cargo.toml new file mode 100644 index 0000000..134d3a2 --- /dev/null +++ b/images/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "images" +version = "0.1.0" +edition = "2021" + +[dependencies] +canvas = { path = "../canvas" } +png = "0.17" diff --git a/src/png.rs b/images/src/encoder.rs similarity index 99% rename from src/png.rs rename to images/src/encoder.rs index 302e57c..84e8728 100644 --- a/src/png.rs +++ b/images/src/encoder.rs @@ -150,7 +150,7 @@ const B64_ALPHABET: &[u8] = /// Encode `data` to standard base-64 (with `=` padding). pub fn base64_encode(data: &[u8]) -> String { - let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + let mut out = String::with_capacity((data.len() + 2) / 3 * 4); let mut chunks = data.chunks_exact(3); for chunk in chunks.by_ref() { let n = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | (chunk[2] as u32); diff --git a/images/src/lib.rs b/images/src/lib.rs new file mode 100644 index 0000000..f9aa63d --- /dev/null +++ b/images/src/lib.rs @@ -0,0 +1,75 @@ +//! PNG encoding and decoding for the `canvas` crate. +//! +//! This crate wraps the core `canvas` RGBA buffer with PNG import/export +//! capabilities. +//! +//! # Quick start +//! +//! ```no_run +//! use canvas::Canvas; +//! +//! let mut canvas = Canvas::new(200, 100); +//! let mut ctx = canvas.get_context("2d").unwrap(); +//! ctx.set_fill_style("red"); +//! ctx.fill_rect(0.0, 0.0, 200.0, 100.0); +//! +//! // Export to a data URL. +//! let url = images::to_data_url(&canvas); +//! assert!(url.starts_with("data:image/png;base64,")); +//! +//! // Or get raw PNG bytes. +//! let png_bytes: Vec = images::to_blob(&canvas); +//! ``` + +pub mod encoder; + +pub use encoder::{base64_encode, encode_png}; + +use canvas::{Canvas, ImageData}; + +/// Encode a `Canvas` as raw PNG bytes. +pub fn to_blob(canvas: &Canvas) -> Vec { + let buf = canvas.pixels(); + encode_png(canvas.width(), canvas.height(), &buf) +} + +/// Encode a `Canvas` as a `data:image/png;base64,...` URL. +pub fn to_data_url(canvas: &Canvas) -> String { + format!("data:image/png;base64,{}", base64_encode(&to_blob(canvas))) +} + +/// Decode a PNG byte slice into an [`ImageData`] with RGBA pixels. +/// +/// Returns an error string if the bytes cannot be decoded as a valid PNG. +pub fn from_png(bytes: &[u8]) -> Result { + use png::ColorType; + use std::io::Cursor; + + let decoder = png::Decoder::new(Cursor::new(bytes)); + let mut reader = decoder + .read_info() + .map_err(|e| format!("PNG read_info error: {e}"))?; + let mut buf = vec![0u8; reader.output_buffer_size()]; + let frame = reader + .next_frame(&mut buf) + .map_err(|e| format!("PNG decode error: {e}"))?; + let raw = buf[..frame.buffer_size()].to_vec(); + let (w, h) = (frame.width, frame.height); + let rgba = match frame.color_type { + ColorType::Rgba => raw, + ColorType::Rgb => raw + .chunks(3) + .flat_map(|p| [p[0], p[1], p[2], 255u8]) + .collect(), + ColorType::Grayscale => raw + .iter() + .flat_map(|&v| [v, v, v, 255u8]) + .collect(), + ColorType::GrayscaleAlpha => raw + .chunks(2) + .flat_map(|p| [p[0], p[0], p[0], p[1]]) + .collect(), + other => return Err(format!("unsupported PNG color type: {other:?}")), + }; + Ok(ImageData::from_rgba(w, h, rgba)) +} diff --git a/images/tests/image_220x200.png b/images/tests/image_220x200.png new file mode 100644 index 0000000..b4b85f6 Binary files /dev/null and b/images/tests/image_220x200.png differ diff --git a/tests/integration_test.rs b/images/tests/integration_test.rs similarity index 82% rename from tests/integration_test.rs rename to images/tests/integration_test.rs index bbd2fae..30092e1 100644 --- a/tests/integration_test.rs +++ b/images/tests/integration_test.rs @@ -1,4 +1,4 @@ -use canvas_rs::{Canvas, Color, ImageData}; +use canvas::{Canvas, Color, ImageData}; use std::f64::consts::PI; // ── Canvas creation ────────────────────────────────────────────────────────── @@ -37,26 +37,18 @@ fn get_context_unknown_returns_none() { #[test] fn to_data_url_starts_with_correct_prefix() { let canvas = Canvas::new(8, 8); - let url = canvas.to_data_url(); + let url = images::to_data_url(&canvas); assert!( url.starts_with("data:image/png;base64,"), "URL should start with data:image/png;base64," ); } -#[test] -fn to_data_url_with_options_respects_type() { - let canvas = Canvas::new(4, 4); - let url = canvas.to_data_url_with_options("image/png", 1.0); - assert!(url.starts_with("data:image/png;base64,")); -} - #[test] fn to_data_url_png_header_valid() { let canvas = Canvas::new(2, 2); - let url = canvas.to_data_url(); + let url = images::to_data_url(&canvas); let b64 = url.strip_prefix("data:image/png;base64,").unwrap(); - // Decode first few bytes manually to check the PNG signature. // PNG signature in base64 starts with "iVBOR". assert!( b64.starts_with("iVBOR"), @@ -64,6 +56,15 @@ fn to_data_url_png_header_valid() { ); } +// ── to_blob ────────────────────────────────────────────────────────────────── + +#[test] +fn to_blob_is_valid_png() { + let canvas = Canvas::new(4, 4); + let bytes = images::to_blob(&canvas); + assert_eq!(&bytes[..8], &[137, 80, 78, 71, 13, 10, 26, 10], "to_blob should return a valid PNG"); +} + // ── fillStyle / strokeStyle properties ─────────────────────────────────────── #[test] @@ -454,7 +455,7 @@ fn alpha_blending_on_fill_rect() { #[test] fn color_hex_short() { - use canvas_rs::color::parse_color; + use canvas::color::parse_color; assert_eq!(parse_color("#f00"), Some(Color::rgb(255, 0, 0))); assert_eq!(parse_color("#0f0"), Some(Color::rgb(0, 255, 0))); assert_eq!(parse_color("#00f"), Some(Color::rgb(0, 0, 255))); @@ -462,13 +463,13 @@ fn color_hex_short() { #[test] fn color_hex_long() { - use canvas_rs::color::parse_color; + use canvas::color::parse_color; assert_eq!(parse_color("#ff0000"), Some(Color::rgb(255, 0, 0))); } #[test] fn color_named_all_common() { - use canvas_rs::color::parse_color; + use canvas::color::parse_color; let colors = ["red","green","blue","white","black","transparent", "yellow","cyan","magenta","orange","purple","pink", "gray","silver","lime","navy","teal","coral","gold"]; @@ -481,21 +482,74 @@ fn color_named_all_common() { #[test] fn png_encode_nonempty() { - use canvas_rs::png::encode_png; + use images::encode_png; let pixels = vec![255u8, 0, 0, 255, 0, 255, 0, 255]; // 2×1 (red, green) let png = encode_png(2, 1, &pixels); // Must start with PNG signature. assert_eq!(&png[..8], &[137, 80, 78, 71, 13, 10, 26, 10]); - // Must contain the IEND tag (4 bytes length=0, then "IEND", then 4-byte CRC). - // Find "IEND" in the file. + // Must contain the IEND tag. let has_iend = png.windows(4).any(|w| w == b"IEND"); assert!(has_iend, "PNG should contain IEND chunk"); } #[test] fn base64_round_trip() { - use canvas_rs::png::base64_encode; + use images::base64_encode; let original = b"Hello, World!"; let encoded = base64_encode(original); assert_eq!(encoded, "SGVsbG8sIFdvcmxkIQ=="); } + +// ── from_png (PNG decoding) ─────────────────────────────────────────────────── + +#[test] +fn from_png_roundtrip() { + // Encode a canvas, then decode it back and check dimensions. + let canvas = Canvas::new(8, 8); + let png_bytes = images::to_blob(&canvas); + let img = images::from_png(&png_bytes).expect("round-trip PNG decode should succeed"); + assert_eq!(img.width, 8); + assert_eq!(img.height, 8); + assert_eq!(img.data.len(), 8 * 8 * 4); +} + +// ── fill_rect + draw image from file ───────────────────────────────────────── + +#[test] +fn fill_rect_green_then_draw_image() { + let canvas = Canvas::new(300, 300); + let mut ctx = canvas.get_context("2d").unwrap(); + + // Draw green rectangle (CSS "green" = rgb(0, 128, 0)). + ctx.set_fill_style("green"); + ctx.fill_rect(10.0, 10.0, 150.0, 100.0); + + // Verify the rectangle is painted before the image is drawn on top. + let snapshot = canvas.get_image_data(); + let green_px = snapshot.get_pixel(50, 50); + assert_eq!(green_px.r, 0, "green rect r should be 0"); + assert_eq!(green_px.g, 128, "green rect g should be 128"); + assert_eq!(green_px.b, 0, "green rect b should be 0"); + assert_eq!(green_px.a, 255, "green rect should be fully opaque"); + + // Pixel outside the rect should still be transparent at this point. + let outside_snap = snapshot.get_pixel(5, 5); + assert_eq!(outside_snap.a, 0, "pixel outside rect should be transparent before image draw"); + + // Load tests/image_220x200.png using images::from_png and draw it on top. + let png_bytes = std::fs::read("tests/image_220x200.png").expect("could not read PNG file"); + let img_data = images::from_png(&png_bytes).expect("could not decode PNG"); + assert_eq!(img_data.width, 220, "loaded image width should be 220"); + assert_eq!(img_data.height, 200, "loaded image height should be 200"); + ctx.draw_image(&img_data, 0.0, 0.0); + + // After drawing, a pixel outside both the image (220×200) and the green + // rect should still be transparent. + let result = canvas.get_image_data(); + let far_px = result.get_pixel(260, 260); + assert_eq!(far_px.a, 0, "pixel outside image and rect should remain transparent"); + + // The canvas should export to a valid PNG data URL without panicking. + let url = images::to_data_url(&canvas); + assert!(url.starts_with("data:image/png;base64,"), "export should produce a valid PNG data URL"); +}