//! Conversions from guiduck scene vocabulary (kurbo/peniko) to tiny-skia //! types. All conversions stay inside this backend crate. use guiduck_scene::RenderError; use guiduck_scene::Shape; use guiduck_scene::geom::{Affine, Cap, Join, PathEl, Stroke}; use guiduck_scene::paint::color::{DynamicColor, Srgb}; use guiduck_scene::paint::{ BlendMode, Brush, Color, Compose, Extend, Fill, Gradient, GradientKind, ImageAlphaType, ImageData, ImageFormat, ImageQuality, Mix, }; pub fn transform(affine: Affine) -> tiny_skia::Transform { let [a, b, c, d, e, f] = affine.as_coeffs(); tiny_skia::Transform::from_row(a as f32, b as f32, c as f32, d as f32, e as f32, f as f32) } pub fn color(color: Color) -> tiny_skia::Color { let [r, g, b, a] = color.components; tiny_skia::Color::from_rgba( r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0), a.clamp(0.0, 1.0), ) .expect("components clamped to [0, 1]") } fn dynamic_color(c: DynamicColor) -> tiny_skia::Color { color(c.to_alpha_color::()) } pub fn fill_rule(rule: Fill) -> tiny_skia::FillRule { match rule { Fill::NonZero => tiny_skia::FillRule::Winding, Fill::EvenOdd => tiny_skia::FillRule::EvenOdd, } } pub fn stroke(style: &Stroke) -> tiny_skia::Stroke { tiny_skia::Stroke { width: style.width as f32, miter_limit: style.miter_limit as f32, // tiny-skia has a single cap for both ends; kurbo distinguishes them. // Widgets use symmetric caps in practice, so the start cap wins. line_cap: match style.start_cap { Cap::Butt => tiny_skia::LineCap::Butt, Cap::Square => tiny_skia::LineCap::Square, Cap::Round => tiny_skia::LineCap::Round, }, line_join: match style.join { Join::Bevel => tiny_skia::LineJoin::Bevel, Join::Miter => tiny_skia::LineJoin::Miter, Join::Round => tiny_skia::LineJoin::Round, }, dash: if style.dash_pattern.is_empty() { None } else { tiny_skia::StrokeDash::new( style.dash_pattern.iter().map(|d| *d as f32).collect(), style.dash_offset as f32, ) }, } } /// Convert a shape to a tiny-skia path. Returns `None` for degenerate shapes /// (empty paths, zero-area rects), which are legal to emit and simply draw /// nothing. pub fn path(shape: &Shape) -> Option { let mut builder = tiny_skia::PathBuilder::new(); push_path_elements(&mut builder, shape.to_path(0.1).iter()); builder.finish() } pub fn push_path_elements( builder: &mut tiny_skia::PathBuilder, elements: impl Iterator, ) { for el in elements { match el { PathEl::MoveTo(p) => builder.move_to(p.x as f32, p.y as f32), PathEl::LineTo(p) => builder.line_to(p.x as f32, p.y as f32), PathEl::QuadTo(c, p) => { builder.quad_to(c.x as f32, c.y as f32, p.x as f32, p.y as f32); } PathEl::CurveTo(c0, c1, p) => builder.cubic_to( c0.x as f32, c0.y as f32, c1.x as f32, c1.y as f32, p.x as f32, p.y as f32, ), PathEl::ClosePath => builder.close(), } } } pub fn spread_mode(extend: Extend) -> tiny_skia::SpreadMode { match extend { Extend::Pad => tiny_skia::SpreadMode::Pad, Extend::Repeat => tiny_skia::SpreadMode::Repeat, Extend::Reflect => tiny_skia::SpreadMode::Reflect, } } pub fn filter_quality(quality: ImageQuality) -> tiny_skia::FilterQuality { match quality { ImageQuality::Low => tiny_skia::FilterQuality::Nearest, ImageQuality::Medium => tiny_skia::FilterQuality::Bilinear, ImageQuality::High => tiny_skia::FilterQuality::Bicubic, } } fn gradient_stops(gradient: &Gradient) -> Vec { gradient .stops .iter() .map(|stop| tiny_skia::GradientStop::new(stop.offset, dynamic_color(stop.color))) .collect() } /// Convert a brush to a tiny-skia shader. /// /// `shader_transform` maps the brush's coordinate space (the same space the /// geometry is authored in) to the target's pixel space, since tiny-skia /// shaders carry their own transform rather than inheriting the painter's. pub fn shader( brush: &Brush, shader_transform: Affine, ) -> Result, RenderError> { let ts = transform(shader_transform); match brush { Brush::Solid(c) => Ok(tiny_skia::Shader::SolidColor(color(*c))), Brush::Gradient(gradient) => { let stops = gradient_stops(gradient); let mode = spread_mode(gradient.extend); let shader = match gradient.kind { GradientKind::Linear(linear) => tiny_skia::LinearGradient::new( point(linear.start), point(linear.end), stops, mode, ts, ), GradientKind::Radial(radial) => tiny_skia::RadialGradient::new( point(radial.start_center), radial.start_radius, point(radial.end_center), radial.end_radius, stops, mode, ts, ), GradientKind::Sweep(sweep) => tiny_skia::SweepGradient::new( point(sweep.center), sweep.start_angle.to_degrees(), sweep.end_angle.to_degrees(), stops, mode, ts, ), }; shader.ok_or_else(|| RenderError::Backend("degenerate gradient".into())) } Brush::Image(image) => Err(RenderError::Backend( format!( "image brushes are drawn via DisplayItem::Image, not as fill brushes ({}x{})", image.image.width, image.image.height ) .into(), )), } } fn point(p: guiduck_scene::geom::Point) -> tiny_skia::Point { tiny_skia::Point { x: p.x as f32, y: p.y as f32, } } /// Convert image pixel data to a premultiplied RGBA pixmap. pub fn image_to_pixmap(image: &ImageData) -> Result { let expected = image .format .size_in_bytes(image.width, image.height) .ok_or_else(|| RenderError::Backend("image dimensions overflow".into()))?; let data = image.data.as_ref(); if data.len() < expected { return Err(RenderError::Backend( format!( "image data too short: {} bytes for {}x{} {:?}", data.len(), image.width, image.height, image.format ) .into(), )); } let mut pixmap = tiny_skia::Pixmap::new(image.width, image.height) .ok_or_else(|| RenderError::Backend("zero-sized image".into()))?; let out = pixmap.data_mut(); for (src, dst) in data[..expected] .chunks_exact(4) .zip(out.chunks_exact_mut(4)) { let [r, g, b, a] = match image.format { ImageFormat::Rgba8 => [src[0], src[1], src[2], src[3]], ImageFormat::Bgra8 => [src[2], src[1], src[0], src[3]], other => { return Err(RenderError::Backend( format!("unsupported image format {other:?}").into(), )); } }; let [r, g, b] = match image.alpha_type { ImageAlphaType::AlphaPremultiplied => [r, g, b], ImageAlphaType::Alpha => [premultiply(r, a), premultiply(g, a), premultiply(b, a)], }; dst.copy_from_slice(&[r, g, b, a]); } Ok(pixmap) } fn premultiply(channel: u8, alpha: u8) -> u8 { ((channel as u16 * alpha as u16 + 127) / 255) as u8 } /// Map a peniko blend mode to tiny-skia's single blend enum. /// /// peniko separates mix (color blending) and compose (Porter-Duff); tiny-skia /// exposes one combined enum. A non-normal mix takes precedence; otherwise the /// compose op is mapped. pub fn blend_mode(blend: BlendMode) -> tiny_skia::BlendMode { use tiny_skia::BlendMode as Ts; match blend.mix { Mix::Normal => match blend.compose { Compose::Clear => Ts::Clear, Compose::Copy => Ts::Source, Compose::Dest => Ts::Destination, Compose::SrcOver => Ts::SourceOver, Compose::DestOver => Ts::DestinationOver, Compose::SrcIn => Ts::SourceIn, Compose::DestIn => Ts::DestinationIn, Compose::SrcOut => Ts::SourceOut, Compose::DestOut => Ts::DestinationOut, Compose::SrcAtop => Ts::SourceAtop, Compose::DestAtop => Ts::DestinationAtop, Compose::Xor => Ts::Xor, Compose::Plus => Ts::Plus, Compose::PlusLighter => Ts::Plus, }, Mix::Multiply => Ts::Multiply, Mix::Screen => Ts::Screen, Mix::Overlay => Ts::Overlay, Mix::Darken => Ts::Darken, Mix::Lighten => Ts::Lighten, Mix::ColorDodge => Ts::ColorDodge, Mix::ColorBurn => Ts::ColorBurn, Mix::HardLight => Ts::HardLight, Mix::SoftLight => Ts::SoftLight, Mix::Difference => Ts::Difference, Mix::Exclusion => Ts::Exclusion, Mix::Hue => Ts::Hue, Mix::Saturation => Ts::Saturation, Mix::Color => Ts::Color, Mix::Luminosity => Ts::Luminosity, } }