Skip to main content

sequoia_export/
lib.rs

1#![forbid(unsafe_code)]
2
3use std::path::{Path, PathBuf};
4
5use image::RgbaImage;
6use sequoia_assets::AssetRegistry;
7use sequoia_core::{
8    Color, GradientDraw, GradientKind, ImageDraw, ImageSource, Quad, Rect, RenderItem, RenderList,
9    RoundedClip, Shadow, TextDraw,
10};
11use sequoia_reactive::Environment;
12use sequoia_style::Theme;
13use sequoia_text::{GlyphAtlas, TextSystem};
14use sequoia_view::{Runtime, View};
15
16pub const SCALE_1X: f32 = 1.0;
17
18pub const SCALE_2X: f32 = 2.0;
19
20pub const SCALE_3X: f32 = 3.0;
21
22pub const SCALE_4X: f32 = 4.0;
23
24pub const SCALE_8X: f32 = 8.0;
25
26pub const MAX_EXPORT_SCALE: f32 = 8.0;
27
28pub const MAX_EXPORT_DIMENSION: u32 = 16384;
29
30pub const MAX_EXPORT_PIXELS: u64 = 128 * 1024 * 1024;
31
32#[derive(Clone, Copy, Debug)]
33pub struct ExportOptions {
34    pub width: f32,
35
36    pub height: f32,
37
38    pub scale: f32,
39
40    pub background: Option<Color>,
41
42    pub theme: Theme,
43}
44
45impl Default for ExportOptions {
46    fn default() -> Self {
47        Self {
48            width: 800.0,
49            height: 600.0,
50            scale: SCALE_2X,
51            background: None,
52            theme: Theme::dark(),
53        }
54    }
55}
56
57impl ExportOptions {
58    pub fn new(width: f32, height: f32) -> Self {
59        Self {
60            width,
61            height,
62            ..Self::default()
63        }
64    }
65
66    pub fn scale(mut self, scale: f32) -> Self {
67        self.scale = scale;
68        self
69    }
70
71    pub fn background(mut self, color: Color) -> Self {
72        self.background = Some(color);
73        self
74    }
75
76    pub fn transparent(mut self) -> Self {
77        self.background = Some(Color::TRANSPARENT);
78        self
79    }
80
81    pub fn theme(mut self, theme: Theme) -> Self {
82        self.theme = theme;
83        self
84    }
85
86    fn sanitized_scale(&self) -> f32 {
87        if self.scale.is_finite() {
88            self.scale.clamp(1.0, MAX_EXPORT_SCALE)
89        } else {
90            SCALE_1X
91        }
92    }
93
94    fn physical_size(&self) -> Result<(u32, u32), ExportError> {
95        if !self.width.is_finite()
96            || !self.height.is_finite()
97            || self.width <= 0.0
98            || self.height <= 0.0
99        {
100            return Err(ExportError::InvalidSize {
101                width: self.width,
102                height: self.height,
103            });
104        }
105        let s = self.sanitized_scale();
106        let w = (self.width * s).round().max(1.0) as u32;
107        let h = (self.height * s).round().max(1.0) as u32;
108        if w == 0 || h == 0 || w > MAX_EXPORT_DIMENSION || h > MAX_EXPORT_DIMENSION {
109            return Err(ExportError::TooLarge { w, h });
110        }
111        if w as u64 * h as u64 > MAX_EXPORT_PIXELS {
112            return Err(ExportError::TooLarge { w, h });
113        }
114        Ok((w, h))
115    }
116}
117
118#[derive(Debug)]
119#[non_exhaustive]
120pub enum ExportError {
121    InvalidSize { width: f32, height: f32 },
122
123    TooLarge { w: u32, h: u32 },
124
125    Io(String),
126}
127
128impl std::fmt::Display for ExportError {
129    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        match self {
131            Self::InvalidSize { width, height } => {
132                write!(f, "sequoia-export: invalid size {width}x{height}")
133            }
134            Self::TooLarge { w, h } => {
135                write!(f, "sequoia-export: {w}x{h}px exceeds export limits")
136            }
137            Self::Io(e) => write!(f, "sequoia-export: {e}"),
138        }
139    }
140}
141
142impl std::error::Error for ExportError {}
143
144pub fn render_to_image(
145    root: impl View + 'static,
146    opts: &ExportOptions,
147) -> Result<RgbaImage, ExportError> {
148    let (pw, ph) = opts.physical_size()?;
149    let scale = opts.sanitized_scale();
150
151    let env = Environment::new();
152    env.provide(opts.theme);
153    env.provide(AssetRegistry::with_builtin_icons());
154
155    let rt = Runtime::new();
156    rt.set_environment(env);
157    rt.mount(root);
158
159    let mut text = TextSystem::new();
160    text.set_scale_factor(scale);
161    rt.layout(
162        Rect::from_xywh(0.0, 0.0, opts.width, opts.height),
163        &mut text,
164    );
165
166    let mut list = RenderList::new();
167    for _ in 0..8 {
168        let epoch = text.atlas().epoch();
169        list.clear();
170        rt.paint(&mut list, &mut text);
171        if text.atlas().epoch() == epoch {
172            break;
173        }
174    }
175
176    Ok(rasterize(&list, text.atlas(), pw, ph, scale, opts))
177}
178
179pub fn export_png(
180    root: impl View + 'static,
181    opts: &ExportOptions,
182    path: impl AsRef<Path>,
183) -> Result<PathBuf, ExportError> {
184    let path = path.as_ref().to_path_buf();
185    if let Some(parent) = path.parent()
186        && !parent.as_os_str().is_empty()
187    {
188        std::fs::create_dir_all(parent)
189            .map_err(|e| ExportError::Io(format!("mkdir {}: {e}", parent.display())))?;
190    }
191    let img = render_to_image(root, opts)?;
192    img.save(&path)
193        .map_err(|e| ExportError::Io(format!("save {}: {e}", path.display())))?;
194    Ok(path)
195}
196
197fn rasterize(
198    list: &RenderList,
199    atlas: &GlyphAtlas,
200    pw: u32,
201    ph: u32,
202    scale: f32,
203    opts: &ExportOptions,
204) -> RgbaImage {
205    let bg = opts.background.unwrap_or(opts.theme.background);
206    let mut img = RgbaImage::new(pw, ph);
207    fill_solid(&mut img, bg);
208
209    let clips = list.item_clips();
210    for (i, item) in list.items().iter().enumerate() {
211        let clip = clips.get(i).copied().flatten();
212        match item {
213            RenderItem::Quad(q) => draw_quad(&mut img, *q, clip, scale),
214            RenderItem::Text(t) => draw_text(&mut img, t, atlas, clip, scale),
215            RenderItem::Image(d) => draw_image(&mut img, d, clip, scale),
216            RenderItem::Shadow(s) => draw_shadow(&mut img, *s, clip, scale),
217            RenderItem::Waveform(w) => {
218                for bar in &w.bars {
219                    draw_quad(
220                        &mut img,
221                        Quad {
222                            rect: *bar,
223                            color: w.color,
224                            radius: 0.0,
225                            border_color: Color::TRANSPARENT,
226                            border_width: 0.0,
227                        },
228                        clip,
229                        scale,
230                    );
231                }
232            }
233            RenderItem::Gradient(g) => draw_gradient(&mut img, g, clip, scale),
234
235            _ => {}
236        }
237    }
238    let _ = (pw, ph);
239    img
240}
241
242fn blend_pixel(img: &mut RgbaImage, x: u32, y: u32, r: f32, g: f32, b: f32, alpha: f32) {
243    if alpha <= 0.0 {
244        return;
245    }
246    let a = alpha.clamp(0.0, 1.0);
247    if a >= 1.0 {
248        img.put_pixel(x, y, image::Rgba([q(r), q(g), q(b), 255]));
249        return;
250    }
251    let d = img.get_pixel(x, y).0;
252    let (dr, dg, db, da) = (
253        d[0] as f32 / 255.0,
254        d[1] as f32 / 255.0,
255        d[2] as f32 / 255.0,
256        d[3] as f32 / 255.0,
257    );
258    img.put_pixel(
259        x,
260        y,
261        image::Rgba([
262            q(r * a + dr * (1.0 - a)),
263            q(g * a + dg * (1.0 - a)),
264            q(b * a + db * (1.0 - a)),
265            q((a + da * (1.0 - a)) * 255.0 / 255.0 * 255.0),
266        ]),
267    );
268}
269
270fn q(c: f32) -> u8 {
271    (c.clamp(0.0, 1.0) * 255.0).round() as u8
272}
273
274fn fill_solid(img: &mut RgbaImage, c: Color) {
275    if c.a <= 0.0 {
276        return;
277    }
278    let (r, g, b) = (q(c.r), q(c.g), q(c.b));
279    if c.a >= 1.0 {
280        for px in img.pixels_mut() {
281            *px = image::Rgba([r, g, b, 255]);
282        }
283        return;
284    }
285    let (w, h) = (img.width(), img.height());
286    for y in 0..h {
287        for x in 0..w {
288            blend_pixel(img, x, y, c.r, c.g, c.b, c.a);
289        }
290    }
291}
292
293fn rect_coverage(x: u32, y: u32, r: (f32, f32, f32, f32)) -> f32 {
294    let (x0, y0, x1, y1) = r;
295    let cx = ((x as f32 + 1.0).min(x1) - (x as f32).max(x0)).clamp(0.0, 1.0);
296    let cy = ((y as f32 + 1.0).min(y1) - (y as f32).max(y0)).clamp(0.0, 1.0);
297    cx * cy
298}
299
300fn rounded_coverage(x: u32, y: u32, r: (f32, f32, f32, f32), radius: f32) -> f32 {
301    let (x0, y0, x1, y1) = r;
302    if x1 <= x0 || y1 <= y0 {
303        return 0.0;
304    }
305    let rad = radius.clamp(0.0, ((x1 - x0).min(y1 - y0) * 0.5).max(0.0));
306    if rad <= 0.0 {
307        return rect_coverage(x, y, r);
308    }
309    let px = x as f32 + 0.5;
310    let py = y as f32 + 0.5;
311    let cx = (x0 + x1) * 0.5;
312    let cy = (y0 + y1) * 0.5;
313    let hx = (x1 - x0) * 0.5 - rad;
314    let hy = (y1 - y0) * 0.5 - rad;
315    let qx = (px - cx).abs() - hx;
316    let qy = (py - cy).abs() - hy;
317    let ax = qx.max(0.0);
318    let ay = qy.max(0.0);
319    let outside = (ax * ax + ay * ay).sqrt() + qx.max(qy).min(0.0) - rad;
320    (0.5 - outside).clamp(0.0, 1.0)
321}
322
323fn clip_coverage(img: &RgbaImage, x: u32, y: u32, clip: Option<RoundedClip>, scale: f32) -> f32 {
324    let Some(c) = clip else { return 1.0 };
325    if c.rect.is_empty() {
326        return 0.0;
327    }
328    let _ = img;
329    rounded_coverage(
330        x,
331        y,
332        (
333            c.rect.left() * scale,
334            c.rect.top() * scale,
335            c.rect.right() * scale,
336            c.rect.bottom() * scale,
337        ),
338        c.radius * scale,
339    )
340}
341
342fn int_bounds(
343    img: &RgbaImage,
344    rect: Rect,
345    scale: f32,
346    inflate: f32,
347) -> Option<(u32, u32, u32, u32)> {
348    let x0 = ((rect.left() * scale - inflate).floor().max(0.0) as u32).min(img.width());
349    let y0 = ((rect.top() * scale - inflate).floor().max(0.0) as u32).min(img.height());
350    let x1 = ((rect.right() * scale + inflate).ceil().max(0.0) as u32).min(img.width());
351    let y1 = ((rect.bottom() * scale + inflate).ceil().max(0.0) as u32).min(img.height());
352    if x1 <= x0 || y1 <= y0 {
353        return None;
354    }
355    Some((x0, y0, x1, y1))
356}
357
358fn draw_quad(img: &mut RgbaImage, quad: Quad, clip: Option<RoundedClip>, scale: f32) {
359    let has_border = quad.border_width > 0.0 && quad.border_color.a > 0.0;
360    if quad.color.a <= 0.0 && !has_border {
361        return;
362    }
363    if has_border {
364        fill_rounded(
365            img,
366            quad.rect,
367            quad.radius,
368            quad.border_color,
369            quad.border_color.a,
370            clip,
371            scale,
372        );
373        let bw = quad.border_width.min(quad.rect.size.width * 0.5);
374        let bw = bw.min(quad.rect.size.height * 0.5).max(0.0);
375        let inner = Rect::from_xywh(
376            quad.rect.left() + bw,
377            quad.rect.top() + bw,
378            (quad.rect.size.width - bw * 2.0).max(0.0),
379            (quad.rect.size.height - bw * 2.0).max(0.0),
380        );
381        fill_rounded(
382            img,
383            inner,
384            (quad.radius - bw).max(0.0),
385            quad.color,
386            quad.color.a,
387            clip,
388            scale,
389        );
390    } else {
391        fill_rounded(
392            img,
393            quad.rect,
394            quad.radius,
395            quad.color,
396            quad.color.a,
397            clip,
398            scale,
399        );
400    }
401}
402
403fn fill_rounded(
404    img: &mut RgbaImage,
405    rect: Rect,
406    radius: f32,
407    color: Color,
408    alpha: f32,
409    clip: Option<RoundedClip>,
410    scale: f32,
411) {
412    if rect.is_empty() || alpha <= 0.0 {
413        return;
414    }
415    let Some((x0, y0, x1, y1)) = int_bounds(img, rect, scale, 1.5) else {
416        return;
417    };
418    let r = (
419        rect.left() * scale,
420        rect.top() * scale,
421        rect.right() * scale,
422        rect.bottom() * scale,
423    );
424    let rad = radius * scale;
425    for y in y0..y1 {
426        for x in x0..x1 {
427            let cov = rounded_coverage(x, y, r, rad) * clip_coverage(img, x, y, clip, scale);
428            if cov > 0.0 {
429                blend_pixel(img, x, y, color.r, color.g, color.b, alpha * cov);
430            }
431        }
432    }
433}
434
435fn draw_gradient(img: &mut RgbaImage, g: &GradientDraw, clip: Option<RoundedClip>, scale: f32) {
436    if !g.is_visible() {
437        return;
438    }
439    let stops = g.stops.stops();
440    if stops.is_empty() {
441        return;
442    }
443
444    let lin: Vec<([f32; 4], f32)> = stops
445        .iter()
446        .map(|s| {
447            let c = s.color.to_linear();
448            ([c.r, c.g, c.b, c.a], s.at)
449        })
450        .collect();
451    let sample = |t: f32| -> [f32; 4] {
452        let tc = t.clamp(0.0, 1.0);
453        if lin.len() == 1 {
454            return lin[0].0;
455        }
456        let mut seg = lin.len() - 2;
457        for i in 0..lin.len() - 1 {
458            if tc <= lin[i + 1].1 {
459                seg = i;
460                break;
461            }
462        }
463        let (c0, p0) = lin[seg];
464        let (c1, p1) = lin[seg + 1];
465        let span = (p1 - p0).max(0.0001);
466        let k = ((tc - p0) / span).clamp(0.0, 1.0);
467        [
468            c0[0] + (c1[0] - c0[0]) * k,
469            c0[1] + (c1[1] - c0[1]) * k,
470            c0[2] + (c1[2] - c0[2]) * k,
471            c0[3] + (c1[3] - c0[3]) * k,
472        ]
473    };
474    let Some((x0, y0, x1, y1)) = int_bounds(img, g.rect, scale, 1.5) else {
475        return;
476    };
477    let (rx, ry, rw, rh) = (
478        g.rect.left() * scale,
479        g.rect.top() * scale,
480        g.rect.size.width * scale,
481        g.rect.size.height * scale,
482    );
483    let rad = g.radius * scale;
484    for y in y0..y1 {
485        for x in x0..x1 {
486            let cov = rounded_coverage(x, y, (rx, ry, rx + rw, ry + rh), rad)
487                * clip_coverage(img, x, y, clip, scale);
488            if cov <= 0.0 {
489                continue;
490            }
491
492            let (px, py) = (x as f32 + 0.5, y as f32 + 0.5);
493            let t = match g.kind {
494                GradientKind::Linear { angle_deg } => {
495                    let a = if angle_deg.is_finite() {
496                        angle_deg.to_radians()
497                    } else {
498                        0.0
499                    };
500                    let (dx, dy) = (a.cos(), a.sin());
501                    let span = (rw * dx.abs() + rh * dy.abs()).max(0.0001);
502                    (((px - rx) * dx + (py - ry) * dy) / span).clamp(0.0, 1.0)
503                }
504                GradientKind::Radial { radius } => {
505                    let (cx, cy) = (rx + rw * 0.5, ry + rh * 0.5);
506                    let mut extent = (rw * 0.5).hypot(rh * 0.5);
507                    let r = if radius.is_finite() {
508                        radius.max(0.0)
509                    } else {
510                        0.0
511                    } * scale;
512                    if r > 0.0001 {
513                        extent = r;
514                    }
515                    if extent <= 0.0001 {
516                        0.0
517                    } else {
518                        ((px - cx).hypot(py - cy) / extent).clamp(0.0, 1.0)
519                    }
520                }
521            };
522            let c = sample(t);
523            let col = Color {
524                r: c[0],
525                g: c[1],
526                b: c[2],
527                a: c[3],
528            }
529            .from_linear();
530            blend_pixel(img, x, y, col.r, col.g, col.b, col.a * cov);
531        }
532    }
533}
534
535fn draw_shadow(img: &mut RgbaImage, shadow: Shadow, clip: Option<RoundedClip>, scale: f32) {
536    if !shadow.is_visible() {
537        return;
538    }
539    if shadow.inset {
540        draw_inset_shadow(img, shadow, clip, scale);
541        return;
542    }
543
544    const STEPS: i32 = 6;
545    let base = Rect::from_xywh(
546        shadow.rect.left() + shadow.offset.x,
547        shadow.rect.top() + shadow.offset.y,
548        shadow.rect.size.width,
549        shadow.rect.size.height,
550    );
551    for i in (1..=STEPS).rev() {
552        let t = i as f32 / STEPS as f32;
553        let inflate = shadow.blur * 0.5 * t + shadow.spread;
554        let layer = Rect::from_xywh(
555            base.left() - inflate,
556            base.top() - inflate,
557            (base.size.width + inflate * 2.0).max(0.0),
558            (base.size.height + inflate * 2.0).max(0.0),
559        );
560        let a = shadow.color.a * 0.22 * (1.0 - t).powi(2) + shadow.color.a * 0.02;
561        fill_rounded(
562            img,
563            layer,
564            shadow.radius + shadow.blur * 0.5 * t,
565            shadow.color,
566            a,
567            clip,
568            scale,
569        );
570    }
571}
572
573fn sd_rounded_box(ax: f32, ay: f32, hx: f32, hy: f32, r: f32) -> f32 {
574    let rr = r.clamp(0.0, hx.min(hy).max(0.0));
575    let bx = hx - rr;
576    let by = hy - rr;
577    let dx = ax - bx;
578    let dy = ay - by;
579    let ox = dx.max(0.0);
580    let oy = dy.max(0.0);
581    (ox * ox + oy * oy).sqrt() + dx.max(dy).min(0.0) - rr
582}
583
584fn draw_inset_shadow(img: &mut RgbaImage, shadow: Shadow, clip: Option<RoundedClip>, scale: f32) {
585    if !(shadow.blur > 0.0) {
586        return;
587    }
588    let r = shadow.rect;
589    if r.is_empty() {
590        return;
591    }
592    let Some((x0, y0, x1, y1)) = int_bounds(img, r, scale, 1.5) else {
593        return;
594    };
595
596    let cx = r.left() + r.size.width * 0.5 + shadow.offset.x;
597    let cy = r.top() + r.size.height * 0.5 + shadow.offset.y;
598    let hx = r.size.width * 0.5 - shadow.spread;
599    let hy = r.size.height * 0.5 - shadow.spread;
600    let mask_r = (
601        r.left() * scale,
602        r.top() * scale,
603        r.right() * scale,
604        r.bottom() * scale,
605    );
606    let mask_radius = (shadow.radius + shadow.spread).max(0.0) * scale;
607    for y in y0..y1 {
608        for x in x0..x1 {
609            let lx = (x as f32 + 0.5) / scale;
610            let ly = (y as f32 + 0.5) / scale;
611            let d = sd_rounded_box((lx - cx).abs(), (ly - cy).abs(), hx, hy, shadow.radius);
612            let t = (0.5 + d / shadow.blur).clamp(0.0, 1.0);
613            if t <= 0.0 {
614                continue;
615            }
616            let mask = rounded_coverage(x, y, mask_r, mask_radius);
617            if mask <= 0.0 {
618                continue;
619            }
620            let cov = clip_coverage(img, x, y, clip, scale);
621            let a = t * shadow.color.a * mask * cov;
622            if a > 0.0 {
623                blend_pixel(img, x, y, shadow.color.r, shadow.color.g, shadow.color.b, a);
624            }
625        }
626    }
627}
628
629fn draw_text(
630    img: &mut RgbaImage,
631    draw: &TextDraw,
632    atlas: &GlyphAtlas,
633    clip: Option<RoundedClip>,
634    scale: f32,
635) {
636    if draw.glyphs.is_empty() || draw.color.a <= 0.0 {
637        return;
638    }
639    let (aw, ah) = atlas.size();
640    if aw == 0 || ah == 0 {
641        return;
642    }
643    let pixels = atlas.pixels();
644    let stride = aw as usize * 4;
645    for g in &draw.glyphs {
646        if g.rect.is_empty() {
647            continue;
648        }
649        let dx0 = (g.rect.left() * scale).floor() as i32;
650        let dy0 = (g.rect.top() * scale).floor() as i32;
651        let dx1 = (g.rect.right() * scale).ceil() as i32;
652        let dy1 = (g.rect.bottom() * scale).ceil() as i32;
653        let dw = (dx1 - dx0).max(1) as f32;
654        let dh = (dy1 - dy0).max(1) as f32;
655        let (u0, v0, u1, v1) = (g.uv[0], g.uv[1], g.uv[2], g.uv[3]);
656        for py in dy0.max(0)..dy1.min(img.height() as i32) {
657            for px in dx0.max(0)..dx1.min(img.width() as i32) {
658                let fx = (px as f32 + 0.5 - dx0 as f32) / dw;
659                let fy = (py as f32 + 0.5 - dy0 as f32) / dh;
660                let tx = (((u0 + fx * (u1 - u0)) * aw as f32) as u32).min(aw - 1);
661                let ty = (((v0 + fy * (v1 - v0)) * ah as f32) as u32).min(ah - 1);
662                let off = (ty as usize) * stride + (tx as usize) * 4;
663                if off + 3 >= pixels.len() {
664                    continue;
665                }
666                let ta = pixels[off + 3] as f32 / 255.0;
667                if ta <= 0.0 {
668                    continue;
669                }
670                let (r, gg, b) = if g.color_glyph {
671                    (
672                        pixels[off] as f32 / 255.0,
673                        pixels[off + 1] as f32 / 255.0,
674                        pixels[off + 2] as f32 / 255.0,
675                    )
676                } else {
677                    (draw.color.r, draw.color.g, draw.color.b)
678                };
679                let cov = clip_coverage(img, px as u32, py as u32, clip, scale);
680                if cov > 0.0 {
681                    blend_pixel(img, px as u32, py as u32, r, gg, b, draw.color.a * ta * cov);
682                }
683            }
684        }
685    }
686}
687
688fn image_file_allowed(path: &str) -> bool {
689    match image::image_dimensions(path) {
690        Ok((w, h)) => {
691            w > 0
692                && h > 0
693                && w <= MAX_EXPORT_DIMENSION
694                && h <= MAX_EXPORT_DIMENSION
695                && u64::from(w) * u64::from(h) <= MAX_EXPORT_PIXELS
696        }
697
698        Err(_) => true,
699    }
700}
701
702fn draw_image(img: &mut RgbaImage, draw: &ImageDraw, clip: Option<RoundedClip>, scale: f32) {
703    match &draw.source {
704        ImageSource::Rgba {
705            width,
706            height,
707            data,
708            ..
709        }
710        | ImageSource::Dynamic {
711            width,
712            height,
713            data,
714            ..
715        } => {
716            blit_rgba(img, draw, *width, *height, data, clip, scale);
717        }
718        ImageSource::File(path) => {
719            if !image_file_allowed(path) {
720                log::warn!("sequoia-export: image `{path}` past the size limits — skipped");
721                return;
722            }
723            match image::open(path) {
724                Ok(loaded) => {
725                    let rgba = loaded.to_rgba8();
726                    let (w, h) = (rgba.width(), rgba.height());
727                    blit_rgba(img, draw, w, h, &rgba.into_raw(), clip, scale);
728                }
729                Err(e) => log::warn!("sequoia-export: cannot load image `{path}`: {e}"),
730            }
731        }
732
733        _ => {}
734    }
735}
736
737fn blit_rgba(
738    img: &mut RgbaImage,
739    draw: &ImageDraw,
740    sw: u32,
741    sh: u32,
742    data: &[u8],
743    clip: Option<RoundedClip>,
744    scale: f32,
745) {
746    if sw == 0 || sh == 0 || data.len() < sw as usize * sh as usize * 4 {
747        return;
748    }
749    let Some((x0, y0, x1, y1)) = int_bounds(img, draw.rect, scale, 1.0) else {
750        return;
751    };
752    let [u0, v0, u1, v1] = draw.uv;
753    let dw = ((draw.rect.right() - draw.rect.left()) * scale).max(1.0);
754    let dh = ((draw.rect.bottom() - draw.rect.top()) * scale).max(1.0);
755    for y in y0..y1 {
756        for x in x0..x1 {
757            let fx = (x as f32 + 0.5 - draw.rect.left() * scale) / dw;
758            let fy = (y as f32 + 0.5 - draw.rect.top() * scale) / dh;
759
760            let sx = (u0 + fx.clamp(0.0, 1.0) * (u1 - u0)) * sw as f32 - 0.5;
761            let sy = (v0 + fy.clamp(0.0, 1.0) * (v1 - v0)) * sh as f32 - 0.5;
762            let (r, g, b, a) = bilinear(data, sw, sh, sx, sy);
763            let cov = rect_coverage(
764                x,
765                y,
766                (
767                    draw.rect.left() * scale,
768                    draw.rect.top() * scale,
769                    draw.rect.right() * scale,
770                    draw.rect.bottom() * scale,
771                ),
772            ) * clip_coverage(img, x, y, clip, scale);
773            if cov > 0.0 {
774                blend_pixel(img, x, y, r, g, b, a * cov);
775            }
776        }
777    }
778}
779
780fn texel(data: &[u8], sw: u32, sh: u32, x: u32, y: u32) -> (f32, f32, f32, f32) {
781    let x = x.min(sw - 1) as usize;
782    let y = y.min(sh - 1) as usize;
783    let off = (y * sw as usize + x) * 4;
784    if off + 3 >= data.len() {
785        return (0.0, 0.0, 0.0, 0.0);
786    }
787    (
788        data[off] as f32 / 255.0,
789        data[off + 1] as f32 / 255.0,
790        data[off + 2] as f32 / 255.0,
791        data[off + 3] as f32 / 255.0,
792    )
793}
794
795fn bilinear(data: &[u8], sw: u32, sh: u32, x: f32, y: f32) -> (f32, f32, f32, f32) {
796    let x0 = x.floor().clamp(0.0, sw as f32 - 1.0) as u32;
797    let y0 = y.floor().clamp(0.0, sh as f32 - 1.0) as u32;
798    let x1 = (x0 + 1).min(sw - 1);
799    let y1 = (y0 + 1).min(sh - 1);
800    let (fx, fy) = (
801        (x - x0 as f32).clamp(0.0, 1.0),
802        (y - y0 as f32).clamp(0.0, 1.0),
803    );
804    let (a, b, c, d) = (
805        texel(data, sw, sh, x0, y0),
806        texel(data, sw, sh, x1, y0),
807        texel(data, sw, sh, x0, y1),
808        texel(data, sw, sh, x1, y1),
809    );
810    let mix = |p: f32, q: f32, r: f32, s: f32| {
811        p * (1.0 - fx) * (1.0 - fy) + q * fx * (1.0 - fy) + r * (1.0 - fx) * fy + s * fx * fy
812    };
813    (
814        mix(a.0, b.0, c.0, d.0),
815        mix(a.1, b.1, c.1, d.1),
816        mix(a.2, b.2, c.2, d.2),
817        mix(a.3, b.3, c.3, d.3),
818    )
819}
820
821#[cfg(test)]
822mod tests {
823    use super::*;
824    use sequoia_view::prelude::*;
825
826    #[test]
827    fn physical_size_follows_scale() {
828        let opts = ExportOptions::new(200.0, 100.0).scale(SCALE_2X);
829        assert_eq!(opts.physical_size().unwrap(), (400, 200));
830        let opts = ExportOptions::new(200.0, 100.0).scale(SCALE_3X);
831        assert_eq!(opts.physical_size().unwrap(), (600, 300));
832    }
833
834    #[test]
835    fn scale_is_clamped_to_sane_range() {
836        let opts = ExportOptions::new(100.0, 100.0).scale(99.0);
837        let (w, _) = opts.physical_size().unwrap();
838        assert_eq!(w, (100.0 * MAX_EXPORT_SCALE).round() as u32);
839        assert!(ExportOptions::new(0.0, 0.0).physical_size().is_err());
840    }
841
842    #[test]
843    fn red_panel_covers_canvas() {
844        let opts = ExportOptions::new(64.0, 32.0)
845            .scale(SCALE_1X)
846            .background(Color::BLACK);
847        let img = render_to_image(Panel::new().background(Color::RED).fill_size(), &opts).unwrap();
848        assert_eq!((img.width(), img.height()), (64, 32));
849        let center = img.get_pixel(32, 16).0;
850        assert!(
851            center[0] > 200 && center[1] < 60 && center[2] < 60,
852            "{center:?}"
853        );
854    }
855
856    #[test]
857    fn eight_x_output_has_correct_dimensions() {
858        let opts = ExportOptions::new(480.0, 320.0).scale(SCALE_8X);
859        let img = render_to_image(Panel::new().background(Color::BLUE).fill_size(), &opts).unwrap();
860        assert_eq!((img.width(), img.height()), (3840, 2560));
861    }
862
863    #[test]
864    fn export_png_roundtrips_through_temp_dir() {
865        let dir = std::env::temp_dir().join("sequoia-export-test");
866        let path = dir.join("roundtrip@2x.png");
867        let opts = ExportOptions::new(64.0, 32.0).scale(SCALE_2X);
868        let saved = export_png(
869            Panel::new().background(Color::RED).fill_size(),
870            &opts,
871            &path,
872        )
873        .unwrap();
874        assert_eq!(saved, path);
875        let loaded = image::open(&path).unwrap().to_rgba8();
876        assert_eq!((loaded.width(), loaded.height()), (128, 64));
877        std::fs::remove_file(&path).ok();
878        std::fs::remove_dir(&dir).ok();
879    }
880
881    #[test]
882    fn three_x_output_has_nine_times_pixels() {
883        let opts1 = ExportOptions::new(120.0, 80.0).scale(SCALE_1X);
884        let opts3 = ExportOptions::new(120.0, 80.0).scale(SCALE_3X);
885        let a = render_to_image(Panel::new().background(Color::BLUE).fill_size(), &opts1).unwrap();
886        let b = render_to_image(Panel::new().background(Color::BLUE).fill_size(), &opts3).unwrap();
887        assert_eq!((a.width(), a.height()), (120, 80));
888        assert_eq!((b.width(), b.height()), (360, 240));
889    }
890
891    #[test]
892    fn image_file_allowed_rejects_dimension_bomb() {
893        fn crc32(data: &[u8]) -> u32 {
894            let mut crc = 0xFFFF_FFFFu32;
895            for &b in data {
896                crc ^= u32::from(b);
897                for _ in 0..8 {
898                    let mask = (crc & 1).wrapping_neg();
899                    crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
900                }
901            }
902            !crc
903        }
904        use image::ImageEncoder as _;
905        let pixels = [0u8; 16];
906        let mut bytes = Vec::new();
907        image::codecs::png::PngEncoder::new(&mut bytes)
908            .write_image(&pixels, 2, 2, image::ExtendedColorType::Rgba8)
909            .unwrap();
910        let dir = std::env::temp_dir().join("sequoia-export-test");
911        std::fs::create_dir_all(&dir).unwrap();
912        let path = dir.join("bomb.png");
913        bytes[16..20].copy_from_slice(&40_000u32.to_be_bytes());
914        bytes[20..24].copy_from_slice(&40_000u32.to_be_bytes());
915        let crc = crc32(&bytes[12..29]);
916        bytes[29..33].copy_from_slice(&crc.to_be_bytes());
917        std::fs::write(&path, &bytes).unwrap();
918        assert!(!image_file_allowed(path.to_str().unwrap()));
919        std::fs::remove_file(&path).ok();
920    }
921
922    #[test]
923    fn text_renders_glyphs_when_fonts_available() {
924        let mut probe = TextSystem::new();
925        if probe.shape("Sequoia", 20.0).glyph_count() == 0 {
926            eprintln!("skip: no fonts in environment");
927            return;
928        }
929        let opts = ExportOptions::new(200.0, 60.0).scale(SCALE_2X);
930        let img = render_to_image(Text::new("Sequoia").size(20.0), &opts).unwrap();
931        let bg = opts.theme.background.to_rgb8();
932        let inked = img
933            .pixels()
934            .filter(|p| {
935                (p.0[0] as i32 - bg.0 as i32).abs()
936                    + (p.0[1] as i32 - bg.1 as i32).abs()
937                    + (p.0[2] as i32 - bg.2 as i32).abs()
938                    > 30
939            })
940            .count();
941        assert!(inked > 20, "expected visible glyphs, got {inked}");
942    }
943}