mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -06:00
Fit to Artwork Bounds resized the active artboard to the bounds of all the art in the document, wherever that art was. With art on two artboards, fitting one of them stretched it over both. artboard.fitToArt now takes `indices` and fits each artboard to the art on it, in one undo step. An object counts for every artboard its drawn bounds overlap, and the artboard is fitted to all of its bounds, also where it reaches past the artboard's edge. Art flush with an edge from outside does not count. An artboard with no art on it keeps its size and place, and the command fails with "no artwork" when none of its artboards has art. Object › Artboards and the Artboards panel menu pass the selected artboards (the Artboard tool's, else the active one), and a recorded action leaves them out. Fit to Selected Art still resizes the active artboard. Docs: docs/mcp.md, docs/target-app-parity.md.
251 lines
10 KiB
Rust
251 lines
10 KiB
Rust
//! Raster export: render a document region with [`RasterExportOptions`] (resolution, background,
|
||
//! anti-aliasing) and encode it as PNG ([`png`]: resolution in `pHYs`, Adam7 interlacing), JPEG
|
||
//! ([`jpeg`]: RGB, CMYK or grey, progressive, resolution and colour profile), lossless WebP, or
|
||
//! a palette image ([`quantize`]) as PNG-8 or [`gif`], [`tiff`] (RGB, CMYK or grey, LZW, either byte
|
||
//! order, the profile), [`bmp`] (1–32 bits, RLE) or [`tga`]; [`web`] adds what Save for Web optimises
|
||
//! further (web snap, colour table edits, lossy GIF, comments); [`psd`] writes layered bitmaps.
|
||
|
||
pub mod bmp;
|
||
pub mod gif;
|
||
pub mod jpeg;
|
||
pub mod png;
|
||
pub mod psd;
|
||
pub mod quantize;
|
||
pub mod tga;
|
||
pub mod tiff;
|
||
pub mod web;
|
||
|
||
use vectorcraft_doc::{Document, Node, NodeKind, SymbolBoxes};
|
||
use vectorcraft_geom::Rect;
|
||
|
||
use crate::{AntiAlias, RenderOptions, Rendered, Renderer, fx};
|
||
|
||
/// A raster file format.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum RasterFormat {
|
||
Png,
|
||
Jpeg,
|
||
WebP,
|
||
/// Indexed PNG: a palette of up to 256 colours.
|
||
Png8,
|
||
Gif,
|
||
Tiff,
|
||
Bmp,
|
||
Tga,
|
||
/// Layered bitmap (.psd).
|
||
Psd,
|
||
}
|
||
|
||
/// How a raster export renders and encodes.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct RasterExportOptions {
|
||
/// Resolution in pixels per inch: 72 renders one pixel per point. Stored in the file.
|
||
pub ppi: f64,
|
||
/// Opaque background colour (RGB); `None` = transparent (JPEG has no alpha: white).
|
||
pub background: Option<[u8; 3]>,
|
||
pub anti_alias: AntiAlias,
|
||
/// PNG, PNG-8: Adam7 interlacing; GIF: interlaced rows.
|
||
pub interlaced: bool,
|
||
/// JPEG quality 0–100.
|
||
pub quality: u8,
|
||
/// JPEG colour model, coding and profile.
|
||
pub jpeg: jpeg::JpegOptions,
|
||
/// PNG-8 and GIF: the colour reduction (also BMP's at 1, 4 and 8 bits).
|
||
pub palette: quantize::PaletteOptions,
|
||
/// TIFF colour model, compression, byte order and profile.
|
||
pub tiff: tiff::TiffOptions,
|
||
/// BMP layout, depth, compression and row order.
|
||
pub bmp: bmp::BmpOptions,
|
||
/// Targa depth.
|
||
pub tga: tga::TgaOptions,
|
||
/// PSD colour model, layers and profile.
|
||
pub psd: psd::PsdOptions,
|
||
/// Appearance of Black › Printing / Exporting › Output All Blacks as Rich Black: pure CMYK
|
||
/// black (K alone at 100%) becomes RGB black in RGB raster exports (document values unchanged).
|
||
pub rich_black: bool,
|
||
}
|
||
|
||
impl Default for RasterExportOptions {
|
||
fn default() -> Self {
|
||
Self {
|
||
ppi: 72.0,
|
||
background: None,
|
||
anti_alias: AntiAlias::Art,
|
||
interlaced: false,
|
||
quality: 90,
|
||
jpeg: jpeg::JpegOptions::default(),
|
||
palette: quantize::PaletteOptions::default(),
|
||
tiff: tiff::TiffOptions::default(),
|
||
bmp: bmp::BmpOptions::default(),
|
||
tga: tga::TgaOptions::default(),
|
||
psd: psd::PsdOptions::default(),
|
||
rich_black: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl RasterExportOptions {
|
||
/// Pixels per point.
|
||
pub fn scale(&self) -> f64 {
|
||
self.ppi / 72.0
|
||
}
|
||
|
||
/// What the renderer draws for `format`: template layers and guides left out, over the background
|
||
/// (white for a JPEG or a flat PSD without one), composited in floating point.
|
||
pub fn render_options(&self, format: RasterFormat) -> RenderOptions {
|
||
let flat = format == RasterFormat::Jpeg || (format == RasterFormat::Psd && !self.psd.layers);
|
||
let background = self.background.or(flat.then_some([255; 3]));
|
||
RenderOptions {
|
||
background: background.map(|[r, g, b]| [r, g, b, 255]),
|
||
skip_templates: true,
|
||
anti_alias: self.anti_alias,
|
||
// Opaque art stays exactly opaque where translucent edges cross it (#787).
|
||
precise: true,
|
||
// Shapes meeting edge to edge leave no seam (#983).
|
||
supersample: true,
|
||
rich_black: self.rich_black,
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
/// Encode a rendered image as `format`. A CMYK JPEG separates the screen colours, and a PSD is
|
||
/// flat; [`Renderer::export_region`] draws the inks and the layers instead.
|
||
pub fn encode(&self, img: &Rendered, format: RasterFormat) -> Result<Vec<u8>, String> {
|
||
match format {
|
||
RasterFormat::Png => {
|
||
png::encode(&img.to_straight(), img.width, img.height, &png::PngOptions { ppi: Some(self.ppi), interlaced: self.interlaced })
|
||
}
|
||
RasterFormat::Jpeg => {
|
||
let px = match self.jpeg.color_model {
|
||
jpeg::ColorModel::Cmyk => jpeg::separated(img),
|
||
model => jpeg::screen_pixels(img, model == jpeg::ColorModel::Gray),
|
||
};
|
||
jpeg::encode(&px, img.width, img.height, self.quality, Some(self.ppi), &self.jpeg)
|
||
}
|
||
RasterFormat::WebP => webp(img),
|
||
RasterFormat::Png8 | RasterFormat::Gif => {
|
||
let ix = quantize::quantize(&img.to_straight(), img.width, img.height, &self.palette);
|
||
match format {
|
||
RasterFormat::Gif => gif::encode(&ix, self.interlaced),
|
||
_ => png::encode_indexed(&ix, &png::PngOptions { ppi: Some(self.ppi), interlaced: self.interlaced }),
|
||
}
|
||
}
|
||
RasterFormat::Tiff => tiff::encode(img, self.ppi, &self.tiff),
|
||
RasterFormat::Bmp => bmp::encode(&img.to_straight(), img.width, img.height, self.ppi, &self.bmp, &self.palette),
|
||
RasterFormat::Tga => tga::encode(&img.to_straight(), img.width, img.height, &self.tga),
|
||
RasterFormat::Psd => psd::encode(img, self.ppi, &self.psd),
|
||
}
|
||
}
|
||
|
||
/// Does `format` write ink amounts (a CMYK JPEG or TIFF)?
|
||
fn cmyk(&self, format: RasterFormat) -> bool {
|
||
match format {
|
||
RasterFormat::Jpeg => self.jpeg.color_model == jpeg::ColorModel::Cmyk,
|
||
RasterFormat::Tiff => self.tiff.color_model == jpeg::ColorModel::Cmyk,
|
||
_ => false,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Renderer {
|
||
/// Render `region` of `doc` (an artboard or any rect) as exported and encode it as `format`.
|
||
/// Callers check the size first ([`crate::raster_size`]). A CMYK JPEG or TIFF is drawn as ink
|
||
/// amounts ([`Renderer::render_region_inks`]); a PSD with its layers ([`psd::export`]).
|
||
pub fn export_region(&mut self, doc: &Document, region: Rect, format: RasterFormat, opts: &RasterExportOptions) -> Result<Vec<u8>, String> {
|
||
if format == RasterFormat::Psd {
|
||
return psd::export(self, doc, region, opts);
|
||
}
|
||
if opts.cmyk(format) {
|
||
let (w, h) = crate::region_pixels(region, opts.scale());
|
||
let inks = self.render_region_inks(doc, region, opts.scale(), &opts.render_options(format));
|
||
return match format {
|
||
RasterFormat::Tiff => tiff::encode_samples(&inks, w, h, opts.ppi, &opts.tiff),
|
||
_ => jpeg::encode(&inks, w, h, opts.quality, Some(opts.ppi), &opts.jpeg),
|
||
};
|
||
}
|
||
let img = self.render_region_with(doc, region, opts.scale(), &opts.render_options(format));
|
||
opts.encode(&img, format)
|
||
}
|
||
}
|
||
|
||
/// Baseline RGB JPEG at `quality` 1–100, partly transparent pixels flattened on white, with the
|
||
/// resolution `ppi` (if any) in the JFIF header and no colour profile.
|
||
pub(crate) fn jpeg(img: &Rendered, quality: u8, ppi: Option<f64>) -> Result<Vec<u8>, String> {
|
||
let o = jpeg::JpegOptions { embed_icc: false, ..Default::default() };
|
||
jpeg::encode(&jpeg::screen_pixels(img, false), img.width, img.height, quality, ppi, &o)
|
||
}
|
||
|
||
/// A straight-alpha pixel composited over white.
|
||
pub fn on_white(p: &[u8; 4]) -> [u8; 3] {
|
||
let a = u32::from(p[3]);
|
||
[0, 1, 2].map(|i| ((u32::from(p[i]) * a + 255 * (255 - a) + 127) / 255) as u8)
|
||
}
|
||
|
||
/// Lossless WebP.
|
||
pub(crate) fn webp(img: &Rendered) -> Result<Vec<u8>, String> {
|
||
let mut buf = Vec::new();
|
||
let enc = image::codecs::webp::WebPEncoder::new_lossless(&mut buf);
|
||
image::ImageEncoder::write_image(enc, &img.to_straight(), img.width, img.height, image::ExtendedColorType::Rgba8)
|
||
.map_err(|e| format!("WebP encoding failed: {e}"))?;
|
||
Ok(buf)
|
||
}
|
||
|
||
/// Bounds of the art an export draws: visible objects off template layers (guides left out),
|
||
/// with their strokes and live effects, symbol instances measured by their art. `None` when
|
||
/// nothing would be drawn.
|
||
pub fn art_bounds(doc: &Document) -> Option<Rect> {
|
||
let symbols = doc.symbol_boxes();
|
||
doc.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, drawn_bounds(&symbols, l)))
|
||
}
|
||
|
||
/// Bounds of the art an export draws ([`art_bounds`]) that is on artboard rectangle `rect`: each
|
||
/// object of a layer or sublayer whose drawn bounds overlap `rect`, with all of its bounds; a group
|
||
/// or a clipping layer counts as one object. `None` when no object overlaps `rect`.
|
||
pub fn art_bounds_on(doc: &Document, rect: Rect) -> Option<Rect> {
|
||
// Art flush with an edge from outside can overlap it by a rounding error (#1295).
|
||
let rect = rect.inflate(-1e-6, -1e-6);
|
||
fn on(symbols: &SymbolBoxes, n: &Node, rect: Rect) -> Option<Rect> {
|
||
match &n.kind {
|
||
NodeKind::Layer { template: false, clip: false, children, .. } if n.visible => {
|
||
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, on(symbols, c, rect)))
|
||
}
|
||
_ => drawn_bounds(symbols, n).filter(|b| b.x0 < rect.x1 && b.x1 > rect.x0 && b.y0 < rect.y1 && b.y1 > rect.y0),
|
||
}
|
||
}
|
||
let symbols = doc.symbol_boxes();
|
||
doc.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, on(&symbols, l, rect)))
|
||
}
|
||
|
||
fn drawn_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||
if !n.visible {
|
||
return None;
|
||
}
|
||
match &n.kind {
|
||
NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. } => None,
|
||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
|
||
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, drawn_bounds(symbols, c)))
|
||
}
|
||
_ if fx::has_fx(n) => fx::visual_bounds(symbols, n),
|
||
// An instance's box takes in its art, the effects in the art and its own effects.
|
||
NodeKind::SymbolInstance { .. } => fx::cull_bounds(symbols, n),
|
||
_ => n.visual_bounds_with(symbols),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|
||
#[cfg(test)]
|
||
mod tests_bmp;
|
||
#[cfg(test)]
|
||
mod tests_jpeg;
|
||
#[cfg(test)]
|
||
mod tests_palette;
|
||
#[cfg(test)]
|
||
mod tests_psd;
|
||
#[cfg(test)]
|
||
mod tests_tga;
|
||
#[cfg(test)]
|
||
mod tests_tiff;
|
||
#[cfg(test)]
|
||
mod tests_web;
|