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