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