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:
dotCooCoo
2026-10-29 10:10:58 +07:00
committed by bflatastic
parent db982ad896
commit 530cca3322
6 changed files with 127 additions and 14 deletions
+30 -3
View File
@@ -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()));
+26 -4
View File
@@ -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",
+4 -4
View File
@@ -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");
}
+62 -1
View File
@@ -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}");
}