mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 04:37:29 -06:00
Export: document.export takes a pixel width or height for raster files
PNG, PNG-8, JPEG, WebP, GIF, TIFF, BMP, Targa and PSD exports take `width` or `height` in pixels. Each file is scaled so that its own artboard, `rect` or art bounds is that wide or high, so with `useArtboards` every file gets its own scale. `width` wins over `height`, and either wins over `ppi` and `scale`, in the order an Export for Screens row reads its size. The scale stays within 0.01 to 64 pixels per point and the file stores the resolution that results. A value that is not a positive number is refused. The per-file scale comes from `ScreenSize::scale_for`, which Export for Screens rows already use. Rows keep their own size: their format settings still drop `width` and `height`. `document.formats` lists the two options for every raster format.
This commit is contained in:
@@ -17,6 +17,7 @@ use vectorcraft_render::encode::{RasterExportOptions, RasterFormat, bmp, psd, tg
|
||||
use super::super::{EngineError, Result, bad, color_value, parse_range};
|
||||
use super::Format;
|
||||
use super::imagemap::{self, MapKind};
|
||||
use super::screens::ScreenSize;
|
||||
|
||||
const C: &str = "document.export";
|
||||
|
||||
@@ -147,6 +148,10 @@ struct RasterOptions {
|
||||
scale: Option<f64>,
|
||||
/// Pixels per inch (wins over `scale`).
|
||||
ppi: Option<f64>,
|
||||
/// The width of each file in pixels (wins over `height`, `ppi` and `scale`).
|
||||
width: Option<f64>,
|
||||
/// The height of each file in pixels (wins over `ppi` and `scale`).
|
||||
height: Option<f64>,
|
||||
quality: Option<u8>,
|
||||
background: Option<Value>,
|
||||
anti_alias: Option<String>,
|
||||
@@ -179,7 +184,24 @@ struct RasterOptions {
|
||||
rich_black: Option<bool>,
|
||||
}
|
||||
|
||||
/// `ppi` with its scale kept within 0.01–64 pixels per point.
|
||||
fn clamped_ppi(ppi: f64) -> f64 {
|
||||
(ppi / 72.0).clamp(0.01, 64.0) * 72.0
|
||||
}
|
||||
|
||||
impl RasterOptions {
|
||||
/// The pixel size each file is scaled to: `width`, else `height`; `None` when neither is set.
|
||||
fn pixel_size(&self) -> Result<Option<ScreenSize>> {
|
||||
let checked = |key: &str, n: f64| {
|
||||
if n.is_finite() && n > 0.0 { Ok(n) } else { Err(bad(C, format!("{key} must be a positive number, not {n}"))) }
|
||||
};
|
||||
match (self.width, self.height) {
|
||||
(Some(w), _) => checked("width", w).map(|w| Some(ScreenSize::Width(w))),
|
||||
(None, Some(h)) => checked("height", h).map(|h| Some(ScreenSize::Height(h))),
|
||||
(None, None) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The render and encoder settings: `ppi`, else 72 × `scale`, within 0.01–64 pixels per point;
|
||||
/// `background`, else `page` (the document's background).
|
||||
fn settings(&self, page: Option<[u8; 3]>) -> Result<RasterExportOptions> {
|
||||
@@ -190,7 +212,7 @@ impl RasterOptions {
|
||||
let color_model = parse(self.color_model.as_deref(), jpeg::ColorModel::from_id, "colorModel", "rgb, cmyk or gray")?;
|
||||
let embed_icc = self.embed_icc.unwrap_or(true);
|
||||
Ok(RasterExportOptions {
|
||||
ppi: (ppi / 72.0).clamp(0.01, 64.0) * 72.0,
|
||||
ppi: clamped_ppi(ppi),
|
||||
background: match &self.background {
|
||||
Some(v) => background(v).map_err(|e| bad(C, e))?,
|
||||
None => page,
|
||||
@@ -519,8 +541,8 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
|
||||
// New Document → Background Contents: White makes the export opaque, unless `background`
|
||||
// says otherwise (JPEG has no alpha: white either way).
|
||||
let page = (doc.setup.background == vectorcraft_doc::Background::White).then_some([255; 3]);
|
||||
let settings = o.settings(page)?;
|
||||
let scale = settings.scale();
|
||||
let mut settings = o.settings(page)?;
|
||||
let size = o.pixel_size()?;
|
||||
let format = match f.id {
|
||||
"png" => RasterFormat::Png,
|
||||
"jpg" => RasterFormat::Jpeg,
|
||||
@@ -550,6 +572,11 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
vectorcraft_doc::cancel::progress(done as u64, total, &format!("Rendering artboard {} of {total}", done + 1));
|
||||
let region = doc.artboards.get(b).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))?.rect;
|
||||
// `width` or `height`: each file gets the scale that makes its region that size.
|
||||
if let Some(size) = size {
|
||||
settings.ppi = clamped_ppi(72.0 * size.scale_for(C, region)?);
|
||||
}
|
||||
let scale = settings.scale();
|
||||
check_format_size(f, region.width() * scale, region.height() * scale)?;
|
||||
vectorcraft_render::raster_size(region, scale).map_err(|e| bad(C, e))?;
|
||||
enc.maps.extend(imagemap::build(doc, region, scale, map, enc.files.len()));
|
||||
|
||||
@@ -75,6 +75,20 @@ pub(super) const RANGE: FormatOption = FormatOption {
|
||||
|
||||
const SCALE: FormatOption = FormatOption { name: "scale", ty: "number", default: "1", description: "pixels per point (0.01–64)" };
|
||||
|
||||
const WIDTH: FormatOption = FormatOption {
|
||||
name: "width",
|
||||
ty: "number",
|
||||
default: "null",
|
||||
description: "the width of each file in pixels: each artboard, rect or art bounds gets its own scale (0.01–64 pixels per point, stored in the file); wins over height, ppi and scale",
|
||||
};
|
||||
|
||||
const HEIGHT: FormatOption = FormatOption {
|
||||
name: "height",
|
||||
ty: "number",
|
||||
default: "null",
|
||||
description: "the height of each file in pixels, as width; wins over ppi and scale",
|
||||
};
|
||||
|
||||
const QUALITY: FormatOption =
|
||||
FormatOption { name: "quality", ty: "integer", default: "90", description: "JPEG quality 0–100 (the JPEG Options dialog shows 0–10)" };
|
||||
|
||||
@@ -198,6 +212,8 @@ const PALETTE_OPTIONS: &[FormatOption] = &[
|
||||
RECT,
|
||||
PPI,
|
||||
SCALE,
|
||||
WIDTH,
|
||||
HEIGHT,
|
||||
BACKGROUND,
|
||||
ANTI_ALIAS,
|
||||
INTERLACED,
|
||||
@@ -219,7 +235,7 @@ const BYTE_ORDER: FormatOption = FormatOption {
|
||||
};
|
||||
|
||||
const TIFF_OPTIONS: &[FormatOption] =
|
||||
&[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, COLOR_MODEL, LZW, BYTE_ORDER, EMBED_ICC];
|
||||
&[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, WIDTH, HEIGHT, BACKGROUND, ANTI_ALIAS, COLOR_MODEL, LZW, BYTE_ORDER, EMBED_ICC];
|
||||
|
||||
const BMP_COLOR_MODEL: FormatOption =
|
||||
FormatOption { name: "colorModel", ty: "string", default: "\"rgb\"", description: "rgb or gray (greys at every depth)" };
|
||||
@@ -256,6 +272,8 @@ const BMP_OPTIONS: &[FormatOption] = &[
|
||||
RECT,
|
||||
PPI,
|
||||
SCALE,
|
||||
WIDTH,
|
||||
HEIGHT,
|
||||
BACKGROUND,
|
||||
ANTI_ALIAS,
|
||||
BMP_COLOR_MODEL,
|
||||
@@ -275,7 +293,7 @@ const TGA_DEPTH: FormatOption = FormatOption {
|
||||
description: "bits per pixel: 16 (one-bit alpha), 24 (flattened on white) or 32 (keeps transparency)",
|
||||
};
|
||||
|
||||
const TGA_OPTIONS: &[FormatOption] = &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, TGA_DEPTH];
|
||||
const TGA_OPTIONS: &[FormatOption] = &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, WIDTH, HEIGHT, BACKGROUND, ANTI_ALIAS, TGA_DEPTH];
|
||||
|
||||
const PSD_LAYERS: FormatOption = FormatOption {
|
||||
name: "layers",
|
||||
@@ -306,6 +324,8 @@ const PSD_OPTIONS: &[FormatOption] = &[
|
||||
RECT,
|
||||
PPI,
|
||||
SCALE,
|
||||
WIDTH,
|
||||
HEIGHT,
|
||||
BACKGROUND,
|
||||
ANTI_ALIAS,
|
||||
COLOR_MODEL,
|
||||
@@ -374,7 +394,7 @@ pub const FORMATS: &[Format] = &[
|
||||
read: true,
|
||||
write: true,
|
||||
raster: true,
|
||||
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, INTERLACED, EMBED_ICC],
|
||||
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, WIDTH, HEIGHT, BACKGROUND, ANTI_ALIAS, INTERLACED, EMBED_ICC],
|
||||
},
|
||||
Format {
|
||||
id: "jpg",
|
||||
@@ -392,6 +412,8 @@ pub const FORMATS: &[Format] = &[
|
||||
RECT,
|
||||
PPI,
|
||||
SCALE,
|
||||
WIDTH,
|
||||
HEIGHT,
|
||||
BACKGROUND,
|
||||
ANTI_ALIAS,
|
||||
QUALITY,
|
||||
@@ -411,7 +433,7 @@ pub const FORMATS: &[Format] = &[
|
||||
read: true,
|
||||
write: true,
|
||||
raster: true,
|
||||
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, LOSSLESS, WEBP_QUALITY],
|
||||
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, WIDTH, HEIGHT, BACKGROUND, ANTI_ALIAS, LOSSLESS, WEBP_QUALITY],
|
||||
},
|
||||
Format {
|
||||
id: "tiff",
|
||||
|
||||
@@ -95,9 +95,9 @@ impl ScreenSize {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixels per point for `region`.
|
||||
fn scale_for(self, region: Rect) -> Result<f64> {
|
||||
let per = |px: f64, pt: f64| (pt > 0.0).then(|| px / pt).ok_or_else(|| bad(C, "an empty artboard has no width or height to scale to"));
|
||||
/// Pixels per point for `region` (`cmd` names the command in the error).
|
||||
pub(super) fn scale_for(self, cmd: &str, region: Rect) -> Result<f64> {
|
||||
let per = |px: f64, pt: f64| (pt > 0.0).then(|| px / pt).ok_or_else(|| bad(cmd, "an empty artboard has no width or height to scale to"));
|
||||
match self {
|
||||
Self::Width(w) => per(w, region.width()),
|
||||
Self::Height(h) => per(h, region.height()),
|
||||
@@ -264,7 +264,7 @@ impl Row {
|
||||
// The resolution is stored in the file too.
|
||||
ScreenSize::Ppi(ppi) => o["ppi"] = json!(ppi),
|
||||
size => {
|
||||
o["scale"] = json!(size.scale_for(region)?);
|
||||
o["scale"] = json!(size.scale_for(C, region)?);
|
||||
if let Some(m) = o.as_object_mut() {
|
||||
m.remove("ppi");
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ fn formats_list_the_raster_options() {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("document.formats", &json!({})).unwrap();
|
||||
let opts = |id: &str| r["formats"].as_array().unwrap().iter().find(|f| f["id"] == id).unwrap()["options"].clone();
|
||||
for k in ["ppi", "scale", "background", "antiAlias", "interlaced"] {
|
||||
for k in ["ppi", "scale", "width", "height", "background", "antiAlias", "interlaced"] {
|
||||
assert!(opts("png").get(k).is_some(), "png lists {k}");
|
||||
}
|
||||
assert_eq!(opts("png")["antiAlias"]["default"], "art");
|
||||
@@ -334,3 +334,64 @@ fn export_rect_refuses_what_cannot_be_exported() {
|
||||
assert!(s.execute("document.export", &p).is_err(), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_or_height_sets_the_pixel_size_of_a_raster_export() {
|
||||
let mut s = session(200.0, 100.0, 1);
|
||||
let size = |s: &mut Session, p: Value| rgba(&b64(&s.execute("document.export", &p).unwrap())).dimensions();
|
||||
assert_eq!(size(&mut s, json!({"format": "png", "width": 512})), (512, 256));
|
||||
assert_eq!(size(&mut s, json!({"format": "png", "height": 128})), (256, 128));
|
||||
assert_eq!(size(&mut s, json!({"format": "png", "width": 512, "height": 50})), (512, 256), "width wins over height");
|
||||
assert_eq!(size(&mut s, json!({"format": "png", "width": 512, "scale": 4, "ppi": 300})), (512, 256), "width wins over scale and ppi");
|
||||
assert_eq!(size(&mut s, json!({"format": "png", "height": 50, "ppi": 300})), (100, 50), "height wins over ppi");
|
||||
assert_eq!(size(&mut s, json!({"format": "webp", "width": 400, "height": null})), (400, 200), "a null height is no height");
|
||||
assert_eq!(size(&mut s, json!({"format": "png", "width": 1})), (2, 1), "the scale stays at 0.01 pixels per point or more");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_width_or_height_that_is_not_a_positive_number_is_refused() {
|
||||
let mut s = session(200.0, 100.0, 1);
|
||||
for (p, msg) in [
|
||||
(json!({"width": 0}), "width must be a positive number, not 0"),
|
||||
(json!({"width": -1}), "width must be a positive number, not -1"),
|
||||
(json!({"height": -2.5}), "height must be a positive number, not -2.5"),
|
||||
(json!({"width": -1, "height": 100}), "width must be a positive number, not -1"),
|
||||
] {
|
||||
let mut p = p.clone();
|
||||
p["format"] = json!("png");
|
||||
let e = s.execute("document.export", &p).unwrap_err().to_string();
|
||||
assert!(e.contains(msg), "{p}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_artboard_file_gets_its_own_scale_for_a_width() {
|
||||
let mut s = session(200.0, 100.0, 1);
|
||||
s.execute("artboard.new", &json!({"x": 300, "y": 0, "width": 100, "height": 400})).unwrap();
|
||||
let r = s.execute("document.export", &json!({"format": "png", "useArtboards": true, "width": 512})).unwrap();
|
||||
let sizes: Vec<(u32, u32)> = r["files"].as_array().unwrap().iter().map(|f| rgba(&b64(f)).dimensions()).collect();
|
||||
assert_eq!(sizes, [(512, 256), (512, 2048)], "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_sized_by_width_stores_the_resolution_that_results() {
|
||||
let mut s = session(200.0, 100.0, 1);
|
||||
// 512 px over 200 pt is 2.56 pixels per point: 184.32 ppi.
|
||||
for format in ["jpg", "png"] {
|
||||
let file = b64(&s.execute("document.export", &json!({"format": format, "width": 512})).unwrap());
|
||||
let (x, y) = super::ppi::resolution(&file).unwrap();
|
||||
assert!((x - 184.32).abs() < 0.6 && (y - 184.32).abs() < 0.6, "{format}: {x} × {y}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_for_screens_rows_keep_their_own_size() {
|
||||
let mut s = session(200.0, 100.0, 1);
|
||||
let r = s
|
||||
.execute(
|
||||
"document.exportForScreens",
|
||||
&json!({"formats": [{"format": "png", "scale": "512w"}], "settings": {"png": {"height": 10, "width": 30}}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(rgba(&b64(&r["files"][0])).dimensions(), (512, 256), "{r}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user