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, OpacityNode, Point, Quad, Rect,
9    RenderItem, RenderList, RoundedClip, Shadow, TextDraw, Transform,
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 plan = list.opacity_plan();
210    draw_opacity_nodes(&mut img, &plan, list, atlas, scale);
211    let _ = (pw, ph);
212    img
213}
214
215/// Draws an opacity plan, rendering each group into a transparent layer and
216/// compositing it once at the group opacity.
217fn draw_opacity_nodes(
218    img: &mut RgbaImage,
219    nodes: &[OpacityNode],
220    list: &RenderList,
221    atlas: &GlyphAtlas,
222    scale: f32,
223) {
224    for node in nodes {
225        match node {
226            OpacityNode::Segment { start, end } => {
227                draw_item_range(img, list, *start, *end, atlas, scale);
228            }
229            OpacityNode::Group { opacity, children } => {
230                let mut layer = RgbaImage::new(img.width(), img.height());
231                draw_opacity_nodes(&mut layer, children, list, atlas, scale);
232                composite_layer(img, &layer, *opacity);
233            }
234            _ => {}
235        }
236    }
237}
238
239fn draw_item_range(
240    img: &mut RgbaImage,
241    list: &RenderList,
242    start: usize,
243    end: usize,
244    atlas: &GlyphAtlas,
245    scale: f32,
246) {
247    let clips = list.item_clips();
248    let transforms = list.item_transforms();
249    let items = list.items();
250    let start = start.min(items.len());
251    let end = end.min(items.len());
252    for (i, item) in items.iter().enumerate().take(end).skip(start) {
253        let clip = clips.get(i).copied().flatten();
254        let transform = transforms.get(i).copied().unwrap_or_default();
255        match item {
256            RenderItem::Quad(q) => draw_quad(img, *q, clip, scale),
257            RenderItem::Text(t) => draw_text(img, t, atlas, clip, scale, transform),
258            RenderItem::Image(d) => draw_image(img, d, clip, scale),
259            RenderItem::Shadow(s) => draw_shadow(img, *s, clip, scale),
260            RenderItem::Waveform(w) => {
261                for bar in &w.bars {
262                    draw_quad(
263                        img,
264                        Quad {
265                            rect: *bar,
266                            color: w.color,
267                            radius: 0.0,
268                            border_color: Color::TRANSPARENT,
269                            border_width: 0.0,
270                        },
271                        clip,
272                        scale,
273                    );
274                }
275            }
276            RenderItem::Gradient(g) => draw_gradient(img, g, clip, scale),
277            _ => {}
278        }
279    }
280}
281
282/// Composites a premultiplied layer over a premultiplied destination.
283fn composite_layer(dst: &mut RgbaImage, src: &RgbaImage, opacity: f32) {
284    let o = if opacity.is_finite() {
285        opacity.clamp(0.0, 1.0)
286    } else {
287        0.0
288    };
289    if o <= 0.0 {
290        return;
291    }
292    for (dp, sp) in dst.pixels_mut().zip(src.pixels()) {
293        let s = sp.0;
294        let sa = s[3] as f32 / 255.0;
295        if sa <= 0.0 {
296            continue;
297        }
298        let a = sa * o;
299        let d = dp.0;
300        let dr = d[0] as f32 / 255.0;
301        let dg = d[1] as f32 / 255.0;
302        let db = d[2] as f32 / 255.0;
303        let da = d[3] as f32 / 255.0;
304        dp.0 = [
305            q(s[0] as f32 / 255.0 * o + dr * (1.0 - a)),
306            q(s[1] as f32 / 255.0 * o + dg * (1.0 - a)),
307            q(s[2] as f32 / 255.0 * o + db * (1.0 - a)),
308            q(a + da * (1.0 - a)),
309        ];
310    }
311}
312
313fn blend_pixel(img: &mut RgbaImage, x: u32, y: u32, r: f32, g: f32, b: f32, alpha: f32) {
314    if alpha <= 0.0 {
315        return;
316    }
317    let a = alpha.clamp(0.0, 1.0);
318    if a >= 1.0 {
319        img.put_pixel(x, y, image::Rgba([q(r), q(g), q(b), 255]));
320        return;
321    }
322    let d = img.get_pixel(x, y).0;
323    let (dr, dg, db, da) = (
324        d[0] as f32 / 255.0,
325        d[1] as f32 / 255.0,
326        d[2] as f32 / 255.0,
327        d[3] as f32 / 255.0,
328    );
329    img.put_pixel(
330        x,
331        y,
332        image::Rgba([
333            q(r * a + dr * (1.0 - a)),
334            q(g * a + dg * (1.0 - a)),
335            q(b * a + db * (1.0 - a)),
336            q((a + da * (1.0 - a)) * 255.0 / 255.0 * 255.0),
337        ]),
338    );
339}
340
341fn q(c: f32) -> u8 {
342    (c.clamp(0.0, 1.0) * 255.0).round() as u8
343}
344
345fn fill_solid(img: &mut RgbaImage, c: Color) {
346    if c.a <= 0.0 {
347        return;
348    }
349    let (r, g, b) = (q(c.r), q(c.g), q(c.b));
350    if c.a >= 1.0 {
351        for px in img.pixels_mut() {
352            *px = image::Rgba([r, g, b, 255]);
353        }
354        return;
355    }
356    let (w, h) = (img.width(), img.height());
357    for y in 0..h {
358        for x in 0..w {
359            blend_pixel(img, x, y, c.r, c.g, c.b, c.a);
360        }
361    }
362}
363
364fn rect_coverage(x: u32, y: u32, r: (f32, f32, f32, f32)) -> f32 {
365    let (x0, y0, x1, y1) = r;
366    let cx = ((x as f32 + 1.0).min(x1) - (x as f32).max(x0)).clamp(0.0, 1.0);
367    let cy = ((y as f32 + 1.0).min(y1) - (y as f32).max(y0)).clamp(0.0, 1.0);
368    cx * cy
369}
370
371fn rounded_coverage(x: u32, y: u32, r: (f32, f32, f32, f32), radius: f32) -> f32 {
372    let (x0, y0, x1, y1) = r;
373    if x1 <= x0 || y1 <= y0 {
374        return 0.0;
375    }
376    let rad = radius.clamp(0.0, ((x1 - x0).min(y1 - y0) * 0.5).max(0.0));
377    if rad <= 0.0 {
378        return rect_coverage(x, y, r);
379    }
380    let px = x as f32 + 0.5;
381    let py = y as f32 + 0.5;
382    let cx = (x0 + x1) * 0.5;
383    let cy = (y0 + y1) * 0.5;
384    let hx = (x1 - x0) * 0.5 - rad;
385    let hy = (y1 - y0) * 0.5 - rad;
386    let qx = (px - cx).abs() - hx;
387    let qy = (py - cy).abs() - hy;
388    let ax = qx.max(0.0);
389    let ay = qy.max(0.0);
390    let outside = (ax * ax + ay * ay).sqrt() + qx.max(qy).min(0.0) - rad;
391    (0.5 - outside).clamp(0.0, 1.0)
392}
393
394fn clip_coverage(img: &RgbaImage, x: u32, y: u32, clip: Option<RoundedClip>, scale: f32) -> f32 {
395    let Some(c) = clip else { return 1.0 };
396    if c.rect.is_empty() {
397        return 0.0;
398    }
399    let _ = img;
400    rounded_coverage(
401        x,
402        y,
403        (
404            c.rect.left() * scale,
405            c.rect.top() * scale,
406            c.rect.right() * scale,
407            c.rect.bottom() * scale,
408        ),
409        c.radius * scale,
410    )
411}
412
413fn int_bounds(
414    img: &RgbaImage,
415    rect: Rect,
416    scale: f32,
417    inflate: f32,
418) -> Option<(u32, u32, u32, u32)> {
419    let x0 = ((rect.left() * scale - inflate).floor().max(0.0) as u32).min(img.width());
420    let y0 = ((rect.top() * scale - inflate).floor().max(0.0) as u32).min(img.height());
421    let x1 = ((rect.right() * scale + inflate).ceil().max(0.0) as u32).min(img.width());
422    let y1 = ((rect.bottom() * scale + inflate).ceil().max(0.0) as u32).min(img.height());
423    if x1 <= x0 || y1 <= y0 {
424        return None;
425    }
426    Some((x0, y0, x1, y1))
427}
428
429fn draw_quad(img: &mut RgbaImage, quad: Quad, clip: Option<RoundedClip>, scale: f32) {
430    let has_border = quad.border_width > 0.0 && quad.border_color.a > 0.0;
431    if quad.color.a <= 0.0 && !has_border {
432        return;
433    }
434    if has_border {
435        fill_rounded(
436            img,
437            quad.rect,
438            quad.radius,
439            quad.border_color,
440            quad.border_color.a,
441            clip,
442            scale,
443        );
444        let bw = quad.border_width.min(quad.rect.size.width * 0.5);
445        let bw = bw.min(quad.rect.size.height * 0.5).max(0.0);
446        let inner = Rect::from_xywh(
447            quad.rect.left() + bw,
448            quad.rect.top() + bw,
449            (quad.rect.size.width - bw * 2.0).max(0.0),
450            (quad.rect.size.height - bw * 2.0).max(0.0),
451        );
452        fill_rounded(
453            img,
454            inner,
455            (quad.radius - bw).max(0.0),
456            quad.color,
457            quad.color.a,
458            clip,
459            scale,
460        );
461    } else {
462        fill_rounded(
463            img,
464            quad.rect,
465            quad.radius,
466            quad.color,
467            quad.color.a,
468            clip,
469            scale,
470        );
471    }
472}
473
474fn fill_rounded(
475    img: &mut RgbaImage,
476    rect: Rect,
477    radius: f32,
478    color: Color,
479    alpha: f32,
480    clip: Option<RoundedClip>,
481    scale: f32,
482) {
483    if rect.is_empty() || alpha <= 0.0 {
484        return;
485    }
486    let Some((x0, y0, x1, y1)) = int_bounds(img, rect, scale, 1.5) else {
487        return;
488    };
489    let r = (
490        rect.left() * scale,
491        rect.top() * scale,
492        rect.right() * scale,
493        rect.bottom() * scale,
494    );
495    let rad = radius * scale;
496    for y in y0..y1 {
497        for x in x0..x1 {
498            let cov = rounded_coverage(x, y, r, rad) * clip_coverage(img, x, y, clip, scale);
499            if cov > 0.0 {
500                blend_pixel(img, x, y, color.r, color.g, color.b, alpha * cov);
501            }
502        }
503    }
504}
505
506fn draw_gradient(img: &mut RgbaImage, g: &GradientDraw, clip: Option<RoundedClip>, scale: f32) {
507    if !g.is_visible() {
508        return;
509    }
510    let stops = g.stops.stops();
511    if stops.is_empty() {
512        return;
513    }
514
515    let lin: Vec<([f32; 4], f32)> = stops
516        .iter()
517        .map(|s| {
518            let c = s.color.to_linear();
519            ([c.r, c.g, c.b, c.a], s.at)
520        })
521        .collect();
522    let sample = |t: f32| -> [f32; 4] {
523        let tc = t.clamp(0.0, 1.0);
524        if lin.len() == 1 {
525            return lin[0].0;
526        }
527        let mut seg = lin.len() - 2;
528        for i in 0..lin.len() - 1 {
529            if tc <= lin[i + 1].1 {
530                seg = i;
531                break;
532            }
533        }
534        let (c0, p0) = lin[seg];
535        let (c1, p1) = lin[seg + 1];
536        let span = (p1 - p0).max(0.0001);
537        let k = ((tc - p0) / span).clamp(0.0, 1.0);
538        [
539            c0[0] + (c1[0] - c0[0]) * k,
540            c0[1] + (c1[1] - c0[1]) * k,
541            c0[2] + (c1[2] - c0[2]) * k,
542            c0[3] + (c1[3] - c0[3]) * k,
543        ]
544    };
545    let Some((x0, y0, x1, y1)) = int_bounds(img, g.rect, scale, 1.5) else {
546        return;
547    };
548    let (rx, ry, rw, rh) = (
549        g.rect.left() * scale,
550        g.rect.top() * scale,
551        g.rect.size.width * scale,
552        g.rect.size.height * scale,
553    );
554    let rad = g.radius * scale;
555    for y in y0..y1 {
556        for x in x0..x1 {
557            let cov = rounded_coverage(x, y, (rx, ry, rx + rw, ry + rh), rad)
558                * clip_coverage(img, x, y, clip, scale);
559            if cov <= 0.0 {
560                continue;
561            }
562
563            let (px, py) = (x as f32 + 0.5, y as f32 + 0.5);
564            let t = match g.kind {
565                GradientKind::Linear { angle_deg } => {
566                    let a = if angle_deg.is_finite() {
567                        angle_deg.to_radians()
568                    } else {
569                        0.0
570                    };
571                    let (dx, dy) = (a.cos(), a.sin());
572                    let span = (rw * dx.abs() + rh * dy.abs()).max(0.0001);
573                    (((px - rx) * dx + (py - ry) * dy) / span).clamp(0.0, 1.0)
574                }
575                GradientKind::Radial { radius } => {
576                    let (cx, cy) = (rx + rw * 0.5, ry + rh * 0.5);
577                    let mut extent = (rw * 0.5).hypot(rh * 0.5);
578                    let r = if radius.is_finite() {
579                        radius.max(0.0)
580                    } else {
581                        0.0
582                    } * scale;
583                    if r > 0.0001 {
584                        extent = r;
585                    }
586                    if extent <= 0.0001 {
587                        0.0
588                    } else {
589                        ((px - cx).hypot(py - cy) / extent).clamp(0.0, 1.0)
590                    }
591                }
592            };
593            let c = sample(t);
594            let col = Color {
595                r: c[0],
596                g: c[1],
597                b: c[2],
598                a: c[3],
599            }
600            .from_linear();
601            blend_pixel(img, x, y, col.r, col.g, col.b, col.a * cov);
602        }
603    }
604}
605
606fn draw_shadow(img: &mut RgbaImage, shadow: Shadow, clip: Option<RoundedClip>, scale: f32) {
607    if !shadow.is_visible() {
608        return;
609    }
610    if shadow.inset {
611        draw_inset_shadow(img, shadow, clip, scale);
612        return;
613    }
614
615    const STEPS: i32 = 6;
616    let base = Rect::from_xywh(
617        shadow.rect.left() + shadow.offset.x,
618        shadow.rect.top() + shadow.offset.y,
619        shadow.rect.size.width,
620        shadow.rect.size.height,
621    );
622    for i in (1..=STEPS).rev() {
623        let t = i as f32 / STEPS as f32;
624        let inflate = shadow.blur * 0.5 * t + shadow.spread;
625        let layer = Rect::from_xywh(
626            base.left() - inflate,
627            base.top() - inflate,
628            (base.size.width + inflate * 2.0).max(0.0),
629            (base.size.height + inflate * 2.0).max(0.0),
630        );
631        let a = shadow.color.a * 0.22 * (1.0 - t).powi(2) + shadow.color.a * 0.02;
632        fill_rounded(
633            img,
634            layer,
635            shadow.radius + shadow.blur * 0.5 * t,
636            shadow.color,
637            a,
638            clip,
639            scale,
640        );
641    }
642}
643
644fn sd_rounded_box(ax: f32, ay: f32, hx: f32, hy: f32, r: f32) -> f32 {
645    let rr = r.clamp(0.0, hx.min(hy).max(0.0));
646    let bx = hx - rr;
647    let by = hy - rr;
648    let dx = ax - bx;
649    let dy = ay - by;
650    let ox = dx.max(0.0);
651    let oy = dy.max(0.0);
652    (ox * ox + oy * oy).sqrt() + dx.max(dy).min(0.0) - rr
653}
654
655fn draw_inset_shadow(img: &mut RgbaImage, shadow: Shadow, clip: Option<RoundedClip>, scale: f32) {
656    if !(shadow.blur > 0.0) {
657        return;
658    }
659    let r = shadow.rect;
660    if r.is_empty() {
661        return;
662    }
663    let Some((x0, y0, x1, y1)) = int_bounds(img, r, scale, 1.5) else {
664        return;
665    };
666
667    let cx = r.left() + r.size.width * 0.5 + shadow.offset.x;
668    let cy = r.top() + r.size.height * 0.5 + shadow.offset.y;
669    let hx = r.size.width * 0.5 - shadow.spread;
670    let hy = r.size.height * 0.5 - shadow.spread;
671    let mask_r = (
672        r.left() * scale,
673        r.top() * scale,
674        r.right() * scale,
675        r.bottom() * scale,
676    );
677    let mask_radius = (shadow.radius + shadow.spread).max(0.0) * scale;
678    for y in y0..y1 {
679        for x in x0..x1 {
680            let lx = (x as f32 + 0.5) / scale;
681            let ly = (y as f32 + 0.5) / scale;
682            let d = sd_rounded_box((lx - cx).abs(), (ly - cy).abs(), hx, hy, shadow.radius);
683            let t = (0.5 + d / shadow.blur).clamp(0.0, 1.0);
684            if t <= 0.0 {
685                continue;
686            }
687            let mask = rounded_coverage(x, y, mask_r, mask_radius);
688            if mask <= 0.0 {
689                continue;
690            }
691            let cov = clip_coverage(img, x, y, clip, scale);
692            let a = t * shadow.color.a * mask * cov;
693            if a > 0.0 {
694                blend_pixel(img, x, y, shadow.color.r, shadow.color.g, shadow.color.b, a);
695            }
696        }
697    }
698}
699
700fn draw_text(
701    img: &mut RgbaImage,
702    draw: &TextDraw,
703    atlas: &GlyphAtlas,
704    clip: Option<RoundedClip>,
705    scale: f32,
706    transform: Transform,
707) {
708    if draw.glyphs.is_empty() || draw.color.a <= 0.0 {
709        return;
710    }
711    let (aw, ah) = atlas.size();
712    if aw == 0 || ah == 0 {
713        return;
714    }
715    let pixels = atlas.pixels();
716    let stride = aw as usize * 4;
717    if transform.is_identity() {
718        for g in &draw.glyphs {
719            if g.rect.is_empty() {
720                continue;
721            }
722            let dx0 = (g.rect.left() * scale).floor() as i32;
723            let dy0 = (g.rect.top() * scale).floor() as i32;
724            let dx1 = (g.rect.right() * scale).ceil() as i32;
725            let dy1 = (g.rect.bottom() * scale).ceil() as i32;
726            let dw = (dx1 - dx0).max(1) as f32;
727            let dh = (dy1 - dy0).max(1) as f32;
728            let (u0, v0, u1, v1) = (g.uv[0], g.uv[1], g.uv[2], g.uv[3]);
729            for py in dy0.max(0)..dy1.min(img.height() as i32) {
730                for px in dx0.max(0)..dx1.min(img.width() as i32) {
731                    let fx = (px as f32 + 0.5 - dx0 as f32) / dw;
732                    let fy = (py as f32 + 0.5 - dy0 as f32) / dh;
733                    let tx = (((u0 + fx * (u1 - u0)) * aw as f32) as u32).min(aw - 1);
734                    let ty = (((v0 + fy * (v1 - v0)) * ah as f32) as u32).min(ah - 1);
735                    let off = (ty as usize) * stride + (tx as usize) * 4;
736                    if off + 3 >= pixels.len() {
737                        continue;
738                    }
739                    let ta = pixels[off + 3] as f32 / 255.0;
740                    if ta <= 0.0 {
741                        continue;
742                    }
743                    let (r, gg, b) = if g.color_glyph {
744                        (
745                            pixels[off] as f32 / 255.0,
746                            pixels[off + 1] as f32 / 255.0,
747                            pixels[off + 2] as f32 / 255.0,
748                        )
749                    } else {
750                        (draw.color.r, draw.color.g, draw.color.b)
751                    };
752                    let cov = clip_coverage(img, px as u32, py as u32, clip, scale);
753                    if cov > 0.0 {
754                        blend_pixel(img, px as u32, py as u32, r, gg, b, draw.color.a * ta * cov);
755                    }
756                }
757            }
758        }
759        return;
760    }
761    let Some(inverse) = transform.inverse() else {
762        return;
763    };
764    for g in &draw.glyphs {
765        if g.rect.is_empty() {
766            continue;
767        }
768        let bounds = transform.apply_rect_aabb(g.rect);
769        let dx0 = (bounds.left() * scale).floor() as i32;
770        let dy0 = (bounds.top() * scale).floor() as i32;
771        let dx1 = (bounds.right() * scale).ceil() as i32;
772        let dy1 = (bounds.bottom() * scale).ceil() as i32;
773        let (u0, v0, u1, v1) = (g.uv[0], g.uv[1], g.uv[2], g.uv[3]);
774        for py in dy0.max(0)..dy1.min(img.height() as i32) {
775            for px in dx0.max(0)..dx1.min(img.width() as i32) {
776                let local = inverse.apply(Point::new(
777                    (px as f32 + 0.5) / scale,
778                    (py as f32 + 0.5) / scale,
779                ));
780                if !g.rect.contains(local) {
781                    continue;
782                }
783                let fx = (local.x - g.rect.left()) / g.rect.size.width;
784                let fy = (local.y - g.rect.top()) / g.rect.size.height;
785                let tx = (((u0 + fx * (u1 - u0)) * aw as f32) as u32).min(aw - 1);
786                let ty = (((v0 + fy * (v1 - v0)) * ah as f32) as u32).min(ah - 1);
787                let off = (ty as usize) * stride + (tx as usize) * 4;
788                if off + 3 >= pixels.len() {
789                    continue;
790                }
791                let ta = pixels[off + 3] as f32 / 255.0;
792                if ta <= 0.0 {
793                    continue;
794                }
795                let (r, gg, b) = if g.color_glyph {
796                    (
797                        pixels[off] as f32 / 255.0,
798                        pixels[off + 1] as f32 / 255.0,
799                        pixels[off + 2] as f32 / 255.0,
800                    )
801                } else {
802                    (draw.color.r, draw.color.g, draw.color.b)
803                };
804                let cov = clip_coverage(img, px as u32, py as u32, clip, scale);
805                if cov > 0.0 {
806                    blend_pixel(img, px as u32, py as u32, r, gg, b, draw.color.a * ta * cov);
807                }
808            }
809        }
810    }
811}
812
813fn image_file_allowed(path: &str) -> bool {
814    match image::image_dimensions(path) {
815        Ok((w, h)) => {
816            w > 0
817                && h > 0
818                && w <= MAX_EXPORT_DIMENSION
819                && h <= MAX_EXPORT_DIMENSION
820                && u64::from(w) * u64::from(h) <= MAX_EXPORT_PIXELS
821        }
822
823        Err(_) => true,
824    }
825}
826
827fn draw_image(img: &mut RgbaImage, draw: &ImageDraw, clip: Option<RoundedClip>, scale: f32) {
828    match &draw.source {
829        ImageSource::Rgba {
830            width,
831            height,
832            data,
833            ..
834        }
835        | ImageSource::Dynamic {
836            width,
837            height,
838            data,
839            ..
840        } => {
841            blit_rgba(img, draw, *width, *height, data, clip, scale);
842        }
843        ImageSource::File(path) => {
844            if !image_file_allowed(path) {
845                log::warn!("sequoia-export: image `{path}` past the size limits — skipped");
846                return;
847            }
848            match image::open(path) {
849                Ok(loaded) => {
850                    let rgba = loaded.to_rgba8();
851                    let (w, h) = (rgba.width(), rgba.height());
852                    blit_rgba(img, draw, w, h, &rgba.into_raw(), clip, scale);
853                }
854                Err(e) => log::warn!("sequoia-export: cannot load image `{path}`: {e}"),
855            }
856        }
857
858        _ => {}
859    }
860}
861
862fn blit_rgba(
863    img: &mut RgbaImage,
864    draw: &ImageDraw,
865    sw: u32,
866    sh: u32,
867    data: &[u8],
868    clip: Option<RoundedClip>,
869    scale: f32,
870) {
871    if sw == 0 || sh == 0 || data.len() < sw as usize * sh as usize * 4 {
872        return;
873    }
874    let Some((x0, y0, x1, y1)) = int_bounds(img, draw.rect, scale, 1.0) else {
875        return;
876    };
877    let [u0, v0, u1, v1] = draw.uv;
878    let dw = ((draw.rect.right() - draw.rect.left()) * scale).max(1.0);
879    let dh = ((draw.rect.bottom() - draw.rect.top()) * scale).max(1.0);
880    for y in y0..y1 {
881        for x in x0..x1 {
882            let fx = (x as f32 + 0.5 - draw.rect.left() * scale) / dw;
883            let fy = (y as f32 + 0.5 - draw.rect.top() * scale) / dh;
884
885            let sx = (u0 + fx.clamp(0.0, 1.0) * (u1 - u0)) * sw as f32 - 0.5;
886            let sy = (v0 + fy.clamp(0.0, 1.0) * (v1 - v0)) * sh as f32 - 0.5;
887            let (r, g, b, a) = bilinear(data, sw, sh, sx, sy);
888            let cov = rect_coverage(
889                x,
890                y,
891                (
892                    draw.rect.left() * scale,
893                    draw.rect.top() * scale,
894                    draw.rect.right() * scale,
895                    draw.rect.bottom() * scale,
896                ),
897            ) * clip_coverage(img, x, y, clip, scale);
898            if cov > 0.0 {
899                blend_pixel(img, x, y, r, g, b, a * cov);
900            }
901        }
902    }
903}
904
905fn texel(data: &[u8], sw: u32, sh: u32, x: u32, y: u32) -> (f32, f32, f32, f32) {
906    let x = x.min(sw - 1) as usize;
907    let y = y.min(sh - 1) as usize;
908    let off = (y * sw as usize + x) * 4;
909    if off + 3 >= data.len() {
910        return (0.0, 0.0, 0.0, 0.0);
911    }
912    (
913        data[off] as f32 / 255.0,
914        data[off + 1] as f32 / 255.0,
915        data[off + 2] as f32 / 255.0,
916        data[off + 3] as f32 / 255.0,
917    )
918}
919
920fn bilinear(data: &[u8], sw: u32, sh: u32, x: f32, y: f32) -> (f32, f32, f32, f32) {
921    let x0 = x.floor().clamp(0.0, sw as f32 - 1.0) as u32;
922    let y0 = y.floor().clamp(0.0, sh as f32 - 1.0) as u32;
923    let x1 = (x0 + 1).min(sw - 1);
924    let y1 = (y0 + 1).min(sh - 1);
925    let (fx, fy) = (
926        (x - x0 as f32).clamp(0.0, 1.0),
927        (y - y0 as f32).clamp(0.0, 1.0),
928    );
929    let (a, b, c, d) = (
930        texel(data, sw, sh, x0, y0),
931        texel(data, sw, sh, x1, y0),
932        texel(data, sw, sh, x0, y1),
933        texel(data, sw, sh, x1, y1),
934    );
935    let mix = |p: f32, q: f32, r: f32, s: f32| {
936        p * (1.0 - fx) * (1.0 - fy) + q * fx * (1.0 - fy) + r * (1.0 - fx) * fy + s * fx * fy
937    };
938    (
939        mix(a.0, b.0, c.0, d.0),
940        mix(a.1, b.1, c.1, d.1),
941        mix(a.2, b.2, c.2, d.2),
942        mix(a.3, b.3, c.3, d.3),
943    )
944}
945
946#[cfg(test)]
947mod tests {
948    use super::*;
949    use sequoia_core::Length;
950    use sequoia_view::prelude::*;
951
952    #[test]
953    fn physical_size_follows_scale() {
954        let opts = ExportOptions::new(200.0, 100.0).scale(SCALE_2X);
955        assert_eq!(opts.physical_size().unwrap(), (400, 200));
956        let opts = ExportOptions::new(200.0, 100.0).scale(SCALE_3X);
957        assert_eq!(opts.physical_size().unwrap(), (600, 300));
958    }
959
960    #[test]
961    fn scale_is_clamped_to_sane_range() {
962        let opts = ExportOptions::new(100.0, 100.0).scale(99.0);
963        let (w, _) = opts.physical_size().unwrap();
964        assert_eq!(w, (100.0 * MAX_EXPORT_SCALE).round() as u32);
965        assert!(ExportOptions::new(0.0, 0.0).physical_size().is_err());
966    }
967
968    #[test]
969    fn red_panel_covers_canvas() {
970        let opts = ExportOptions::new(64.0, 32.0)
971            .scale(SCALE_1X)
972            .background(Color::BLACK);
973        let img = render_to_image(Panel::new().background(Color::RED).fill_size(), &opts).unwrap();
974        assert_eq!((img.width(), img.height()), (64, 32));
975        let center = img.get_pixel(32, 16).0;
976        assert!(
977            center[0] > 200 && center[1] < 60 && center[2] < 60,
978            "{center:?}"
979        );
980    }
981
982    #[test]
983    fn opacity_group_composites_overlapping_children_once() {
984        let opts = ExportOptions::new(40.0, 40.0)
985            .scale(SCALE_1X)
986            .background(Color::BLACK);
987        let layered = Stack::new()
988            .fill_size()
989            .child(Panel::new().background(Color::RED).fill_size())
990            .child(Panel::new().background(Color::RED).fill_size());
991        let img = render_to_image(Opacity::new(layered, 0.5), &opts).unwrap();
992        let center = img.get_pixel(20, 20).0;
993        assert!(
994            (110..=140).contains(&center[0]) && center[1] < 10 && center[2] < 10,
995            "group alpha should be applied once, got {center:?}"
996        );
997    }
998
999    #[test]
1000    fn nested_opacity_groups_multiply() {
1001        let opts = ExportOptions::new(40.0, 40.0)
1002            .scale(SCALE_1X)
1003            .background(Color::BLACK);
1004        let panel = Panel::new()
1005            .background(Color::RED)
1006            .width(Length::px(40.0))
1007            .height(Length::px(40.0));
1008        let img = render_to_image(Opacity::new(Opacity::new(panel, 0.5), 0.5), &opts).unwrap();
1009        let center = img.get_pixel(20, 20).0;
1010        assert!(
1011            (50..=80).contains(&center[0]) && center[1] < 10 && center[2] < 10,
1012            "nested opacity should multiply, got {center:?}"
1013        );
1014    }
1015
1016    #[test]
1017    fn eight_x_output_has_correct_dimensions() {
1018        let opts = ExportOptions::new(480.0, 320.0).scale(SCALE_8X);
1019        let img = render_to_image(Panel::new().background(Color::BLUE).fill_size(), &opts).unwrap();
1020        assert_eq!((img.width(), img.height()), (3840, 2560));
1021    }
1022
1023    #[test]
1024    fn export_png_roundtrips_through_temp_dir() {
1025        let dir = std::env::temp_dir().join("sequoia-export-test");
1026        let path = dir.join("roundtrip@2x.png");
1027        let opts = ExportOptions::new(64.0, 32.0).scale(SCALE_2X);
1028        let saved = export_png(
1029            Panel::new().background(Color::RED).fill_size(),
1030            &opts,
1031            &path,
1032        )
1033        .unwrap();
1034        assert_eq!(saved, path);
1035        let loaded = image::open(&path).unwrap().to_rgba8();
1036        assert_eq!((loaded.width(), loaded.height()), (128, 64));
1037        std::fs::remove_file(&path).ok();
1038        std::fs::remove_dir(&dir).ok();
1039    }
1040
1041    #[test]
1042    fn three_x_output_has_nine_times_pixels() {
1043        let opts1 = ExportOptions::new(120.0, 80.0).scale(SCALE_1X);
1044        let opts3 = ExportOptions::new(120.0, 80.0).scale(SCALE_3X);
1045        let a = render_to_image(Panel::new().background(Color::BLUE).fill_size(), &opts1).unwrap();
1046        let b = render_to_image(Panel::new().background(Color::BLUE).fill_size(), &opts3).unwrap();
1047        assert_eq!((a.width(), a.height()), (120, 80));
1048        assert_eq!((b.width(), b.height()), (360, 240));
1049    }
1050
1051    #[test]
1052    fn image_file_allowed_rejects_dimension_bomb() {
1053        fn crc32(data: &[u8]) -> u32 {
1054            let mut crc = 0xFFFF_FFFFu32;
1055            for &b in data {
1056                crc ^= u32::from(b);
1057                for _ in 0..8 {
1058                    let mask = (crc & 1).wrapping_neg();
1059                    crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
1060                }
1061            }
1062            !crc
1063        }
1064        use image::ImageEncoder as _;
1065        let pixels = [0u8; 16];
1066        let mut bytes = Vec::new();
1067        image::codecs::png::PngEncoder::new(&mut bytes)
1068            .write_image(&pixels, 2, 2, image::ExtendedColorType::Rgba8)
1069            .unwrap();
1070        let dir = std::env::temp_dir().join("sequoia-export-test");
1071        std::fs::create_dir_all(&dir).unwrap();
1072        let path = dir.join("bomb.png");
1073        bytes[16..20].copy_from_slice(&40_000u32.to_be_bytes());
1074        bytes[20..24].copy_from_slice(&40_000u32.to_be_bytes());
1075        let crc = crc32(&bytes[12..29]);
1076        bytes[29..33].copy_from_slice(&crc.to_be_bytes());
1077        std::fs::write(&path, &bytes).unwrap();
1078        assert!(!image_file_allowed(path.to_str().unwrap()));
1079        std::fs::remove_file(&path).ok();
1080    }
1081
1082    #[test]
1083    fn text_renders_glyphs_when_fonts_available() {
1084        let mut probe = TextSystem::new();
1085        if probe.shape("Sequoia", 20.0).glyph_count() == 0 {
1086            eprintln!("skip: no fonts in environment");
1087            return;
1088        }
1089        let opts = ExportOptions::new(200.0, 60.0).scale(SCALE_2X);
1090        let img = render_to_image(Text::new("Sequoia").size(20.0), &opts).unwrap();
1091        let bg = opts.theme.background.to_rgb8();
1092        let inked = img
1093            .pixels()
1094            .filter(|p| {
1095                (p.0[0] as i32 - bg.0 as i32).abs()
1096                    + (p.0[1] as i32 - bg.1 as i32).abs()
1097                    + (p.0[2] as i32 - bg.2 as i32).abs()
1098                    > 30
1099            })
1100            .count();
1101        assert!(inked > 20, "expected visible glyphs, got {inked}");
1102    }
1103
1104    #[test]
1105    fn rotated_text_export_swaps_ink_axis() {
1106        let mut probe = TextSystem::new();
1107        if probe.shape("Sequoia", 28.0).glyph_count() == 0 {
1108            eprintln!("skip: no fonts in environment");
1109            return;
1110        }
1111        let opts = ExportOptions::new(320.0, 320.0).scale(SCALE_1X);
1112        let bg = opts.theme.background.to_rgb8();
1113        let ink_bbox = |img: &RgbaImage| -> (u32, u32) {
1114            let mut min_x = u32::MAX;
1115            let mut min_y = u32::MAX;
1116            let mut max_x = 0;
1117            let mut max_y = 0;
1118            for (x, y, p) in img.enumerate_pixels() {
1119                let d = (p.0[0] as i32 - bg.0 as i32).abs()
1120                    + (p.0[1] as i32 - bg.1 as i32).abs()
1121                    + (p.0[2] as i32 - bg.2 as i32).abs();
1122                if d > 30 {
1123                    min_x = min_x.min(x);
1124                    min_y = min_y.min(y);
1125                    max_x = max_x.max(x);
1126                    max_y = max_y.max(y);
1127                }
1128            }
1129            assert!(
1130                min_x <= max_x && min_y <= max_y,
1131                "expected visible text ink"
1132            );
1133            (max_x - min_x + 1, max_y - min_y + 1)
1134        };
1135        let plain = render_to_image(
1136            Stack::new()
1137                .size(Length::Fill(1.0), Length::Fill(1.0))
1138                .overlay(Text::new("Sequoia").size(28.0), 120.0, 140.0),
1139            &opts,
1140        )
1141        .unwrap();
1142        let rotated = render_to_image(
1143            Stack::new()
1144                .size(Length::Fill(1.0), Length::Fill(1.0))
1145                .overlay(Text::new("Sequoia").size(28.0).rotate(90.0), 120.0, 140.0),
1146            &opts,
1147        )
1148        .unwrap();
1149        let (plain_w, plain_h) = ink_bbox(&plain);
1150        let (rot_w, rot_h) = ink_bbox(&rotated);
1151        assert!(
1152            plain_w > plain_h * 2,
1153            "plain ink should be wide: {plain_w}x{plain_h}"
1154        );
1155        assert!(
1156            rot_h > rot_w * 2,
1157            "rotated ink should be tall: {rot_w}x{rot_h}"
1158        );
1159    }
1160}