1#![deny(missing_docs)]
31
32pub use plotkit_core::primitives::*;
34pub use plotkit_core::renderer::Renderer;
35
36pub 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
67pub 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
98pub 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
110pub 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
123pub 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
132pub 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
141pub 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
163pub fn affine_to_canvas_params(affine: Affine) -> [f64; 6] {
177 affine.as_coeffs()
178}
179
180pub 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
195pub 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#[cfg(target_arch = "wasm32")]
212mod wasm_impl {
213 use super::*;
216 use js_sys::Array;
217 use wasm_bindgen::prelude::*;
218 use web_sys::CanvasRenderingContext2d;
219
220 pub struct WasmRenderer {
234 ctx: CanvasRenderingContext2d,
235 width: u32,
236 height: u32,
237 }
238
239 impl WasmRenderer {
240 pub fn new(ctx: CanvasRenderingContext2d, width: u32, height: u32) -> Self {
246 Self { ctx, width, height }
247 }
248
249 pub fn context(&self) -> &CanvasRenderingContext2d {
251 &self.ctx
252 }
253
254 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 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 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 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 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 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 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 let height = style.size;
446 (width, height)
447 } else {
448 (estimate_text_width(text, style), style.size)
450 }
451 }
452
453 fn finalize(self) -> Vec<u8> {
454 Vec::new()
458 }
459 }
460}
461
462#[cfg(target_arch = "wasm32")]
463pub use wasm_impl::WasmRenderer;
464
465#[cfg(test)]
470mod tests {
471 use super::*;
472
473 #[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 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; assert_eq!(color_to_css(&c), "rgba(78,121,167,1)");
518 }
519
520 #[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 assert_eq!(build_font_string(&style), "10px sans-serif");
555 }
556
557 #[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 #[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 #[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 #[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 #[test]
671 fn estimate_text_width_normal() {
672 let style = TextStyle::new(10.0);
673 let width = estimate_text_width("hello", &style);
674 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 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 #[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 #[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}