convert.rs raw

//! 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::<Srgb>())
}

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<tiny_skia::Path> {
    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<Item = PathEl>,
) {
    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<tiny_skia::GradientStop> {
    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<tiny_skia::Shader<'_>, 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<tiny_skia::Pixmap, RenderError> {
    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,
    }
}