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
151pub 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
218pub 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
256fn 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
323fn 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(¢er[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(¢er[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}