Skip to main content

plotkit_render_wasm/
lib.rs

1//! WASM Canvas2D rendering backend for plotkit.
2//!
3//! This crate provides `WasmRenderer`, a rendering backend that translates
4//! plotkit drawing primitives into HTML5 Canvas2D API calls via `web-sys`.
5//! It is designed to run in WebAssembly environments inside a browser.
6//!
7//! # Architecture
8//!
9//! The renderer wraps a `CanvasRenderingContext2d` obtained from an
10//! `HtmlCanvasElement`. Each plotkit primitive (path fill, path stroke,
11//! text draw, clipping, image blit) maps directly to the corresponding
12//! Canvas2D method calls.
13//!
14//! Helper functions for color conversion, font string construction, and
15//! affine transform decomposition are exposed as pure functions so they
16//! can be unit-tested without a browser environment.
17//!
18//! # Example (browser-side)
19//!
20//! ```ignore
21//! use plotkit_render_wasm::WasmRenderer;
22//! use web_sys::CanvasRenderingContext2d;
23//!
24//! let ctx: CanvasRenderingContext2d = /* obtain from canvas element */;
25//! let mut renderer = WasmRenderer::new(ctx, 800, 600);
26//! // ... use renderer via the Renderer trait ...
27//! let _ = renderer.finalize();
28//! ```
29
30#![deny(missing_docs)]
31
32// Re-export core types for downstream convenience.
33pub use plotkit_core::primitives::*;
34pub use plotkit_core::renderer::Renderer;
35
36// ---------------------------------------------------------------------------
37// Pure helper functions (testable without a browser)
38// ---------------------------------------------------------------------------
39
40/// Converts a plotkit [`Color`] to a CSS `rgba(...)` string.
41///
42/// The alpha channel is normalized from the 0-255 range to a 0.0-1.0 float
43/// with four decimal places of precision, matching CSS color level 4 syntax.
44///
45/// # Examples
46///
47/// ```
48/// use plotkit_render_wasm::{color_to_css, Color};
49///
50/// assert_eq!(color_to_css(&Color::rgb(255, 0, 0)), "rgba(255,0,0,1)");
51/// assert_eq!(color_to_css(&Color::new(0, 128, 255, 128)), "rgba(0,128,255,0.5020)");
52/// ```
53pub fn color_to_css(c: &Color) -> String {
54    if c.a == 255 {
55        format!("rgba({},{},{},1)", c.r, c.g, c.b)
56    } else {
57        format!("rgba({},{},{},{:.4})", c.r, c.g, c.b, c.a as f64 / 255.0)
58    }
59}
60
61/// Builds a CSS font shorthand string from a [`TextStyle`].
62///
63/// The resulting string follows the CSS font shorthand syntax:
64/// `"[weight] [size]px [family]"`. If no family is specified, `"sans-serif"`
65/// is used as a sensible default that works across all browsers.
66///
67/// # Examples
68///
69/// ```
70/// use plotkit_render_wasm::{build_font_string, TextStyle, FontWeight};
71///
72/// let style = TextStyle::new(14.0);
73/// assert_eq!(build_font_string(&style), "14px sans-serif");
74///
75/// let mut bold = TextStyle::new(16.0);
76/// bold.weight = FontWeight::Bold;
77/// bold.family = Some("Helvetica".to_string());
78/// assert_eq!(build_font_string(&bold), "bold 16px Helvetica");
79/// ```
80pub fn build_font_string(style: &TextStyle) -> String {
81    let weight = match style.weight {
82        FontWeight::Normal => "",
83        FontWeight::Bold => "bold ",
84    };
85    let family = style.family.as_deref().unwrap_or("sans-serif");
86    format!("{}{:.0}px {}", weight, style.size, family)
87}
88
89/// Returns the Canvas2D `textAlign` property value for a given [`HAlign`].
90///
91/// Maps plotkit's horizontal alignment enum to the string values expected
92/// by `CanvasRenderingContext2d.textAlign`.
93pub fn halign_to_canvas(align: HAlign) -> &'static str {
94    match align {
95        HAlign::Left => "left",
96        HAlign::Center => "center",
97        HAlign::Right => "right",
98    }
99}
100
101/// Returns the Canvas2D `textBaseline` property value for a given [`VAlign`].
102///
103/// Maps plotkit's vertical alignment enum to the string values expected
104/// by `CanvasRenderingContext2d.textBaseline`.
105pub fn valign_to_canvas(align: VAlign) -> &'static str {
106    match align {
107        VAlign::Top => "top",
108        VAlign::Middle => "middle",
109        VAlign::Bottom => "bottom",
110        VAlign::Baseline => "alphabetic",
111    }
112}
113
114/// Returns the Canvas2D `lineCap` property value for a given [`StrokeCap`].
115pub fn stroke_cap_to_canvas(cap: StrokeCap) -> &'static str {
116    match cap {
117        StrokeCap::Butt => "butt",
118        StrokeCap::Round => "round",
119        StrokeCap::Square => "square",
120    }
121}
122
123/// Returns the Canvas2D `lineJoin` property value for a given [`StrokeJoin`].
124pub fn stroke_join_to_canvas(join: StrokeJoin) -> &'static str {
125    match join {
126        StrokeJoin::Miter => "miter",
127        StrokeJoin::Round => "round",
128        StrokeJoin::Bevel => "bevel",
129    }
130}
131
132/// Counts the number of each path element type in a [`Path`].
133///
134/// Returns a tuple of `(move_to, line_to, quad_to, curve_to, close)` counts.
135/// Useful for diagnostics, debugging, and testing path construction.
136pub fn count_path_elements(path: &Path) -> (usize, usize, usize, usize, usize) {
137    let mut m = 0;
138    let mut l = 0;
139    let mut q = 0;
140    let mut c = 0;
141    let mut z = 0;
142    for el in &path.elements {
143        match el {
144            PathEl::MoveTo(_) => m += 1,
145            PathEl::LineTo(_) => l += 1,
146            PathEl::QuadTo(_, _) => q += 1,
147            PathEl::CurveTo(_, _, _) => c += 1,
148            PathEl::ClosePath => z += 1,
149        }
150    }
151    (m, l, q, c, z)
152}
153
154/// Decomposes a plotkit [`Affine`] transform into the six parameters
155/// expected by `CanvasRenderingContext2d.setTransform(a, b, c, d, e, f)`.
156///
157/// The kurbo `Affine` stores its coefficients as `[a, b, c, d, e, f]` where
158/// the matrix is:
159///
160/// ```text
161/// | a  c  e |
162/// | b  d  f |
163/// | 0  0  1 |
164/// ```
165///
166/// Canvas2D `setTransform` expects `(a, b, c, d, e, f)` with the same layout.
167pub fn affine_to_canvas_params(affine: Affine) -> [f64; 6] {
168    affine.as_coeffs()
169}
170
171/// Estimates the width of a text string for a given [`TextStyle`].
172///
173/// This uses a heuristic based on average character width ratios for common
174/// proportional fonts. The estimate is `char_count * size * 0.6` for normal
175/// weight and `char_count * size * 0.65` for bold, which provides reasonable
176/// approximations when the Canvas2D `measureText` API is not available
177/// (e.g., during testing or server-side pre-layout).
178pub fn estimate_text_width(text: &str, style: &TextStyle) -> f64 {
179    let factor = match style.weight {
180        FontWeight::Normal => 0.6,
181        FontWeight::Bold => 0.65,
182    };
183    text.len() as f64 * style.size * factor
184}
185
186/// Computes a dash pattern array string suitable for Canvas2D `setLineDash`.
187///
188/// Returns the dash lengths as a `Vec<f64>`. If the stroke has no dash
189/// pattern, returns an empty vector (which clears any active dash on the
190/// canvas context).
191pub fn dash_pattern_values(stroke: &Stroke) -> Vec<f64> {
192    match &stroke.dash {
193        Some(pattern) => pattern.dashes.clone(),
194        None => Vec::new(),
195    }
196}
197
198// ---------------------------------------------------------------------------
199// WasmRenderer (only available on wasm32 targets)
200// ---------------------------------------------------------------------------
201
202#[cfg(target_arch = "wasm32")]
203mod wasm_impl {
204    //! Browser-targeted renderer implementation using `web-sys`.
205
206    use super::*;
207    use js_sys::Array;
208    use wasm_bindgen::prelude::*;
209    use web_sys::CanvasRenderingContext2d;
210
211    /// A plotkit renderer that draws to an HTML5 Canvas2D context.
212    ///
213    /// This struct wraps a `CanvasRenderingContext2d` and implements the full
214    /// [`Renderer`] trait. All drawing operations are executed immediately on
215    /// the canvas — the `finalize` method returns an empty `Vec<u8>` since
216    /// the output is already visible on screen.
217    ///
218    /// # Clipping
219    ///
220    /// Clipping is implemented using the canvas `save()`/`restore()` stack.
221    /// Each call to [`push_clip`](Renderer::push_clip) saves the context state,
222    /// applies a clip path, and the matching [`pop_clip`](Renderer::pop_clip)
223    /// restores it.
224    pub struct WasmRenderer {
225        ctx: CanvasRenderingContext2d,
226        width: u32,
227        height: u32,
228    }
229
230    impl WasmRenderer {
231        /// Creates a new WASM renderer targeting the given canvas context.
232        ///
233        /// The `width` and `height` parameters should match the canvas element's
234        /// dimensions in CSS pixels. They are used for `Renderer::size()` and
235        /// do not modify the canvas element itself.
236        pub fn new(ctx: CanvasRenderingContext2d, width: u32, height: u32) -> Self {
237            Self { ctx, width, height }
238        }
239
240        /// Returns a reference to the underlying `CanvasRenderingContext2d`.
241        pub fn context(&self) -> &CanvasRenderingContext2d {
242            &self.ctx
243        }
244
245        /// Traces a plotkit [`Path`] onto the current canvas path.
246        ///
247        /// This begins a new path on the context and issues the appropriate
248        /// `moveTo`, `lineTo`, `quadraticCurveTo`, and `bezierCurveTo` calls
249        /// for each path element.
250        fn trace_path(&self, path: &Path) {
251            self.ctx.begin_path();
252            for el in &path.elements {
253                match *el {
254                    PathEl::MoveTo(p) => {
255                        self.ctx.move_to(p.x, p.y);
256                    }
257                    PathEl::LineTo(p) => {
258                        self.ctx.line_to(p.x, p.y);
259                    }
260                    PathEl::QuadTo(cp, end) => {
261                        self.ctx.quadratic_curve_to(cp.x, cp.y, end.x, end.y);
262                    }
263                    PathEl::CurveTo(cp1, cp2, end) => {
264                        self.ctx
265                            .bezier_curve_to(cp1.x, cp1.y, cp2.x, cp2.y, end.x, end.y);
266                    }
267                    PathEl::ClosePath => {
268                        self.ctx.close_path();
269                    }
270                }
271            }
272        }
273
274        /// Applies a plotkit [`Affine`] transform to the canvas context.
275        fn apply_transform(&self, transform: Affine) {
276            let [a, b, c, d, e, f] = affine_to_canvas_params(transform);
277            let _ = self.ctx.set_transform(a, b, c, d, e, f);
278        }
279
280        /// Resets the canvas transform to the identity matrix.
281        fn reset_transform(&self) {
282            let _ = self.ctx.set_transform(1.0, 0.0, 0.0, 1.0, 0.0, 0.0);
283        }
284
285        /// Configures the canvas stroke style from plotkit types.
286        fn configure_stroke(&self, paint: &Paint, stroke: &Stroke) {
287            let color = color_to_css(&paint.color);
288            self.ctx.set_stroke_style_str(&color);
289            self.ctx.set_line_width(stroke.width);
290            self.ctx.set_line_cap(stroke_cap_to_canvas(stroke.cap));
291            self.ctx.set_line_join(stroke_join_to_canvas(stroke.join));
292
293            let dash_values = dash_pattern_values(stroke);
294            let js_array = Array::new();
295            for &v in &dash_values {
296                js_array.push(&JsValue::from_f64(v));
297            }
298            let _ = self.ctx.set_line_dash(&js_array);
299
300            if let Some(ref pattern) = stroke.dash {
301                self.ctx.set_line_dash_offset(pattern.offset);
302            } else {
303                self.ctx.set_line_dash_offset(0.0);
304            }
305        }
306    }
307
308    impl Renderer for WasmRenderer {
309        fn size(&self) -> (u32, u32) {
310            (self.width, self.height)
311        }
312
313        fn fill_path(&mut self, path: &Path, paint: &Paint, transform: Affine) {
314            self.ctx.save();
315            self.apply_transform(transform);
316
317            let color = color_to_css(&paint.color);
318            self.ctx.set_fill_style_str(&color);
319
320            self.trace_path(path);
321            self.ctx.fill();
322
323            self.ctx.restore();
324        }
325
326        fn stroke_path(&mut self, path: &Path, paint: &Paint, stroke: &Stroke, transform: Affine) {
327            self.ctx.save();
328            self.apply_transform(transform);
329            self.configure_stroke(paint, stroke);
330
331            self.trace_path(path);
332            self.ctx.stroke();
333
334            self.ctx.restore();
335        }
336
337        fn draw_text(&mut self, text: &str, pos: Point, style: &TextStyle, transform: Affine) {
338            self.ctx.save();
339            self.apply_transform(transform);
340
341            let font = build_font_string(style);
342            self.ctx.set_font(&font);
343
344            let color = color_to_css(&style.color);
345            self.ctx.set_fill_style_str(&color);
346
347            self.ctx.set_text_align(halign_to_canvas(style.halign));
348            self.ctx.set_text_baseline(valign_to_canvas(style.valign));
349
350            let _ = self.ctx.fill_text(text, pos.x, pos.y);
351
352            self.ctx.restore();
353        }
354
355        fn draw_image(&mut self, img: &Image, dst: Rect, transform: Affine) {
356            self.ctx.save();
357            self.apply_transform(transform);
358
359            // Create ImageData from raw RGBA pixels and draw it scaled into the
360            // destination rectangle. We use a temporary canvas for proper scaling.
361            // The conversion is infallible (Err = Infallible) but kept as a
362            // fallible match for forward compatibility with web-sys signatures.
363            #[allow(irrefutable_let_patterns)]
364            if let Ok(clamped) = wasm_bindgen::Clamped(img.data.as_slice()).try_into() {
365                if let Ok(image_data) = web_sys::ImageData::new_with_u8_clamped_array_and_sh(
366                    clamped, img.width, img.height,
367                ) {
368                    // Use createImageBitmap or putImageData with scaling.
369                    // The simplest approach: put image data at origin, then
370                    // use drawImage to scale. For proper scaling we create
371                    // a temporary offscreen canvas.
372                    if let Some(window) = web_sys::window() {
373                        if let Some(document) = window.document() {
374                            if let Ok(temp_canvas) = document.create_element("canvas") {
375                                let temp_canvas: web_sys::HtmlCanvasElement =
376                                    temp_canvas.unchecked_into();
377                                temp_canvas.set_width(img.width);
378                                temp_canvas.set_height(img.height);
379                                if let Ok(Some(temp_ctx)) = temp_canvas.get_context("2d") {
380                                    let temp_ctx: CanvasRenderingContext2d =
381                                        temp_ctx.unchecked_into();
382                                    let _ = temp_ctx.put_image_data(&image_data, 0.0, 0.0);
383                                    let _ =
384                                        self.ctx.draw_image_with_html_canvas_element_and_dw_and_dh(
385                                            &temp_canvas,
386                                            dst.x,
387                                            dst.y,
388                                            dst.width,
389                                            dst.height,
390                                        );
391                                }
392                            }
393                        }
394                    }
395                }
396            }
397
398            self.ctx.restore();
399        }
400
401        fn push_clip(&mut self, path: &Path, transform: Affine) {
402            self.ctx.save();
403            self.apply_transform(transform);
404            self.trace_path(path);
405            self.ctx.clip();
406            // Reset transform after clipping so subsequent draws are not
407            // double-transformed. The clip region remains in the saved state.
408            self.reset_transform();
409        }
410
411        fn pop_clip(&mut self) {
412            self.ctx.restore();
413        }
414
415        fn measure_text(&self, text: &str, style: &TextStyle) -> (f64, f64) {
416            let font = build_font_string(style);
417            self.ctx.set_font(&font);
418
419            if let Ok(metrics) = self.ctx.measure_text(text) {
420                let width = metrics.width();
421                // Canvas2D measureText gives width natively. For height,
422                // use the font metrics if available, otherwise fall back
423                // to the font size as a reasonable approximation.
424                let height = style.size;
425                (width, height)
426            } else {
427                // Fallback to heuristic estimate if measureText fails.
428                (estimate_text_width(text, style), style.size)
429            }
430        }
431
432        fn finalize(self) -> Vec<u8> {
433            // Canvas renders immediately — there is no serialized output.
434            // Return an empty vector as per the contract for immediate-mode
435            // rendering backends.
436            Vec::new()
437        }
438    }
439
440    /// Renders a built-in demo plot onto the given canvas. Called from JS as
441    /// `render_demo(canvas, kind)` where `kind` is one of `"line"`,
442    /// `"scatter"`, `"bar"`, or `"hist"`.
443    #[wasm_bindgen]
444    pub fn render_demo(canvas: web_sys::HtmlCanvasElement, kind: &str) -> Result<(), JsValue> {
445        use plotkit_core::figure::Figure;
446        use wasm_bindgen::JsCast;
447
448        let width = canvas.width();
449        let height = canvas.height();
450        let ctx = canvas
451            .get_context("2d")?
452            .ok_or_else(|| JsValue::from_str("canvas has no 2d context"))?
453            .dyn_into::<web_sys::CanvasRenderingContext2d>()?;
454
455        let mut fig = Figure::with_size(width, height);
456        let ax = fig.add_subplot(1, 1, 1);
457        let to_js = |e: plotkit_core::error::PlotError| JsValue::from_str(&e.to_string());
458        let xs: Vec<f64> = (0..200).map(|i| i as f64 * 0.05).collect();
459        match kind {
460            "scatter" => {
461                let ys: Vec<f64> = xs.iter().map(|v| v.sin()).collect();
462                ax.scatter(xs.clone(), ys).map_err(to_js)?;
463                ax.set_title("scatter demo");
464            }
465            "bar" => {
466                let cats = vec!["A".to_string(), "B".to_string(), "C".to_string()];
467                ax.bar(cats, vec![3.0, 7.0, 5.0]).map_err(to_js)?;
468                ax.set_title("bar demo");
469            }
470            "hist" => {
471                let data: Vec<f64> = (0..500)
472                    .map(|i| (i as f64 * 0.1).sin() + (i as f64 * 0.031).cos())
473                    .collect();
474                ax.hist(data, 30).map_err(to_js)?;
475                ax.set_title("histogram demo");
476            }
477            _ => {
478                let ys: Vec<f64> = xs.iter().map(|v| v.sin()).collect();
479                ax.plot(xs.clone(), ys).map_err(to_js)?.label("sin(x)");
480                ax.set_title("line demo");
481                ax.legend();
482            }
483        }
484        ax.set_xlabel("x");
485        ax.set_ylabel("y");
486        ax.grid(true);
487
488        let renderer = WasmRenderer::new(ctx, width, height);
489        let _ = fig.render_to(renderer);
490        Ok(())
491    }
492}
493
494#[cfg(target_arch = "wasm32")]
495pub use wasm_impl::WasmRenderer;
496
497// ---------------------------------------------------------------------------
498// Tests
499// ---------------------------------------------------------------------------
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    // -- Color conversion tests --------------------------------------------
506
507    #[test]
508    fn color_opaque_to_css() {
509        let c = Color::rgb(255, 0, 0);
510        assert_eq!(color_to_css(&c), "rgba(255,0,0,1)");
511    }
512
513    #[test]
514    fn color_transparent_to_css() {
515        let c = Color::TRANSPARENT;
516        assert_eq!(color_to_css(&c), "rgba(0,0,0,0.0000)");
517    }
518
519    #[test]
520    fn color_semi_transparent_to_css() {
521        let c = Color::new(0, 128, 255, 128);
522        let css = color_to_css(&c);
523        assert_eq!(css, "rgba(0,128,255,0.5020)");
524    }
525
526    #[test]
527    fn color_white_to_css() {
528        let c = Color::WHITE;
529        assert_eq!(color_to_css(&c), "rgba(255,255,255,1)");
530    }
531
532    #[test]
533    fn color_black_to_css() {
534        let c = Color::BLACK;
535        assert_eq!(color_to_css(&c), "rgba(0,0,0,1)");
536    }
537
538    #[test]
539    fn color_with_alpha_one_to_css() {
540        // Alpha = 1 (nearly transparent, but not zero)
541        let c = Color::new(100, 200, 50, 1);
542        let css = color_to_css(&c);
543        assert!(css.contains("0.0039"), "expected '0.0039' in {}", css);
544    }
545
546    #[test]
547    fn color_tableau_blue_to_css() {
548        let c = Color::TAB_BLUE; // 0x4E, 0x79, 0xA7
549        assert_eq!(color_to_css(&c), "rgba(78,121,167,1)");
550    }
551
552    // -- Font string building tests ----------------------------------------
553
554    #[test]
555    fn font_string_default() {
556        let style = TextStyle::new(14.0);
557        assert_eq!(build_font_string(&style), "14px sans-serif");
558    }
559
560    #[test]
561    fn font_string_bold() {
562        let mut style = TextStyle::new(20.0);
563        style.weight = FontWeight::Bold;
564        assert_eq!(build_font_string(&style), "bold 20px sans-serif");
565    }
566
567    #[test]
568    fn font_string_custom_family() {
569        let mut style = TextStyle::new(12.0);
570        style.family = Some("Helvetica Neue".to_string());
571        assert_eq!(build_font_string(&style), "12px Helvetica Neue");
572    }
573
574    #[test]
575    fn font_string_bold_custom_family() {
576        let mut style = TextStyle::new(16.0);
577        style.weight = FontWeight::Bold;
578        style.family = Some("Georgia".to_string());
579        assert_eq!(build_font_string(&style), "bold 16px Georgia");
580    }
581
582    #[test]
583    fn font_string_fractional_size() {
584        let style = TextStyle::new(10.5);
585        // The format spec is {:.0} so it should round
586        assert_eq!(build_font_string(&style), "10px sans-serif");
587    }
588
589    // -- Alignment mapping tests -------------------------------------------
590
591    #[test]
592    fn halign_mapping() {
593        assert_eq!(halign_to_canvas(HAlign::Left), "left");
594        assert_eq!(halign_to_canvas(HAlign::Center), "center");
595        assert_eq!(halign_to_canvas(HAlign::Right), "right");
596    }
597
598    #[test]
599    fn valign_mapping() {
600        assert_eq!(valign_to_canvas(VAlign::Top), "top");
601        assert_eq!(valign_to_canvas(VAlign::Middle), "middle");
602        assert_eq!(valign_to_canvas(VAlign::Bottom), "bottom");
603        assert_eq!(valign_to_canvas(VAlign::Baseline), "alphabetic");
604    }
605
606    // -- Stroke style mapping tests ----------------------------------------
607
608    #[test]
609    fn stroke_cap_mapping() {
610        assert_eq!(stroke_cap_to_canvas(StrokeCap::Butt), "butt");
611        assert_eq!(stroke_cap_to_canvas(StrokeCap::Round), "round");
612        assert_eq!(stroke_cap_to_canvas(StrokeCap::Square), "square");
613    }
614
615    #[test]
616    fn stroke_join_mapping() {
617        assert_eq!(stroke_join_to_canvas(StrokeJoin::Miter), "miter");
618        assert_eq!(stroke_join_to_canvas(StrokeJoin::Round), "round");
619        assert_eq!(stroke_join_to_canvas(StrokeJoin::Bevel), "bevel");
620    }
621
622    // -- Path element counting tests ---------------------------------------
623
624    #[test]
625    fn count_empty_path() {
626        let path = Path::new();
627        assert_eq!(count_path_elements(&path), (0, 0, 0, 0, 0));
628    }
629
630    #[test]
631    fn count_rect_path() {
632        let path = Path::rect(Rect::new(0.0, 0.0, 100.0, 50.0));
633        let (m, l, q, c, z) = count_path_elements(&path);
634        assert_eq!(m, 1, "rect should have 1 MoveTo");
635        assert_eq!(l, 3, "rect should have 3 LineTo");
636        assert_eq!(q, 0, "rect should have 0 QuadTo");
637        assert_eq!(c, 0, "rect should have 0 CurveTo");
638        assert_eq!(z, 1, "rect should have 1 ClosePath");
639    }
640
641    #[test]
642    fn count_circle_path() {
643        let path = Path::circle(Point::new(50.0, 50.0), 25.0);
644        let (m, l, q, c, z) = count_path_elements(&path);
645        assert_eq!(m, 1, "circle should have 1 MoveTo");
646        assert_eq!(l, 0, "circle should have 0 LineTo");
647        assert_eq!(q, 0, "circle should have 0 QuadTo");
648        assert_eq!(c, 4, "circle should have 4 CurveTo");
649        assert_eq!(z, 1, "circle should have 1 ClosePath");
650    }
651
652    #[test]
653    fn count_mixed_path() {
654        let mut path = Path::new();
655        path.move_to(0.0, 0.0)
656            .line_to(10.0, 0.0)
657            .quad_to(15.0, 5.0, 10.0, 10.0)
658            .curve_to(5.0, 15.0, -5.0, 15.0, -10.0, 10.0)
659            .close();
660        let (m, l, q, c, z) = count_path_elements(&path);
661        assert_eq!(m, 1);
662        assert_eq!(l, 1);
663        assert_eq!(q, 1);
664        assert_eq!(c, 1);
665        assert_eq!(z, 1);
666    }
667
668    // -- Affine transform tests -------------------------------------------
669
670    #[test]
671    fn identity_affine_params() {
672        let params = affine_to_canvas_params(Affine::IDENTITY);
673        assert_eq!(params, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
674    }
675
676    #[test]
677    fn translate_affine_params() {
678        let t = Affine::translate((100.0, 200.0));
679        let [a, b, c, d, e, f] = affine_to_canvas_params(t);
680        assert_eq!(a, 1.0);
681        assert_eq!(b, 0.0);
682        assert_eq!(c, 0.0);
683        assert_eq!(d, 1.0);
684        assert_eq!(e, 100.0);
685        assert_eq!(f, 200.0);
686    }
687
688    #[test]
689    fn scale_affine_params() {
690        let s = Affine::scale_non_uniform(2.0, 3.0);
691        let [a, b, c, d, e, f] = affine_to_canvas_params(s);
692        assert_eq!(a, 2.0);
693        assert_eq!(d, 3.0);
694        assert_eq!(e, 0.0);
695        assert_eq!(f, 0.0);
696        assert_eq!(b, 0.0);
697        assert_eq!(c, 0.0);
698    }
699
700    // -- Text estimation tests --------------------------------------------
701
702    #[test]
703    fn estimate_text_width_normal() {
704        let style = TextStyle::new(10.0);
705        let width = estimate_text_width("hello", &style);
706        // 5 chars * 10.0 * 0.6 = 30.0
707        assert!((width - 30.0).abs() < 1e-10);
708    }
709
710    #[test]
711    fn estimate_text_width_bold() {
712        let mut style = TextStyle::new(10.0);
713        style.weight = FontWeight::Bold;
714        let width = estimate_text_width("hello", &style);
715        // 5 chars * 10.0 * 0.65 = 32.5
716        assert!((width - 32.5).abs() < 1e-10);
717    }
718
719    #[test]
720    fn estimate_text_width_empty() {
721        let style = TextStyle::new(16.0);
722        let width = estimate_text_width("", &style);
723        assert_eq!(width, 0.0);
724    }
725
726    // -- Dash pattern tests -----------------------------------------------
727
728    #[test]
729    fn dash_pattern_solid_stroke() {
730        let stroke = Stroke::new(2.0);
731        let dashes = dash_pattern_values(&stroke);
732        assert!(dashes.is_empty());
733    }
734
735    #[test]
736    fn dash_pattern_dashed_stroke() {
737        let stroke = Stroke::new(1.5).with_dash(DashPattern {
738            dashes: vec![5.0, 3.0, 1.0],
739            offset: 2.0,
740        });
741        let dashes = dash_pattern_values(&stroke);
742        assert_eq!(dashes, vec![5.0, 3.0, 1.0]);
743    }
744
745    // -- Font size rounding edge cases ------------------------------------
746
747    #[test]
748    fn font_string_large_size() {
749        let style = TextStyle::new(72.0);
750        assert_eq!(build_font_string(&style), "72px sans-serif");
751    }
752
753    #[test]
754    fn font_string_small_size() {
755        let style = TextStyle::new(6.0);
756        assert_eq!(build_font_string(&style), "6px sans-serif");
757    }
758}