Skip to main content

osmic_render/
skia.rs

1use cosmic_text::{Attrs, Buffer as TextBuffer, Family, FontSystem, Metrics, Shaping, SwashCache};
2use tiny_skia::{
3    Color as SkiaColor, FillRule, LineCap as SkiaCap, LineJoin as SkiaJoin, Paint, PathBuilder,
4    Pixmap, Stroke, Transform,
5};
6use tracing::info;
7
8use osmic_core::error::{OsmicError, OsmicResult};
9use osmic_core::Color;
10
11use crate::backend::{RenderBackend, RenderConfig};
12use crate::scene::{LineCap, LineJoin, RenderFeature, RenderLayer, SceneGraph};
13
14/// Software rendering backend using tiny-skia + cosmic-text.
15pub struct SkiaBackend {
16    pixmap: Pixmap,
17    config: RenderConfig,
18    font_system: FontSystem,
19    swash_cache: SwashCache,
20}
21
22impl RenderBackend for SkiaBackend {
23    fn init(config: &RenderConfig) -> OsmicResult<Self> {
24        let w = (config.width as f32 * config.pixel_ratio) as u32;
25        let h = (config.height as f32 * config.pixel_ratio) as u32;
26        let pixmap = Pixmap::new(w, h)
27            .ok_or_else(|| OsmicError::Render("Failed to create pixmap".into()))?;
28
29        info!(width = w, height = h, "SkiaBackend initialized");
30
31        let font_system = FontSystem::new();
32        let swash_cache = SwashCache::new();
33
34        Ok(Self {
35            pixmap,
36            config: config.clone(),
37            font_system,
38            swash_cache,
39        })
40    }
41
42    fn render(&mut self, scene: &SceneGraph) -> OsmicResult<()> {
43        // Clear with background
44        let bg = to_skia_color(&scene.background);
45        self.pixmap.fill(bg);
46
47        let scale = self.config.pixel_ratio;
48        let transform = Transform::from_scale(scale, scale);
49
50        // Sort layers by z-order
51        let mut layers: Vec<&RenderLayer> = scene.layers.iter().collect();
52        layers.sort_by_key(|l| l.z_order);
53
54        for layer in layers {
55            for feature in &layer.features {
56                match feature {
57                    RenderFeature::Fill { coords, color } => {
58                        self.render_fill(coords, color, transform);
59                    }
60                    RenderFeature::Stroke {
61                        coords,
62                        color,
63                        width,
64                        cap,
65                        join,
66                    } => {
67                        self.render_stroke(coords, color, *width, *cap, *join, transform);
68                    }
69                    RenderFeature::Label {
70                        position,
71                        text,
72                        font_size,
73                        color,
74                        halo_color,
75                        halo_width,
76                    } => {
77                        self.render_label(
78                            position,
79                            text,
80                            *font_size,
81                            color,
82                            halo_color.as_ref(),
83                            *halo_width,
84                            transform,
85                        );
86                    }
87                }
88            }
89        }
90
91        Ok(())
92    }
93
94    fn read_pixels(&self) -> Option<Vec<u8>> {
95        Some(self.pixmap.data().to_vec())
96    }
97
98    fn resize(&mut self, width: u32, height: u32) {
99        let w = (width as f32 * self.config.pixel_ratio) as u32;
100        let h = (height as f32 * self.config.pixel_ratio) as u32;
101        if let Some(pm) = Pixmap::new(w, h) {
102            self.pixmap = pm;
103            self.config.width = width;
104            self.config.height = height;
105        }
106    }
107}
108
109impl SkiaBackend {
110    // All parameters are independent rendering inputs; a struct would add boilerplate for a private method.
111    #[allow(clippy::too_many_arguments)]
112    fn render_label(
113        &mut self,
114        position: &[f32; 2],
115        text: &str,
116        font_size: f32,
117        color: &Color,
118        halo_color: Option<&Color>,
119        halo_width: f32,
120        transform: Transform,
121    ) {
122        if text.is_empty() {
123            return;
124        }
125
126        let scaled_size = font_size * self.config.pixel_ratio;
127        let metrics = Metrics::new(scaled_size, scaled_size * 1.2);
128        let mut buffer = TextBuffer::new(&mut self.font_system, metrics);
129        let attrs = Attrs::new().family(Family::SansSerif);
130        buffer.set_text(&mut self.font_system, text, &attrs, Shaping::Advanced, None);
131        buffer.shape_until_scroll(&mut self.font_system, false);
132
133        let px = position[0] * self.config.pixel_ratio;
134        let py = position[1] * self.config.pixel_ratio;
135
136        // Render halo first (wider, lighter text behind)
137        if let Some(hc) = halo_color {
138            let hw = (halo_width * self.config.pixel_ratio).max(1.0);
139            let halo_c = cosmic_text::Color::rgba(
140                (hc.r * 255.0) as u8,
141                (hc.g * 255.0) as u8,
142                (hc.b * 255.0) as u8,
143                200,
144            );
145            for dy in [-hw, 0.0, hw] {
146                for dx in [-hw, 0.0, hw] {
147                    if dx == 0.0 && dy == 0.0 {
148                        continue;
149                    }
150                    self.draw_text_buffer(&buffer, px + dx, py + dy, halo_c);
151                }
152            }
153        }
154
155        // Render text
156        let text_c = cosmic_text::Color::rgba(
157            (color.r * 255.0) as u8,
158            (color.g * 255.0) as u8,
159            (color.b * 255.0) as u8,
160            255,
161        );
162        self.draw_text_buffer(&buffer, px, py, text_c);
163        let _ = transform; // transform already applied via position
164    }
165
166    fn draw_text_buffer(
167        &mut self,
168        buffer: &TextBuffer,
169        offset_x: f32,
170        offset_y: f32,
171        color: cosmic_text::Color,
172    ) {
173        let pw = self.pixmap.width();
174        let ph = self.pixmap.height();
175        buffer.draw(
176            &mut self.font_system,
177            &mut self.swash_cache,
178            color,
179            |x, y, w, h, drawn_color| {
180                let alpha = drawn_color.a();
181                if alpha == 0 {
182                    return;
183                }
184                for dy in 0..h as i32 {
185                    for dx in 0..w as i32 {
186                        let px = (x + dx) + offset_x as i32;
187                        let py = (y + dy) + offset_y as i32;
188                        if px >= 0 && py >= 0 && (px as u32) < pw && (py as u32) < ph {
189                            let idx = ((py as u32 * pw + px as u32) * 4) as usize;
190                            let data = self.pixmap.data_mut();
191                            let src_a = alpha as f32 / 255.0;
192                            let dst_a = data[idx + 3] as f32 / 255.0;
193                            let out_a = src_a + dst_a * (1.0 - src_a);
194                            if out_a > 0.0 {
195                                // Premultiplied alpha blending
196                                data[idx] = ((drawn_color.r() as f32 * src_a
197                                    + data[idx] as f32 * (1.0 - src_a))
198                                    .min(255.0)) as u8;
199                                data[idx + 1] = ((drawn_color.g() as f32 * src_a
200                                    + data[idx + 1] as f32 * (1.0 - src_a))
201                                    .min(255.0))
202                                    as u8;
203                                data[idx + 2] = ((drawn_color.b() as f32 * src_a
204                                    + data[idx + 2] as f32 * (1.0 - src_a))
205                                    .min(255.0))
206                                    as u8;
207                                data[idx + 3] = (out_a * 255.0).min(255.0) as u8;
208                            }
209                        }
210                    }
211                }
212            },
213        );
214    }
215
216    /// Encode the pixmap as PNG bytes.
217    pub fn to_png(&self) -> OsmicResult<Vec<u8>> {
218        self.pixmap
219            .encode_png()
220            .map_err(|e| OsmicError::Render(format!("PNG encode failed: {e}")))
221    }
222
223    fn render_fill(&mut self, rings: &[Vec<[f32; 2]>], color: &Color, transform: Transform) {
224        let mut pb = PathBuilder::new();
225        for ring in rings {
226            if ring.len() < 3 {
227                continue;
228            }
229            pb.move_to(ring[0][0], ring[0][1]);
230            for pt in &ring[1..] {
231                pb.line_to(pt[0], pt[1]);
232            }
233            pb.close();
234        }
235
236        if let Some(path) = pb.finish() {
237            let mut paint = Paint::default();
238            paint.set_color(to_skia_color(color));
239            paint.anti_alias = true;
240            self.pixmap
241                .fill_path(&path, &paint, FillRule::EvenOdd, transform, None);
242        }
243    }
244
245    fn render_stroke(
246        &mut self,
247        coords: &[[f32; 2]],
248        color: &Color,
249        width: f32,
250        cap: LineCap,
251        join: LineJoin,
252        transform: Transform,
253    ) {
254        if coords.len() < 2 {
255            return;
256        }
257
258        let mut pb = PathBuilder::new();
259        pb.move_to(coords[0][0], coords[0][1]);
260        for pt in &coords[1..] {
261            pb.line_to(pt[0], pt[1]);
262        }
263
264        if let Some(path) = pb.finish() {
265            let mut paint = Paint::default();
266            paint.set_color(to_skia_color(color));
267            paint.anti_alias = true;
268
269            let stroke = Stroke {
270                width,
271                line_cap: match cap {
272                    LineCap::Butt => SkiaCap::Butt,
273                    LineCap::Round => SkiaCap::Round,
274                    LineCap::Square => SkiaCap::Square,
275                },
276                line_join: match join {
277                    LineJoin::Miter => SkiaJoin::Miter,
278                    LineJoin::Round => SkiaJoin::Round,
279                    LineJoin::Bevel => SkiaJoin::Bevel,
280                },
281                ..Stroke::default()
282            };
283
284            self.pixmap
285                .stroke_path(&path, &paint, &stroke, transform, None);
286        }
287    }
288}
289
290fn to_skia_color(c: &Color) -> SkiaColor {
291    SkiaColor::from_rgba(c.r, c.g, c.b, c.a).unwrap_or(SkiaColor::BLACK)
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn init_with_default_config_succeeds() {
300        let config = RenderConfig::default();
301        let backend = SkiaBackend::init(&config).expect("init should succeed");
302        let pixels = backend.read_pixels().expect("read_pixels on fresh pixmap");
303        assert_eq!(
304            pixels.len(),
305            (config.width * config.height * 4) as usize,
306            "pixel buffer should match width * height * RGBA"
307        );
308    }
309
310    #[test]
311    fn init_small_custom_dimensions() {
312        let config = RenderConfig {
313            width: 32,
314            height: 16,
315            background: Color::rgba(0.0, 0.0, 0.0, 1.0),
316            pixel_ratio: 1.0,
317        };
318        let backend = SkiaBackend::init(&config).expect("small init ok");
319        let pixels = backend.read_pixels().unwrap();
320        assert_eq!(pixels.len(), 32 * 16 * 4);
321    }
322
323    #[test]
324    fn render_empty_scene_clears_to_background() {
325        let config = RenderConfig {
326            width: 4,
327            height: 4,
328            background: Color::rgba(1.0, 0.0, 0.0, 1.0),
329            pixel_ratio: 1.0,
330        };
331        let mut backend = SkiaBackend::init(&config).unwrap();
332        let scene = SceneGraph {
333            background: Color::rgba(1.0, 0.0, 0.0, 1.0),
334            layers: vec![],
335        };
336        backend.render(&scene).expect("render empty scene");
337        let pixels = backend.read_pixels().unwrap();
338        // First pixel's red channel should be saturated.
339        assert_eq!(pixels[0], 255, "background red channel should be 255");
340    }
341}