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
14pub 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 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 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 #[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 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 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; }
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 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 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 assert_eq!(pixels[0], 255, "background red channel should be 255");
340 }
341}