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