Files
figcraft/crates/render/src/lib.rs
T

164 lines
6.1 KiB
Rust

//! The FigCraft renderer.
//!
//! [`render_view`] draws a page region at any zoom into a premultiplied RGBA pixmap (the canvas);
//! [`render_node`] rasterises one node for export at a scale. Everything is CPU (tiny-skia) so it
//! runs identically on desktop, in tests and in the browser.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod draw;
pub mod fx;
pub mod images;
pub mod paint;
pub use draw::{node_outline, subtree_outline, ts_path};
pub use tiny_skia::Pixmap;
use figcraft_color::Color;
use figcraft_doc::{Document, NodeId, NodeKind};
use figcraft_geom::{Affine, Rect};
/// Which part of a page to draw, and how big.
#[derive(Clone, Debug)]
pub struct View {
/// Page coordinate shown at the pixmap's top-left.
pub origin: (f64, f64),
/// Device pixels per page unit.
pub scale: f64,
pub width: u32,
pub height: u32,
}
impl View {
pub fn transform(&self) -> Affine {
Affine::scale(self.scale) * Affine::translate((-self.origin.0, -self.origin.1))
}
pub fn page_rect(&self) -> Rect {
let s = self.scale.max(1e-9);
Rect::new(self.origin.0, self.origin.1, self.origin.0 + f64::from(self.width) / s, self.origin.1 + f64::from(self.height) / s)
}
}
#[derive(Clone, Debug, Default)]
pub struct Options {
/// Fill the background (None = transparent, e.g. export without page colour).
pub background: Option<Color>,
pub outline_mode: bool,
/// Nodes to leave out (an editor drawing them itself).
pub skip: Vec<NodeId>,
}
fn new_pixmap(w: u32, h: u32) -> Option<Pixmap> {
let w = w.clamp(1, 16_384);
let h = h.clamp(1, 16_384);
Pixmap::new(w, h)
}
/// Render a page region.
pub fn render_view(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Option<Pixmap> {
let mut pix = new_pixmap(view.width, view.height)?;
let bg = opts.background.or_else(|| match doc.node(page).map(|p| &p.kind) {
Some(NodeKind::Page { background, .. }) => Some(*background),
_ => None,
});
if let Some(bg) = bg {
pix.fill(paint::ts_color(bg));
}
let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: opts.outline_mode, skip: &opts.skip };
let xf = view.transform();
let Some(p) = doc.node(page) else { return Some(pix) };
for c in &p.children {
draw::draw_node(&ctx, &mut pix, *c, xf, 0);
}
Some(pix)
}
/// Render several nodes (in z-order) into an existing pixmap through `view`.
pub fn render_nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap) {
let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: false, skip: &[] };
for id in ids {
let parent_xf = doc.node(*id).and_then(|n| n.parent).map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY);
draw::draw_node(&ctx, pix, *id, view.transform() * parent_xf, 0);
}
}
/// The page-space rectangle a node exports: a clipping frame's box (plus its own effects), else
/// everything it paints.
pub fn export_bounds(doc: &Document, id: NodeId) -> Option<Rect> {
let n = doc.node(id)?;
if n.kind.is_frame_like() && n.clip {
let reach = n.paint_reach();
return Some(figcraft_geom::transform_rect_bbox(doc.world_transform(id), n.local_rect().inflate(reach, reach)));
}
doc.world_render_bounds(id)
}
/// Rasterise one node at `scale` (export). Transparent background.
pub fn render_node(doc: &Document, id: NodeId, scale: f64) -> Option<Pixmap> {
let b = export_bounds(doc, id)?;
let scale = if scale.is_finite() && scale > 0.0 { scale.min(64.0) } else { 1.0 };
let w = (b.width() * scale).ceil().max(1.0);
let h = (b.height() * scale).ceil().max(1.0);
if w > 16_384.0 || h > 16_384.0 {
return None;
}
let view = View { origin: (b.x0, b.y0), scale, width: w as u32, height: h as u32 };
let mut pix = new_pixmap(view.width, view.height)?;
render_nodes_into(doc, &[id], &view, &mut pix);
Some(pix)
}
/// Un-premultiply into straight RGBA8 bytes.
pub fn to_rgba8(p: &Pixmap) -> Vec<u8> {
let mut out = Vec::with_capacity(p.data().len());
for px in p.data().chunks_exact(4) {
let a = px.get(3).copied().unwrap_or(0);
if a == 0 {
out.extend_from_slice(&[0, 0, 0, 0]);
} else {
let un = |c: u8| ((u32::from(c) * 255 + u32::from(a) / 2) / u32::from(a)).min(255) as u8;
out.extend_from_slice(&[un(px.first().copied().unwrap_or(0)), un(px.get(1).copied().unwrap_or(0)), un(px.get(2).copied().unwrap_or(0)), a]);
}
}
out
}
/// Encode as PNG.
pub fn encode_png(p: &Pixmap) -> Result<Vec<u8>, String> {
let rgba = to_rgba8(p);
let mut out = Vec::new();
let enc = image::codecs::png::PngEncoder::new(&mut out);
image::ImageEncoder::write_image(enc, &rgba, p.width(), p.height(), image::ExtendedColorType::Rgba8).map_err(|e| e.to_string())?;
Ok(out)
}
/// Encode as JPEG over `background` (JPEG has no alpha).
pub fn encode_jpeg(p: &Pixmap, background: Color, quality: u8) -> Result<Vec<u8>, String> {
let rgba = to_rgba8(p);
let bg = background.to_rgba8();
let mut rgb = Vec::with_capacity(rgba.len() / 4 * 3);
for px in rgba.chunks_exact(4) {
let a = u32::from(px.get(3).copied().unwrap_or(0));
for c in 0..3 {
let s = u32::from(px.get(c).copied().unwrap_or(0));
let b = u32::from(bg.get(c).copied().unwrap_or(255));
rgb.push(((s * a + b * (255 - a) + 127) / 255) as u8);
}
}
let mut out = Vec::new();
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100));
image::ImageEncoder::write_image(enc, &rgb, p.width(), p.height(), image::ExtendedColorType::Rgb8).map_err(|e| e.to_string())?;
Ok(out)
}
/// Encode as lossless WebP.
pub fn encode_webp(p: &Pixmap) -> Result<Vec<u8>, String> {
let rgba = to_rgba8(p);
let mut out = Vec::new();
let enc = image::codecs::webp::WebPEncoder::new_lossless(&mut out);
image::ImageEncoder::write_image(enc, &rgba, p.width(), p.height(), image::ExtendedColorType::Rgba8).map_err(|e| e.to_string())?;
Ok(out)
}
#[cfg(test)]
mod tests;