Merge pull request #90 from storytold/pr/m4-jpeg-png8-gif-webp

M4: JPEG Options with CMYK and ICC, PNG-8 and GIF, WebP options, Export As Text, Save for Office (M4.48–M4.50)
This commit is contained in:
BFlat
2026-10-05 11:40:06 +07:00
committed by GitHub
38 changed files with 2646 additions and 133 deletions
Generated
+8
View File
@@ -1885,6 +1885,12 @@ dependencies = [
"libc",
]
[[package]]
name = "jpeg-encoder"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b454d911ac55068f53495488d8ccd0646eaa540c033a28ee15b07838afafb01f"
[[package]]
name = "js-sys"
version = "0.3.106"
@@ -4238,7 +4244,9 @@ name = "vectorcraft-render"
version = "0.1.1"
dependencies = [
"flate2",
"gif",
"image",
"jpeg-encoder",
"log",
"proptest",
"serde_json",
+2
View File
@@ -48,6 +48,8 @@ skrifa = "0.47"
harfrust = "0.13"
linesweeper = "0.4"
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] }
gif = { version = "0.14", default-features = false, features = ["std", "raii_no_panic"] }
# ui
egui = "0.36"
+3
View File
@@ -23,3 +23,6 @@ list (author, source, license) is in ASSETS.md.
Rust dependencies (vello_cpu, kurbo, peniko, skrifa, harfrust, usvg, linesweeper, egui, eframe, …) are
fetched through crates.io under their own permissive licenses (mostly MIT and/or Apache-2.0; also
BSD, ISC, Zlib, BSL-1.0 and Unicode-3.0).
JPEG export uses the jpeg-encoder crate (MIT OR Apache-2.0, and the IJG License): this software is
based in part on the work of the Independent JPEG Group.
+118 -9
View File
@@ -1,10 +1,14 @@
//! ICC profiles through `moxcms` (pure Rust): built-in RGB spaces and user-supplied `.icc` files.
//! ICC profiles through `moxcms` (pure Rust): built-in RGB spaces and user-supplied `.icc` files,
//! and the profiles exports embed ([`encode_builtin`]), written in code from the CMS.
use std::sync::{Arc, OnceLock};
use moxcms::{ColorProfile, DataColorSpace, Layout, ProfileText, RenderingIntent, TransformF32Executor, TransformOptions};
use moxcms::{
ColorProfile, DataColorSpace, Layout, LocalizableString, LutMultidimensionalType, LutStore, LutWarehouse, Matrix3d, ProfileClass, ProfileText,
RenderingIntent, ToneReprCurve, TransformF32Executor, TransformOptions, Vector3d,
};
use super::{CmsError, Intent, ProfileKind};
use super::{CmsError, Intent, ProfileKind, lab};
fn moxcms_intent(i: Intent) -> RenderingIntent {
match i {
@@ -27,6 +31,8 @@ type Xf = Option<Arc<TransformF32Executor>>;
pub struct IccProfile {
pub name: String,
pub kind: ProfileKind,
/// The file it was loaded from (empty for profiles made in code).
source: Vec<u8>,
profile: ColorProfile,
srgb: ColorProfile,
to_srgb: [OnceLock<Xf>; 4],
@@ -58,12 +64,25 @@ impl IccProfile {
other => return Err(CmsError::Unsupported(format!("profile colour space {other:?} is not RGB, CMYK or Gray"))),
};
let name = name.or_else(|| text(&profile.description)).unwrap_or_else(|| format!("Untitled {kind:?} profile"));
Ok(Self { name, kind, profile, srgb: ColorProfile::new_srgb(), to_srgb: Default::default(), from_srgb: Default::default() })
Ok(Self {
name,
kind,
source: Vec::new(),
profile,
srgb: ColorProfile::new_srgb(),
to_srgb: Default::default(),
from_srgb: Default::default(),
})
}
pub fn from_bytes(name: Option<String>, bytes: &[u8]) -> Result<Self, CmsError> {
let p = ColorProfile::new_from_slice(bytes).map_err(|e| CmsError::BadProfile(format!("{e:?}")))?;
Self::from_profile(name, p)
Ok(Self { source: bytes.to_vec(), ..Self::from_profile(name, p)? })
}
/// The profile as an ICC file: the bytes it was loaded from, else encoded.
pub fn bytes(&self) -> Result<Vec<u8>, CmsError> {
if self.source.is_empty() { encode(&self.profile) } else { Ok(self.source.clone()) }
}
fn layout(&self) -> Layout {
@@ -122,14 +141,104 @@ impl IccProfile {
}
}
/// Built-in RGB working spaces from `moxcms`'s standard primaries.
pub fn builtin_rgb(name: &str) -> Option<IccProfile> {
let p = match name {
/// The primaries and tone curves of built-in RGB space `name`.
fn rgb_space(name: &str) -> Option<ColorProfile> {
Some(match name {
super::WIDE_GAMUT_RGB => ColorProfile::new_adobe_rgb(),
super::DISPLAY_P3 => ColorProfile::new_display_p3(),
super::PROPHOTO_RGB => ColorProfile::new_pro_photo_rgb(),
super::SRGB => ColorProfile::new_srgb(),
_ => return None,
})
}
/// Built-in RGB working spaces from `moxcms`'s standard primaries.
pub fn builtin_rgb(name: &str) -> Option<IccProfile> {
IccProfile::from_profile(Some(name.to_string()), rgb_space(name)?).ok()
}
/// Built-in RGB space `name` as an ICC file, under its name here.
pub(super) fn builtin_rgb_bytes(name: &str) -> Result<Vec<u8>, CmsError> {
encode(&labelled(rgb_space(name).ok_or_else(|| CmsError::UnknownProfile(name.into()))?, name))
}
/// Encode `p` as an ICC file. The creation date is fixed so exports are reproducible.
pub(super) fn encode(p: &ColorProfile) -> Result<Vec<u8>, CmsError> {
let mut bytes = p.encode().map_err(|e| CmsError::BadProfile(format!("{e:?}")))?;
// Header bytes 24..36: year, month, day, hours, minutes, seconds (u16 each).
if let Some(date) = bytes.get_mut(24..36) {
date.copy_from_slice(&[2026u16, 1, 1, 0, 0, 0].map(u16::to_be_bytes).concat());
}
Ok(bytes)
}
fn text_tag(s: &str) -> Option<ProfileText> {
Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), s.into())]))
}
/// Profiles made here carry their name and no copyright claim.
fn labelled(mut p: ColorProfile, name: &str) -> ColorProfile {
p.description = text_tag(name);
p.copyright = text_tag("No copyright, use freely");
p
}
/// Grid points per input channel of the CMYK → Lab table and of the Lab → CMYK table.
const A2B_GRID: usize = 9;
const B2A_GRID: usize = 17;
/// A CMYK output profile sampled from a conversion: `to_lab` (CMYK → media-relative Lab) in a
/// 9⁴ table, `from_lab` (Lab → CMYK, gamut-mapped) in a 17³ table, Lab PCS (ICC v4 encoding).
pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, from_lab: impl Fn(lab::Lab) -> [f32; 4]) -> ColorProfile {
let unit = |i: usize, n: usize| i as f32 / (n - 1) as f32;
let q = |v: f32| (v.clamp(0.0, 1.0) * 65535.0).round() as u16;
let mut a2b = Vec::with_capacity(A2B_GRID.pow(4) * 3);
for i in 0..A2B_GRID.pow(4) {
let cmyk = [3, 2, 1, 0].map(|d| unit(i / A2B_GRID.pow(d) % A2B_GRID, A2B_GRID));
let l = to_lab(cmyk);
a2b.extend([l.l / 100.0, (l.a + 128.0) / 255.0, (l.b + 128.0) / 255.0].map(q));
}
let mut b2a = Vec::with_capacity(B2A_GRID.pow(3) * 4);
for i in 0..B2A_GRID.pow(3) {
let [l, a, b] = [2, 1, 0].map(|d| unit(i / B2A_GRID.pow(d) % B2A_GRID, B2A_GRID));
b2a.extend(from_lab(lab::Lab::new(l * 100.0, a * 255.0 - 128.0, b * 255.0 - 128.0)).map(q));
}
let identity = |n: usize| vec![ToneReprCurve::Parametric(vec![1.0]); n];
let table = |inputs: usize, outputs: usize, grid: usize, clut: Vec<u16>| {
let mut grid_points = [0u8; 16];
grid_points[..inputs].fill(grid as u8);
LutWarehouse::Multidimensional(LutMultidimensionalType {
num_input_channels: inputs as u8,
num_output_channels: outputs as u8,
grid_points,
clut: Some(LutStore::Store16(clut)),
// A curves sit on the device side, B curves on the PCS side.
a_curves: identity(4),
b_curves: identity(3),
m_curves: vec![],
matrix: Matrix3d::IDENTITY,
bias: Vector3d::default(),
})
};
IccProfile::from_profile(Some(name.to_string()), p).ok()
let a2b = table(4, 3, A2B_GRID, a2b);
let b2a = table(3, 4, B2A_GRID, b2a);
let mut p = ColorProfile::default();
p.profile_class = ProfileClass::OutputDevice;
p.color_space = DataColorSpace::Cmyk;
p.pcs = DataColorSpace::Lab;
p.rendering_intent = RenderingIntent::RelativeColorimetric;
p.white_point = moxcms::WHITE_POINT_D50.to_xyzd();
p.media_white_point = Some(p.white_point);
p.lut_a_to_b_perceptual = Some(a2b.clone());
p.lut_a_to_b_colorimetric = Some(a2b);
p.lut_b_to_a_perceptual = Some(b2a.clone());
p.lut_b_to_a_colorimetric = Some(b2a);
labelled(p, name)
}
/// The grey space of greyscale exports: sRGB's tone curve, D50 white.
pub(super) fn gray_profile(name: &str) -> ColorProfile {
let mut p = ColorProfile::new_gray_with_gamma(2.2);
p.gray_trc = ColorProfile::new_srgb().red_trc;
labelled(p, name)
}
+31
View File
@@ -33,6 +33,8 @@ pub const PROPHOTO_RGB: &str = "ProPhoto RGB";
pub const GENERIC_CMYK: &str = "VectorCraft Generic CMYK (SWOP-like)";
/// Profile-free CMYK (`rgb = (1−c)(1−k)` …): uncalibrated, kept for legacy numbers.
pub const DEVICE_CMYK: &str = "Device CMYK (uncalibrated)";
/// The grey space greyscale exports are written in: sRGB's tone curve (a grey shows as on screen).
pub const GRAY: &str = "Gray (sRGB tone curve)";
/// Built-in RGB and CMYK working spaces, in menu order.
const BUILTIN_RGB: [&str; 4] = [SRGB, WIDE_GAMUT_RGB, DISPLAY_P3, PROPHOTO_RGB];
@@ -252,6 +254,33 @@ pub fn register_icc(bytes: &[u8], name: Option<String>) -> Result<ProfileInfo, C
Ok(info)
}
/// Profile `name` as an ICC file to embed in an export: any RGB or CMYK profile of [`profiles`]
/// (by its current or legacy name), or [`GRAY`]. Built-in profiles are written in code from the
/// CMS: the RGB spaces from their primaries, the CMYK spaces as lookup tables sampled from their
/// model (both directions, perceptual and relative colorimetric, Lab PCS); a profile the user
/// loaded is the file it came from. Cached.
pub fn icc_bytes(name: &str) -> Result<Arc<[u8]>, CmsError> {
static CACHE: Mutex<Vec<(String, Arc<[u8]>)>> = Mutex::new(Vec::new());
let name = canonical_name(name);
if let Some(p) = user_profile(name) {
return p.bytes().map(Arc::from);
}
if let Some((_, b)) = CACHE.lock().unwrap_or_else(|e| e.into_inner()).iter().find(|(n, _)| n == name) {
return Ok(b.clone());
}
let bytes: Arc<[u8]> = if name == GRAY {
icc::encode(&icc::gray_profile(name))?
} else if BUILTIN_CMYK.contains(&name) {
let cms = Cms::new(&ColorSettings { cmyk: name.into(), ..ColorSettings::default() })?;
icc::encode(&icc::cmyk_profile(name, |c| cms.cmyk_to_lab(c), |l| cms.lab_to_cmyk(l, Intent::RelativeColorimetric)))?
} else {
icc::builtin_rgb_bytes(name)?
}
.into();
CACHE.lock().unwrap_or_else(|e| e.into_inner()).push((name.to_string(), bytes.clone()));
Ok(bytes)
}
/// Load a `.icc`/`.icm` file from disk (native only).
#[cfg(not(target_arch = "wasm32"))]
pub fn load_icc_file(path: &std::path::Path) -> Result<ProfileInfo, CmsError> {
@@ -671,3 +700,5 @@ pub(crate) fn cmyk_is_device() -> bool {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_icc;
+62
View File
@@ -0,0 +1,62 @@
use super::*;
fn parse(bytes: &[u8]) -> moxcms::ColorProfile {
moxcms::ColorProfile::new_from_slice(bytes).unwrap()
}
#[test]
fn builtin_rgb_spaces_encode_under_their_names() {
for name in BUILTIN_RGB {
let p = IccProfile::from_bytes(None, &icc_bytes(name).unwrap()).unwrap();
assert_eq!((p.name.as_str(), p.kind), (name, ProfileKind::Rgb));
}
// Legacy names find today's profile.
assert_eq!(icc_bytes(LEGACY_NAMES[0].0).unwrap(), icc_bytes(WIDE_GAMUT_RGB).unwrap());
assert!(matches!(icc_bytes("No Such Profile"), Err(CmsError::UnknownProfile(_))));
}
#[test]
fn cmyk_profiles_convert_like_the_cms() {
for name in BUILTIN_CMYK {
let bytes = icc_bytes(name).unwrap();
assert_eq!(parse(&bytes).color_space, moxcms::DataColorSpace::Cmyk);
let p = IccProfile::from_bytes(None, &bytes).unwrap();
assert_eq!((p.name.as_str(), p.kind), (name, ProfileKind::Cmyk));
let cms = Cms::new(&ColorSettings { cmyk: name.into(), ..Default::default() }).unwrap();
for cmyk in [[0.0; 4], [1.0, 0.0, 0.0, 0.0], [0.2, 0.7, 0.1, 0.3], [0.0, 0.0, 0.0, 1.0], [0.5, 0.5, 0.5, 0.0]] {
let want = cms.cmyk_to_srgb(cmyk, false);
let got = p.to_srgb(&cmyk, Intent::RelativeColorimetric).unwrap();
let de = delta_e2000(lab::srgb_to_lab(want), lab::srgb_to_lab(got));
assert!(de < 2.5, "{name} {cmyk:?}: {want:?} vs {got:?} (ΔE {de})");
}
// And back: an in-gamut colour separates to about the same inks.
let srgb = cms.cmyk_to_srgb([0.1, 0.6, 0.8, 0.0], false);
let back = p.from_srgb(srgb, Intent::RelativeColorimetric).unwrap();
let again = cms.cmyk_to_srgb([back[0], back[1], back[2], back[3]], false);
assert!(delta_e2000(lab::srgb_to_lab(srgb), lab::srgb_to_lab(again)) < 3.0, "{back:?}");
}
}
#[test]
fn gray_is_srgb_toned() {
let p = IccProfile::from_bytes(None, &icc_bytes(GRAY).unwrap()).unwrap();
assert_eq!((p.name.as_str(), p.kind), (GRAY, ProfileKind::Gray));
for v in [0.0f32, 0.25, 0.5, 1.0] {
let got = p.to_srgb(&[v], Intent::RelativeColorimetric).unwrap();
assert!(got.iter().all(|c| (c - v).abs() < 0.01), "{v} → {got:?}");
}
}
#[test]
fn encoded_profiles_are_reproducible() {
assert_eq!(icc::builtin_rgb_bytes(DISPLAY_P3).unwrap(), icc::builtin_rgb_bytes(DISPLAY_P3).unwrap());
}
#[test]
fn a_loaded_profile_is_embedded_as_loaded() {
let mut bytes = icc::builtin_rgb_bytes(PROPHOTO_RGB).unwrap();
// A byte the parser ignores (in the reserved header area) marks the file as this one.
bytes[100] = 7;
register_icc(&bytes, Some("Embedded As Loaded RGB".into())).unwrap();
assert_eq!(&*icc_bytes("Embedded As Loaded RGB").unwrap(), &bytes[..]);
}
+79 -11
View File
@@ -1,16 +1,21 @@
//! One encoder per writable format. Each format's options are parsed, typed, from the export params
//! (unknown keys are ignored, so one params object can carry the options of several formats).
use std::borrow::Cow;
use serde::Deserialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use vectorcraft_doc::{Artboard, Document};
use vectorcraft_geom::Rect;
use vectorcraft_render::AntiAlias;
use vectorcraft_render::encode::jpeg::{self, JpegOptions};
use vectorcraft_render::encode::quantize::{Dither, PaletteOptions, Reduction};
use vectorcraft_render::encode::{RasterExportOptions, RasterFormat};
use super::super::*;
use super::Format;
use super::imagemap::{self, MapKind};
const C: &str = "document.export";
@@ -65,6 +70,8 @@ pub struct Encoded {
pub files: Vec<(Option<usize>, Vec<u8>)>,
/// Images to write next to the file(s) (SVG `images: "link"`).
pub linked: Vec<vectorcraft_svg::LinkedImage>,
/// Image maps, each written beside its image under the image's name (raster `imageMap`).
pub maps: Vec<imagemap::Map>,
/// The encoder's warnings (features approximated or left out, options not applied yet).
pub warnings: Vec<String>,
}
@@ -76,13 +83,13 @@ impl Encoded {
/// Every file to write for the destination `path` (a path or a file name): the file itself
/// when there is one, else `{stem}-{artboard name}.{ext}` beside it, then the linked images
/// beside it under their own names.
pub fn named<'a>(&'a self, doc: &Document, path: &str) -> Vec<(String, &'a [u8])> {
/// beside it under their own names, then the image maps as `{image stem}.html` or `.map`.
pub fn named<'a>(&'a self, doc: &Document, path: &str) -> Vec<(String, Cow<'a, [u8]>)> {
// Siblings keep the path's own separators (this runs on every platform and on the web).
let dir = &path[..path.rfind(['/', '\\']).map_or(0, |i| i + 1)];
let sibling = |name: &str| format!("{dir}{name}");
let mut out: Vec<(String, &[u8])> = match self.files.as_slice() {
[(_, bytes)] => vec![(path.to_string(), bytes.as_slice())],
let mut out: Vec<(String, Cow<[u8]>)> = match self.files.as_slice() {
[(_, bytes)] => vec![(path.to_string(), Cow::Borrowed(bytes.as_slice()))],
files => {
let boards: Vec<usize> = files.iter().map(|(b, _)| b.unwrap_or(0)).collect();
let file = std::path::Path::new(&path[dir.len()..]);
@@ -91,18 +98,28 @@ impl Encoded {
super::artboard_file_names(doc, &boards)
.into_iter()
.zip(files)
.map(|(board, (_, bytes))| (sibling(&format!("{stem}-{board}{ext}")), bytes.as_slice()))
.map(|(board, (_, bytes))| (sibling(&format!("{stem}-{board}{ext}")), Cow::Borrowed(bytes.as_slice())))
.collect()
}
};
out.extend(self.linked.iter().map(|l| (sibling(&l.name), l.bytes.as_slice())));
let maps: Vec<(String, Cow<[u8]>)> = self
.maps
.iter()
.filter_map(|m| {
let image = std::path::Path::new(&out.get(m.file)?.0);
let name = image.file_name()?.to_string_lossy();
Some((image.with_extension(m.kind.ext()).to_string_lossy().into_owned(), Cow::Owned(m.text(&name).into_bytes())))
})
.collect();
out.extend(self.linked.iter().map(|l| (sibling(&l.name), Cow::Borrowed(l.bytes.as_slice()))));
out.extend(maps);
out
}
/// The one file and the warnings, when the export wrote nothing else.
fn single(self, f: &Format) -> Result<(Vec<u8>, Vec<String>)> {
if !self.linked.is_empty() {
return Err(bad(C, format!("{} with linked images writes several files: use document.export or document.save", f.label)));
if !self.linked.is_empty() || !self.maps.is_empty() {
return Err(bad(C, format!("{} with linked images or an image map writes several files: use document.export", f.label)));
}
match <[_; 1]>::try_from(self.files) {
Ok([(_, bytes)]) => Ok((bytes, self.warnings)),
@@ -123,6 +140,18 @@ struct RasterOptions {
background: Option<Value>,
anti_alias: Option<String>,
interlaced: Option<bool>,
color_model: Option<String>,
method: Option<String>,
scans: Option<u8>,
embed_icc: Option<bool>,
image_map: Option<String>,
colors: Option<u16>,
reduction: Option<String>,
dither: Option<String>,
dither_amount: Option<u8>,
transparency: Option<bool>,
matte: Option<Value>,
lossless: Option<bool>,
}
impl RasterOptions {
@@ -141,11 +170,38 @@ impl RasterOptions {
},
anti_alias: self.anti_alias.as_deref().map(anti_alias).transpose().map_err(|e| bad(C, e))?.unwrap_or_default(),
interlaced: self.interlaced.unwrap_or(false),
quality: self.quality.unwrap_or(90),
quality: self.quality.unwrap_or(90).min(100),
jpeg: JpegOptions {
color_model: parse(self.color_model.as_deref(), jpeg::ColorModel::from_id, "colorModel", "rgb, cmyk or gray")?,
method: parse(self.method.as_deref(), jpeg::Method::from_id, "method", "baseline, optimized or progressive")?,
scans: self.scans.unwrap_or(3).clamp(*jpeg::SCANS.start(), *jpeg::SCANS.end()),
embed_icc: self.embed_icc.unwrap_or(true),
},
palette: PaletteOptions {
colors: self.colors.unwrap_or(256).clamp(2, 256),
reduction: parse(
self.reduction.as_deref(),
Reduction::from_id,
"reduction",
"perceptual, selective, adaptive, web, blackWhite or gray",
)?,
dither: parse(self.dither.as_deref(), Dither::from_id, "dither", "none, diffusion, pattern or noise")?,
dither_amount: self.dither_amount.unwrap_or(100).min(100),
transparency: self.transparency.unwrap_or(true),
matte: match &self.matte {
Some(v) => background(v).map_err(|e| bad(C, format!("matte: {e}")))?,
None => Some([255; 3]),
},
},
})
}
}
/// An enum option by id (its default when absent).
fn parse<T: Default>(v: Option<&str>, from_id: fn(&str) -> Option<T>, name: &str, ids: &str) -> Result<T> {
v.map(|s| from_id(s).ok_or_else(|| bad(C, format!("{name} `{s}`: {ids}")))).transpose().map(Option::unwrap_or_default)
}
/// A raster background: `transparent` (also `none` or null), `white`, `black` or a colour
/// (`"#rrggbb"`, `[r, g, b]` 0–1, `{c, m, y, k}`, `{gray}`) → opaque RGB, `None` = transparent.
pub(crate) fn background(v: &Value) -> std::result::Result<Option<[u8; 3]>, String> {
@@ -254,12 +310,13 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
d.template = true;
vectorcraft_format::save_file(&d)
}
"txt" => super::text::encode(doc, p)?,
"svg" | "svgz" => return super::svg::encode(doc, p, f.id == "svgz").map_err(|e| bad(C, e)),
"pdf" => {
let (bytes, warnings) = super::pdf::encode(C, doc, p)?;
return Ok(Encoded { warnings, ..Encoded::one(bytes) });
}
"png" | "jpg" | "webp" => {
"png" | "jpg" | "webp" | "gif" | "png8" => {
let o: RasterOptions = options(f, p)?;
// New Document → Background Contents: White makes the export opaque, unless `background`
// says otherwise (JPEG has no alpha: white either way).
@@ -269,6 +326,8 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
let format = match f.id {
"png" => RasterFormat::Png,
"jpg" => RasterFormat::Jpeg,
"gif" => RasterFormat::Gif,
"png8" => RasterFormat::Png8,
_ => RasterFormat::WebP,
};
// Use Artboards: one file per chosen artboard (default all); else the one chosen.
@@ -276,15 +335,21 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
Some(true) => boards(o.boards.resolve(n))?.unwrap_or_else(|| (0..n).collect()),
_ => vec![boards(o.boards.one(n))?],
};
// JPEG Options → Image Map.
let map = match (format, o.image_map.as_deref()) {
(RasterFormat::Jpeg, Some(m)) => MapKind::from_id(m).ok_or_else(|| bad(C, format!("imageMap `{m}`: none, client or server")))?,
_ => MapKind::None,
};
let mut enc = Encoded::default();
let mut renderer = vectorcraft_render::Renderer::new();
for b in chosen {
let region = doc.artboards.get(b).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))?.rect;
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()));
let bytes = renderer.export_region(doc, region, format, &settings).map_err(EngineError::Other)?;
// File Info as PNG text chunks.
let bytes = if format == RasterFormat::Png {
let bytes = if matches!(format, RasterFormat::Png | RasterFormat::Png8) {
vectorcraft_render::encode::png::with_text(bytes, &doc.metadata.png_text(&doc.title))
} else {
bytes
@@ -294,6 +359,9 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
if enc.files.is_empty() {
return Err(bad(C, "the document has no artboard"));
}
if format == RasterFormat::WebP && o.lossless == Some(false) {
enc.warnings.push("lossy WebP isn't available yet: the file is lossless (exact, but larger)".into());
}
return Ok(enc);
}
_ => return Err(bad(C, format!("no encoder for {} yet", f.label))),
+56 -18
View File
@@ -1,11 +1,12 @@
//! `document.export`, `document.serialize`, `document.exportSelection`, `document.exportForScreens`.
use std::borrow::Cow;
use std::collections::HashSet;
use std::sync::Arc;
use serde::Deserialize;
use serde_json::{Value, json};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_doc::{Document, Node, NodeKind};
use super::super::*;
use super::encode::single_artboard;
@@ -18,7 +19,7 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?;
let expanded = super::pdf::expand_preset(s, "document.serialize", p)?;
let p = &*expanded;
let doc = &s.doc()?.doc;
let doc = &*source(s, f, p, "document.serialize")?;
let enc = encode_all(doc, f.id, p)?;
let files = enc.named(doc, &default_name(doc, f.extensions[0]));
let (main, linked) = files.split_at(enc.files.len());
@@ -27,7 +28,7 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
"svg" => json!({ "text": String::from_utf8_lossy(bytes) }),
_ => json!({ "dataBase64": vectorcraft_format::base64_encode(bytes) }),
};
let mut out = merge(data(main.first().map_or(&[][..], |m| m.1)), json!({ "warnings": enc.warnings }));
let mut out = merge(data(main.first().map_or(&[][..], |m| &m.1)), json!({ "warnings": enc.warnings }));
if main.len() > 1 {
out["files"] = main.iter().map(|(name, bytes)| merge(json!({ "name": name }), data(bytes))).collect();
}
@@ -42,7 +43,7 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
let f = writable("document.export", str_param(p, "format"), path)?;
let expanded = super::pdf::expand_preset(s, "document.export", p)?;
let p = &*expanded;
let doc = &s.doc()?.doc;
let doc = &*source(s, f, p, "document.export")?;
let enc = encode_all(doc, f.id, p)?;
write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.warnings }))
}
@@ -61,6 +62,34 @@ pub(super) fn without_artboards(p: &Value) -> Value {
q
}
/// The document an export of `f` writes: the active one, or for text with `selectionOnly` the
/// selected objects alone.
fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<Cow<'a, Document>> {
if f.id == "txt" && bool_or(p, "selectionOnly", false) {
return Ok(Cow::Owned(selection(s, cmd)?.0));
}
Ok(Cow::Borrowed(&s.doc()?.doc))
}
/// The selected objects (not on template layers) alone on one layer, with one artboard: their
/// visual bounds.
fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom::Rect)> {
let ids = edit::selected_roots(s)?;
let st = s.doc()?;
let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. }));
// On a template layer or sublayer, at any depth.
let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template));
let nodes: Vec<Arc<Node>> = ids.into_iter().filter(|id| !on_template(*id)).filter_map(|id| st.doc.node(id).cloned().map(Arc::new)).collect();
let bounds = nodes.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)).ok_or_else(|| bad(cmd, "select something to export"))?;
let mut d = single_artboard(&st.doc, bounds, "Selection");
let mut layer = Node::layer(d.alloc_id(), "Selection", vectorcraft_doc::LayerColor::Preset(0));
if let Some(ch) = layer.children_mut() {
*ch = nodes;
}
d.layers = vec![Arc::new(layer)];
Ok((d, bounds))
}
/// File → Export Selection: the selected objects alone, cropped to their visual bounds. Objects on
/// template layers are guides, not artwork, and are left out.
pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
@@ -69,19 +98,7 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
let f = writable(C, str_param(p, "format"), path)?;
let expanded = super::pdf::expand_preset(s, C, p)?;
let p = &*expanded;
let ids = edit::selected_roots(s)?;
let st = s.doc()?;
let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. }));
// On a template layer or sublayer, at any depth.
let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template));
let nodes: Vec<Arc<Node>> = ids.into_iter().filter(|id| !on_template(*id)).filter_map(|id| st.doc.node(id).cloned().map(Arc::new)).collect();
let bounds = nodes.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)).ok_or_else(|| bad(C, "select something to export"))?;
let mut d = single_artboard(&st.doc, bounds, "Selection");
let mut layer = Node::layer(d.alloc_id(), "Selection", vectorcraft_doc::LayerColor::Preset(0));
if let Some(ch) = layer.children_mut() {
*ch = nodes;
}
d.layers = vec![Arc::new(layer)];
let (d, bounds) = selection(s, C)?;
let bytes = encode(&d, f.id, &without_artboards(p))?;
write_or_return(path, &bytes, json!({ "bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()] }))
}
@@ -101,7 +118,7 @@ fn screen_format(row: &Value) -> Result<ScreenFormat> {
}
let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?;
if matches!(format.id, "vectorcraft" | "template") {
return Err(bad(C, "Export for Screens writes png, jpg, webp, svg, svgz or pdf"));
return Err(bad(C, "Export for Screens writes png, jpg, webp, gif, png8, svg, svgz or pdf"));
}
// Vector formats have no pixel size: scale doesn't apply and adds no @Nx suffix (not even one
// left over from a raster row switched to SVG or PDF).
@@ -179,3 +196,24 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(json!({ "files": files }))
}
/// File → Save for Office Documents: one artboard as a PNG at `ppi`, on white unless
/// `transparent`.
pub(super) fn export_for_office(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.exportForOffice";
let path = str_param(p, "path");
let ppi = f64_or(p, "ppi", 150.0);
let doc = &s.doc()?.doc;
let artboard = match p.get("artboard") {
None | Some(Value::Null) => 0,
Some(v) => v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(C, "artboard is a 0-based index"))?,
};
if artboard >= doc.artboards.len() {
return Err(bad(C, format!("no artboard {artboard} (0-based; the document has {})", doc.artboards.len())));
}
let background = if bool_or(p, "transparent", false) { "transparent" } else { "white" };
let bytes = encode(doc, "png", &json!({ "ppi": ppi, "background": background, "artboard": artboard }))?;
// The pixel size, from the PNG header.
let side = |at: usize| bytes.get(at..at + 4).and_then(|b| b.try_into().ok()).map_or(0, u32::from_be_bytes);
write_or_return(path, &bytes, json!({ "width": side(16), "height": side(20) }))
}
+176
View File
@@ -0,0 +1,176 @@
//! Image maps of raster exports (JPEG Options → Image Map): the objects with a URL (Attributes
//! panel) as clickable areas of the exported image, written beside it as a client-side map (an
//! HTML page with `<map>`/`<area>`) or a server-side map (an NCSA `.map` file).
use std::fmt::Write as _;
use kurbo::{PathEl, Shape};
use vectorcraft_doc::{Document, ImageMap, Node, NodeKind};
use vectorcraft_geom::Rect;
/// Where the image map goes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum MapKind {
#[default]
None,
/// An HTML page showing the image with a `<map>` (`.html`).
Client,
/// An NCSA map file for the web server (`.map`).
Server,
}
impl MapKind {
pub fn from_id(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"none" => Some(MapKind::None),
"client" | "clientside" | "client-side" => Some(MapKind::Client),
"server" | "serverside" | "server-side" => Some(MapKind::Server),
_ => None,
}
}
pub fn ext(self) -> &'static str {
match self {
MapKind::Server => "map",
_ => "html",
}
}
}
/// The shape of a clickable area, in image pixels.
#[derive(Clone, Debug, PartialEq)]
enum Outline {
Rect([i64; 4]),
Poly(Vec<[i64; 2]>),
}
#[derive(Clone, Debug, PartialEq)]
struct Area {
shape: Outline,
href: String,
alt: String,
}
/// The image map of one exported image, written next to it.
#[derive(Clone, Debug, PartialEq)]
pub struct Map {
pub kind: MapKind,
/// The image it belongs to (an index into the export's files).
pub file: usize,
width: u32,
height: u32,
/// Topmost first: the first area under the pointer wins.
areas: Vec<Area>,
}
/// The areas of the visible objects of `doc` with a URL and an Image Map shape that `region`
/// (exported at `scale` pixels per point) shows. `None` when there are none.
pub fn build(doc: &Document, region: Rect, scale: f64, kind: MapKind, file: usize) -> Option<Map> {
if kind == MapKind::None {
return None;
}
let mut areas = vec![];
for l in &doc.layers {
collect(l, region, scale, &mut areas);
}
areas.reverse();
let (width, height) = vectorcraft_render::region_pixels(region, scale);
(!areas.is_empty()).then_some(Map { kind, file, width, height, areas })
}
fn collect(n: &Node, region: Rect, scale: f64, out: &mut Vec<Area>) {
if !n.visible || matches!(n.kind, NodeKind::Layer { template: true, .. }) {
return;
}
let shape = n.attrs.as_deref().filter(|a| !a.url.is_empty()).map(|a| a.image_map).unwrap_or_default();
let px = |x: f64, y: f64| [((x - region.x0) * scale).round() as i64, ((y - region.y0) * scale).round() as i64];
if let (Some(href), Some(b)) = (n.url(), n.visual_bounds().filter(|b| b.overlaps(region)))
&& shape != ImageMap::None
{
let outline = match (&n.kind, shape) {
(NodeKind::Path { path, .. }, ImageMap::Polygon) => {
polygon(&path.to_bezpath(), 0.5 / scale).map(|pts| pts.into_iter().map(|p| px(p.x, p.y)).collect())
}
_ => None,
};
let shape = match outline {
Some(pts) => Outline::Poly(pts),
None => {
let ([x0, y0], [x1, y1]) = (px(b.x0, b.y0), px(b.x1, b.y1));
Outline::Rect([x0, y0, x1, y1])
}
};
out.push(Area { shape, href: href.to_string(), alt: n.name.clone().unwrap_or_default() });
}
for c in n.children().into_iter().flatten() {
collect(c, region, scale, out);
}
}
/// The first subpath of `path` as straight segments within `tolerance` (at least three points).
fn polygon(path: &kurbo::BezPath, tolerance: f64) -> Option<Vec<kurbo::Point>> {
let mut pts = vec![];
let mut done = false;
kurbo::flatten(path.path_elements(tolerance), tolerance, |el| match el {
_ if done => {}
PathEl::MoveTo(p) if pts.is_empty() => pts.push(p),
PathEl::LineTo(p) => pts.push(p),
PathEl::ClosePath | PathEl::MoveTo(_) => done = true,
_ => {}
});
pts.dedup();
if pts.len() > 1 && pts.first() == pts.last() {
pts.pop();
}
(pts.len() >= 3).then_some(pts)
}
/// Escape text for an HTML attribute.
fn attr(s: &str) -> String {
s.chars().filter(|c| !c.is_control()).fold(String::with_capacity(s.len()), |mut o, c| {
match c {
'&' => o.push_str("&amp;"),
'"' => o.push_str("&quot;"),
'<' => o.push_str("&lt;"),
'>' => o.push_str("&gt;"),
c => o.push(c),
}
o
})
}
impl Map {
/// The map file's text for an image named `image` (its file name).
pub fn text(&self, image: &str) -> String {
let join = |pts: &[[i64; 2]], sep: &str| pts.iter().map(|[x, y]| format!("{x},{y}")).collect::<Vec<_>>().join(sep);
let mut out = String::new();
match self.kind {
MapKind::Server => {
for a in &self.areas {
// NCSA format: no spaces inside a URL.
let href = a.href.replace(char::is_whitespace, "%20");
let _ = match &a.shape {
Outline::Rect([x0, y0, x1, y1]) => writeln!(out, "rect {href} {x0},{y0} {x1},{y1}"),
Outline::Poly(pts) => writeln!(out, "poly {href} {}", join(pts, " ")),
};
}
}
_ => {
let name = attr(std::path::Path::new(image).file_stem().map_or("image".into(), |s| s.to_string_lossy()).as_ref());
let _ = writeln!(out, "<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n<title>{name}</title>\n</head>\n<body>");
let _ =
writeln!(out, "<img src=\"{}\" width=\"{}\" height=\"{}\" usemap=\"#{name}\" alt=\"\">", attr(image), self.width, self.height);
let _ = writeln!(out, "<map name=\"{name}\">");
for a in &self.areas {
let (shape, coords) = match &a.shape {
Outline::Rect(r) => ("rect", r.map(|v| v.to_string()).join(",")),
Outline::Poly(pts) => ("poly", join(pts, ",")),
};
let _ = writeln!(out, "<area shape=\"{shape}\" coords=\"{coords}\" href=\"{}\" alt=\"{}\">", attr(&a.href), attr(&a.alt));
}
out.push_str("</map>\n</body>\n</html>\n");
}
}
out
}
}
+125 -8
View File
@@ -16,6 +16,7 @@
mod batch;
mod encode;
mod export;
mod imagemap;
mod load;
pub mod pdf;
mod pdfimport;
@@ -23,6 +24,7 @@ pub mod pngtext;
pub mod ppi;
mod save;
mod svg;
mod text;
use serde_json::{Value, json};
@@ -54,7 +56,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Serialize Document",
[],
None,
"{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp, …the format's options (see document.formats; SVG ones also as svg: {…})} → {text, warnings} for svg, else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
"{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8|txt, …the format's options (see document.formats; SVG ones also as svg: {…})} → {text, warnings} for svg, else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
has_doc,
export::serialize
),
@@ -63,7 +65,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Document",
[],
None,
"{path?, format?: svg|svgz|pdf|png|jpg|webp|vectorcraft|template (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), quality?: 90 (jpg); SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers} (see document.formats), …the PDF options of document.exportPdf} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
"{path?, format?: svg|svgz|pdf|png|jpg|webp|gif|png8|txt|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), gif/png8: colors?: 256 (2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, webp: lossless?: true (lossy WebP isn't available yet: written lossless, with a warning), txt: the stories in stacking order (back to front; a thread once), encoding?: utf8|utf16 (with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false; SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers} (see document.formats), …the PDF options of document.exportPdf} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
has_doc,
export::export
),
@@ -81,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export for Screens",
["File", "Export"],
None,
"{folder?, artboards?: [index…] | range?: \"1-3\" (default all), formats?: [{format: png|jpg|webp|svg|svgz|pdf, scale?: 1 (raster only), ppi?: (raster: wins over scale, scale = ppi / 72), suffix?: \"@2x\" (raster default: @{scale}x when scale ≠ 1; vector formats drop @Nx suffixes), …the format's options (antiAlias, background…)}], prefix?, antiAlias?: none|art|type (raster rows without their own)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]}",
"{folder?, artboards?: [index…] | range?: \"1-3\" (default all), formats?: [{format: png|jpg|webp|gif|png8|svg|svgz|pdf, scale?: 1 (raster only), ppi?: (raster: wins over scale, scale = ppi / 72), suffix?: \"@2x\" (raster default: @{scale}x when scale ≠ 1; vector formats drop @Nx suffixes), …the format's options (antiAlias, background…)}], prefix?, antiAlias?: none|art|type (raster rows without their own)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]}",
has_doc,
export::export_for_screens
),
@@ -104,6 +106,15 @@ pub fn specs() -> Vec<CommandSpec> {
always,
pdfimport::pdf_info
),
cmd!(
"document.exportForOffice",
"Save for Office Documents…",
["File"],
None,
"{path?, ppi?: 150 (72, 150, 300… pixels per inch), transparent?: false (else on white), artboard?: 0} the artboard as a PNG for office documents and slides → {path, bytes, width, height} (pixels); no path → {dataBase64, bytes, width, height}",
has_doc,
export::export_for_office
),
]
.into_iter()
.chain(save::specs())
@@ -181,7 +192,8 @@ const RANGE: FormatOption = FormatOption {
description: "1-based artboards such as \"1-3, 5\", or \"all\" (wins over artboards and artboard)",
};
const SCALE: FormatOption = FormatOption { name: "scale", ty: "number", default: "1", description: "pixels per point (0.01–64)" };
const QUALITY: FormatOption = FormatOption { name: "quality", ty: "integer", default: "90", description: "JPEG quality 1–100" };
const QUALITY: FormatOption =
FormatOption { name: "quality", ty: "integer", default: "90", description: "JPEG quality 0–100 (the JPEG Options dialog shows 0–10)" };
const USE_ARTBOARDS: FormatOption = FormatOption {
name: "useArtboards",
ty: "boolean",
@@ -208,6 +220,85 @@ const ANTI_ALIAS: FormatOption = FormatOption {
};
const INTERLACED: FormatOption =
FormatOption { name: "interlaced", ty: "boolean", default: "false", description: "Adam7 interlacing (the image builds up while it loads)" };
const COLOR_MODEL: FormatOption = FormatOption {
name: "colorModel",
ty: "string",
default: "\"rgb\"",
description: "rgb, cmyk (ink amounts in the working CMYK space: CMYK colours keep their inks) or gray",
};
const METHOD: FormatOption = FormatOption {
name: "method",
ty: "string",
default: "\"baseline\"",
description: "baseline (standard), optimized (smaller Huffman tables) or progressive (builds up in scans)",
};
const SCANS: FormatOption = FormatOption { name: "scans", ty: "integer", default: "3", description: "progressive scans, 3–5" };
const EMBED_ICC: FormatOption = FormatOption {
name: "embedIcc",
ty: "boolean",
default: "true",
description: "embed the colour profile: sRGB (RGB), the working CMYK space (CMYK) or gray with sRGB's tone curve",
};
const IMAGE_MAP: FormatOption = FormatOption {
name: "imageMap",
ty: "string",
default: "\"none\"",
description: "none, client (an HTML page with <map>, <stem>.html) or server (an NCSA <stem>.map): the areas of objects with a URL and an Image Map shape (attributes.set)",
};
const COLORS: FormatOption =
FormatOption { name: "colors", ty: "integer", default: "256", description: "most palette entries, 2–256 (the transparent one included)" };
const REDUCTION: FormatOption = FormatOption {
name: "reduction",
ty: "string",
default: "\"selective\"",
description: "the palette: perceptual, selective (also keeps rarer colours, snaps near web colours), adaptive (most used), web (web-safe), blackWhite or gray; art with that many colours or fewer keeps them exactly",
};
const DITHER: FormatOption = FormatOption {
name: "dither",
ty: "string",
default: "\"diffusion\"",
description: "none, diffusion (Floyd–Steinberg), pattern (8×8 ordered) or noise",
};
const DITHER_AMOUNT: FormatOption = FormatOption { name: "ditherAmount", ty: "integer", default: "100", description: "dither strength 0–100" };
const TRANSPARENCY: FormatOption = FormatOption {
name: "transparency",
ty: "boolean",
default: "true",
description: "pixels under half opacity become one transparent entry (false: everything is blended over the matte)",
};
const MATTE: FormatOption = FormatOption {
name: "matte",
ty: "string",
default: "\"white\"",
description: "the colour partly transparent edges are blended over (\"#rrggbb\", white, black), or none (they keep their colour)",
};
const LOSSLESS: FormatOption = FormatOption {
name: "lossless",
ty: "boolean",
default: "true",
description: "lossless WebP; false asks for lossy, which isn't available yet (written lossless, with a warning)",
};
const WEBP_QUALITY: FormatOption =
FormatOption { name: "quality", ty: "integer", default: "90", description: "lossy quality 0–100 (for when lossy WebP is available)" };
/// The options of the palette formats (PNG-8, GIF).
const PALETTE_OPTIONS: &[FormatOption] = &[
ARTBOARD,
ARTBOARDS,
RANGE,
USE_ARTBOARDS,
PPI,
SCALE,
BACKGROUND,
ANTI_ALIAS,
INTERLACED,
COLORS,
REDUCTION,
DITHER,
DITHER_AMOUNT,
TRANSPARENCY,
MATTE,
];
/// A format `document.open` reads but nothing writes yet.
const fn reader(id: &'static str, label: &'static str, extensions: &'static [&'static str], mime: &'static str, raster: bool) -> Format {
@@ -258,9 +349,24 @@ pub const FORMATS: &[Format] = &[
read: true,
write: true,
raster: true,
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, QUALITY],
options: &[
ARTBOARD,
ARTBOARDS,
RANGE,
USE_ARTBOARDS,
PPI,
SCALE,
BACKGROUND,
ANTI_ALIAS,
QUALITY,
COLOR_MODEL,
METHOD,
SCANS,
EMBED_ICC,
IMAGE_MAP,
],
},
reader("gif", "GIF", &["gif"], "image/gif", true),
Format { id: "gif", label: "GIF", extensions: &["gif"], mime: "image/gif", read: true, write: true, raster: true, options: PALETTE_OPTIONS },
Format {
id: "webp",
label: "WebP",
@@ -269,7 +375,7 @@ pub const FORMATS: &[Format] = &[
read: true,
write: true,
raster: true,
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS],
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, LOSSLESS, WEBP_QUALITY],
},
reader("tiff", "TIFF", &["tif", "tiff"], "image/tiff", true),
reader("bmp", "BMP", &["bmp"], "image/bmp", true),
@@ -283,6 +389,8 @@ pub const FORMATS: &[Format] = &[
raster: false,
options: &[],
},
Format { id: "png8", label: "PNG-8", extensions: &["png"], mime: "image/png", read: false, write: true, raster: true, options: PALETTE_OPTIONS },
Format { id: "txt", label: "Text", extensions: TEXT_EXTS, mime: "text/plain", read: false, write: true, raster: false, options: text::OPTIONS },
];
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
@@ -459,7 +567,7 @@ pub fn artboard_file_names(doc: &vectorcraft_doc::Document, boards: &[usize]) ->
fn write_encoded(path: Option<&str>, name: &str, doc: &vectorcraft_doc::Document, enc: &Encoded, extra: Value) -> Result<Value> {
let files = enc.named(doc, path.unwrap_or(name));
let (main, linked) = files.split_at(enc.files.len());
let row = |(name, bytes): &(String, &[u8])| match path {
let row = |(name, bytes): &(String, std::borrow::Cow<[u8]>)| match path {
Some(_) => json!(name),
None => json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) }),
};
@@ -528,3 +636,12 @@ mod tests_raster;
#[cfg(test)]
mod tests_exportas;
#[cfg(test)]
mod tests_jpeg;
#[cfg(test)]
mod tests_palette;
#[cfg(test)]
mod tests_text;
+1 -1
View File
@@ -154,7 +154,7 @@ fn formats_query_lists_readers_writers_and_options() {
let mut s = Session::new();
let r = s.execute("document.formats", &json!({})).unwrap();
let ids = |k: &str| r[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect::<Vec<_>>();
assert_eq!(ids("writable"), ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "webp", "template"]);
assert_eq!(ids("writable"), ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8", "txt"]);
assert!(ids("readable").contains(&"tiff".to_string()) && ids("readable").contains(&"ait".to_string()));
let png = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap();
assert_eq!(png["options"]["scale"]["default"], 1);
@@ -0,0 +1,89 @@
//! JPEG Options: colour model, coding method, scans, profile and image maps.
use serde_json::{Value, json};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 60, "height": 40})).unwrap();
s
}
fn b64(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
}
/// The JPEG's SOF marker and the number of components in its frame header.
fn frame(file: &[u8]) -> (u8, u8) {
let mut i = 2;
while !(0xC0..=0xC2).contains(&file[i + 1]) {
i += 2 + u16::from_be_bytes([file[i + 2], file[i + 3]]) as usize;
}
(file[i + 1], file[i + 9])
}
#[test]
fn jpeg_options_reach_the_file() {
let mut s = session();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap();
let jpg = |s: &mut Session, p: Value| b64(&s.execute("document.export", &merge(json!({"format": "jpg"}), p)).unwrap());
assert_eq!(frame(&jpg(&mut s, json!({}))), (0xC0, 3), "baseline RGB by default");
assert_eq!(frame(&jpg(&mut s, json!({"colorModel": "cmyk"}))), (0xC0, 4));
assert_eq!(frame(&jpg(&mut s, json!({"colorModel": "grayscale"}))), (0xC0, 1));
assert_eq!(frame(&jpg(&mut s, json!({"method": "progressive", "scans": 5}))).0, 0xC2);
let icc = |f: &[u8]| f.windows(12).any(|w| w == b"ICC_PROFILE\0");
assert!(icc(&jpg(&mut s, json!({}))) && !icc(&jpg(&mut s, json!({"embedIcc": false}))));
assert!(jpg(&mut s, json!({"quality": 0})).len() < jpg(&mut s, json!({"quality": 100})).len());
for bad in [json!({"colorModel": "lab"}), json!({"method": "fast"}), json!({"imageMap": "both"})] {
assert!(s.execute("document.export", &merge(json!({"format": "jpg"}), bad.clone())).is_err(), "{bad}");
}
let r = s.execute("document.formats", &json!({})).unwrap();
let jpg = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "jpg").unwrap();
for k in ["colorModel", "method", "scans", "embedIcc", "imageMap"] {
assert!(jpg["options"].get(k).is_some(), "{k}");
}
}
#[test]
fn image_maps_list_the_objects_with_a_url() {
let mut s = session();
// Areas cover the visual bounds: no stroke, so they are the shapes' own.
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 5, "width": 20, "height": 10})).unwrap();
s.execute("attributes.set", &json!({"url": "https://example.com/a?b=1&c=2", "imageMap": "rectangle"})).unwrap();
s.execute("shape.ellipse", &json!({"x": 35, "y": 20, "width": 20, "height": 10})).unwrap();
s.execute("attributes.set", &json!({"url": "https://example.com/round", "imageMap": "polygon"})).unwrap();
// A URL without a map shape makes no area.
s.execute("shape.rectangle", &json!({"x": 0, "y": 30, "width": 5, "height": 5})).unwrap();
s.execute("attributes.set", &json!({"url": "https://example.com/none"})).unwrap();
let r = s.execute("document.export", &json!({"format": "jpg", "imageMap": "client", "ppi": 144})).unwrap();
let linked = r["linked"].as_array().unwrap();
assert_eq!(linked.len(), 1);
assert_eq!(linked[0]["name"], "Untitled-1.html");
let html = String::from_utf8(b64(&linked[0])).unwrap();
assert!(html.contains("<img src=\"Untitled-1.jpg\" width=\"120\" height=\"80\" usemap=\"#Untitled-1\""), "{html}");
assert!(html.contains("<area shape=\"rect\" coords=\"20,10,60,30\" href=\"https://example.com/a?b=1&amp;c=2\""), "{html}");
let poly = html.lines().find(|l| l.contains("shape=\"poly\"")).expect("a polygon area");
assert!(poly.contains("coords=\"70,50,") && !poly.contains(",70,50\""), "the ellipse's outline, not closed twice: {poly}");
assert!(html.find("example.com/round") < html.find("example.com/a?"), "topmost first: {html}");
assert!(!html.contains("example.com/none"));
let r = s.execute("document.export", &json!({"format": "jpg", "imageMap": "server"})).unwrap();
let map = String::from_utf8(b64(&r["linked"][0])).unwrap();
assert_eq!(r["linked"][0]["name"], "Untitled-1.map");
assert!(map.lines().any(|l| l == "rect https://example.com/a?b=1&c=2 10,5 30,15"), "{map}");
assert!(map.lines().any(|l| l.starts_with("poly https://example.com/round ")), "{map}");
// Written next to the image, named after it.
let dir = std::env::temp_dir().join(format!("vc-imagemap-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("pic.jpeg");
let r = s.execute("document.export", &json!({"path": path.to_string_lossy(), "imageMap": "client"})).unwrap();
assert_eq!(r["linked"][0], dir.join("pic.html").to_string_lossy().as_ref());
assert!(std::fs::read_to_string(dir.join("pic.html")).unwrap().contains("src=\"pic.jpeg\""));
let _ = std::fs::remove_dir_all(dir);
// One file only: Export for Screens can't add the map.
assert!(s.execute("document.exportForScreens", &json!({"formats": [{"format": "jpg", "imageMap": "client"}]})).is_err());
}
@@ -0,0 +1,72 @@
//! PNG-8 and GIF: palette exports through document.export and Export for Screens.
use serde_json::{Value, json};
use super::*;
fn b64(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
}
/// A 40×30 pt document with a red rectangle and a blue ellipse, no strokes.
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 40, "height": 30})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 30})).unwrap();
s.execute("shape.ellipse", &json!({"x": 22, "y": 5, "width": 15, "height": 20})).unwrap();
// Fills the new ellipse (selected).
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
s
}
/// PNG `PLTE` entries.
fn plte_len(file: &[u8]) -> usize {
let i = file.windows(4).position(|w| w == b"PLTE").expect("PLTE");
u32::from_be_bytes(file[i - 4..i].try_into().unwrap()) as usize / 3
}
#[test]
fn png8_and_gif_export_palettes() {
let mut s = session();
let png8 = b64(&s.execute("document.export", &json!({"format": "png8", "colors": 16})).unwrap());
assert_eq!(png8[25], 3, "colour type 3");
assert!(plte_len(&png8) <= 16);
let img = image::load_from_memory(&png8).unwrap().to_rgba8();
assert_eq!(img.dimensions(), (40, 30));
assert_eq!(img.get_pixel(5, 15).0, [255, 0, 0, 255]);
assert_eq!(img.get_pixel(39, 0)[3], 0, "transparent where there is no art");
let gif = b64(&s.execute("document.export", &json!({"format": "gif", "background": "white", "dither": "none"})).unwrap());
assert_eq!(&gif[..6], b"GIF89a");
let img = image::load_from_memory_with_format(&gif, image::ImageFormat::Gif).unwrap().to_rgba8();
assert_eq!(img.get_pixel(5, 15).0, [255, 0, 0, 255]);
assert_eq!(img.get_pixel(39, 0).0, [255, 255, 255, 255], "the white background");
// Black & White: two entries whatever the art.
let bw = b64(&s.execute("document.export", &json!({"format": "png8", "reduction": "blackWhite", "transparency": false})).unwrap());
assert!(plte_len(&bw) <= 2);
for bad in [json!({"reduction": "octree"}), json!({"dither": "lots"}), json!({"matte": "plaid"})] {
assert!(s.execute("document.export", &merge(json!({"format": "gif"}), bad.clone())).is_err(), "{bad}");
}
}
#[test]
fn palette_formats_reach_export_as_and_screens() {
let mut s = session();
// A .png path stays PNG; png8 is asked for by format.
let r = s.execute("document.formats", &json!({})).unwrap();
let f = |id: &str| r["formats"].as_array().unwrap().iter().find(|f| f["id"] == id).unwrap().clone();
assert_eq!((f("png8")["extensions"][0].as_str(), f("png8")["read"].as_bool()), (Some("png"), Some(false)));
assert_eq!(f("gif")["write"], true);
for k in ["colors", "reduction", "dither", "ditherAmount", "transparency", "matte", "interlaced"] {
assert!(f("gif")["options"].get(k).is_some() && f("png8")["options"].get(k).is_some(), "{k}");
}
assert_eq!(format_for_name("a.png").unwrap().id, "png");
let r = s.execute("document.exportForScreens", &json!({"formats": [{"format": "gif", "scale": 2}, {"format": "png8"}]})).unwrap();
let names: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Artboard-1@2x.gif", "Artboard-1.png"]);
let gif = vectorcraft_format::base64_decode(r["files"][0]["dataBase64"].as_str().unwrap()).unwrap();
assert_eq!(image::load_from_memory(&gif).unwrap().width(), 80);
}
@@ -0,0 +1,87 @@
//! Export As Text, WebP options and Save for Office Documents.
use serde_json::{Value, json};
use super::*;
fn b64(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
}
fn text(s: &mut Session, x: f64, y: f64, t: &str) -> u64 {
s.execute("text.create", &json!({"x": x, "y": y, "text": t})).unwrap()["id"].as_u64().unwrap()
}
#[test]
fn text_export_writes_the_stories_in_stacking_order() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
text(&mut s, 10.0, 200.0, "Back story\nsecond line");
let front = text(&mut s, 10.0, 40.0, "Front");
// A thread of two frames is one story, written once.
let a =
s.execute("text.create", &json!({"x": 10, "y": 80, "text": "Threaded words", "area": {"width": 120, "height": 40}})).unwrap()["id"].clone();
let b = s.execute("text.create", &json!({"x": 200, "y": 80, "text": "", "area": {"width": 120, "height": 40}})).unwrap()["id"].clone();
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
// Hidden objects are left out.
let hidden = text(&mut s, 10.0, 260.0, "Hidden");
s.execute("select.set", &json!({"ids": [hidden]})).unwrap();
s.execute("object.hide", &json!({})).unwrap();
let export = |s: &mut Session, p: Value| b64(&s.execute("document.export", &merge(json!({"format": "txt"}), p)).unwrap());
let utf8 = String::from_utf8(export(&mut s, json!({}))).unwrap();
assert_eq!(utf8, "Back story\nsecond line\nFront\nThreaded words\n", "back to front, one line ending after each line");
let crlf = export(&mut s, json!({"lineEndings": "crlf"}));
assert_eq!(String::from_utf8(crlf).unwrap(), utf8.replace('\n', "\r\n"));
let utf16 = export(&mut s, json!({"encoding": "utf16"}));
assert_eq!(&utf16[..2], [0xFF, 0xFE], "byte order mark");
let units: Vec<u16> = utf16[2..].chunks(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect();
assert_eq!(String::from_utf16(&units).unwrap(), utf8);
// Only the selection.
s.execute("select.set", &json!({"ids": [front]})).unwrap();
assert_eq!(export(&mut s, json!({"selectionOnly": true})), b"Front\n");
for bad in [json!({"encoding": "latin1"}), json!({"lineEndings": "cr"})] {
assert!(s.execute("document.export", &merge(json!({"format": "txt"}), bad.clone())).is_err(), "{bad}");
}
// A .txt path picks the format.
let dir = std::env::temp_dir().join(format!("vc-text-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("story.txt");
assert_eq!(s.execute("document.export", &json!({"path": path.to_string_lossy()})).unwrap()["format"], "txt");
assert_eq!(std::fs::read_to_string(&path).unwrap(), utf8);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn webp_is_lossless_and_says_so_when_lossy_is_asked_for() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 30, "height": 20})).unwrap();
s.execute("shape.rectangle", &json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap();
let r = s.execute("document.export", &json!({"format": "webp"})).unwrap();
let file = b64(&r);
assert_eq!((&file[..4], &file[8..12], &file[12..16]), (&b"RIFF"[..], &b"WEBP"[..], &b"VP8L"[..]), "lossless bitstream");
assert_eq!(r["warnings"], json!([]));
let r = s.execute("document.export", &json!({"format": "webp", "lossless": false, "quality": 60, "background": "black"})).unwrap();
assert_eq!(&b64(&r)[12..16], b"VP8L");
assert!(r["warnings"][0].as_str().unwrap().contains("lossless"), "{r}");
assert_eq!(image::load_from_memory(&b64(&r)).unwrap().to_rgba8().get_pixel(29, 19).0, [0, 0, 0, 255]);
}
#[test]
fn save_for_office_writes_a_png_at_the_resolution() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 612, "height": 792, "artboards": 2})).unwrap();
let r = s.execute("document.exportForOffice", &json!({"ppi": 150})).unwrap();
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(1275), Some(1650)), "Letter at 150 ppi");
let img = image::load_from_memory(&b64(&r)).unwrap().to_rgba8();
assert_eq!(img.dimensions(), (1275, 1650));
assert_eq!(img.get_pixel(0, 0).0, [255, 255, 255, 255], "on white");
let r = s.execute("document.exportForOffice", &json!({"ppi": 72, "transparent": true, "artboard": 1})).unwrap();
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(612), Some(792)));
assert_eq!(image::load_from_memory(&b64(&r)).unwrap().to_rgba8().get_pixel(0, 0)[3], 0, "transparent");
assert!(s.execute("document.exportForOffice", &json!({"artboard": 2})).is_err());
assert!(s.execute("document.exportForOffice", &json!({"ppi": -3})).is_err());
let spec = crate::cmd::find_command("document.exportForOffice").unwrap();
assert_eq!((spec.label, spec.menu), ("Save for Office Documents…", &["File"][..]));
}
+96
View File
@@ -0,0 +1,96 @@
//! Export As Text (`.txt`): the document's stories, one after another in stacking order (back to
//! front), as UTF-8 or UTF-16 with LF or CRLF line endings. Threaded frames are one story,
//! written where its first frame is; hidden objects and template layers are left out.
use std::collections::HashSet;
use serde::Deserialize;
use serde_json::Value;
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use super::super::*;
use super::FormatOption;
const C: &str = "document.export";
const ENCODING: FormatOption = FormatOption {
name: "encoding",
ty: "string",
default: "\"utf8\"",
description: "utf8 (no byte order mark) or utf16 (little-endian, with a byte order mark)",
};
const LINE_ENDINGS: FormatOption =
FormatOption { name: "lineEndings", ty: "string", default: "\"lf\"", description: "lf (macOS, Linux) or crlf (Windows)" };
const SELECTION_ONLY: FormatOption =
FormatOption { name: "selectionOnly", ty: "boolean", default: "false", description: "only the selected type objects (document.export)" };
/// The options `document.formats` lists for text.
pub(super) const OPTIONS: &[FormatOption] = &[ENCODING, LINE_ENDINGS, SELECTION_ONLY];
#[derive(Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct TextOptions {
encoding: Option<String>,
line_endings: Option<String>,
}
/// The text file of `doc` with the text options of `p`.
pub(super) fn encode(doc: &Document, p: &Value) -> Result<Vec<u8>> {
let o = if p.is_object() { TextOptions::deserialize(p).map_err(|e| bad(C, format!("Text options: {e}")))? } else { TextOptions::default() };
let eol = match o.line_endings.as_deref().map(str::to_ascii_lowercase).as_deref() {
None | Some("lf") => "\n",
Some("crlf") => "\r\n",
Some(other) => return Err(bad(C, format!("lineEndings `{other}`: lf or crlf"))),
};
let mut text = String::new();
for story in stories(doc) {
// Any line break in the story becomes the chosen one; every story ends with one.
let story = story.replace("\r\n", "\n").replace('\r', "\n");
for line in story.split('\n') {
text.push_str(line);
text.push_str(eol);
}
}
match o.encoding.as_deref().map(|e| e.to_ascii_lowercase().replace(['-', '_'], "")).as_deref() {
None | Some("utf8") => Ok(text.into_bytes()),
Some("utf16" | "utf16le" | "unicode") => Ok([0xFF, 0xFE].into_iter().chain(text.encode_utf16().flat_map(u16::to_le_bytes)).collect()),
Some(_) => Err(bad(C, format!("encoding `{}`: utf8 or utf16", o.encoding.unwrap_or_default()))),
}
}
/// The text of every visible story in stacking order (back to front).
fn stories(doc: &Document) -> Vec<String> {
let mut out = vec![];
let mut done = HashSet::new();
for l in &doc.layers {
collect(doc, l, &mut done, &mut out);
}
out
}
fn collect(doc: &Document, n: &Node, done: &mut HashSet<NodeId>, out: &mut Vec<String>) {
if !n.visible || matches!(n.kind, NodeKind::Layer { template: true, .. }) {
return;
}
if let NodeKind::Text(t) = &n.kind {
if done.contains(&n.id) {
return;
}
// A threaded frame: the whole thread's story, once.
match doc.text_threads.iter().find(|th| th.contains(&n.id)) {
Some(thread) => {
done.extend(thread.iter().copied());
let frames = thread.iter().filter_map(|id| doc.node(*id)).filter_map(|f| match &f.kind {
NodeKind::Text(t) => Some(t.plain_text()),
_ => None,
});
out.push(frames.collect());
}
None => out.push(t.plain_text()),
}
return;
}
for c in n.children().into_iter().flatten() {
collect(doc, c, done, out);
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ fn export_tool_lists_engine_formats_and_options() {
let tools = tool_definitions();
let export = tools.iter().find(|t| t["name"] == "export").unwrap();
let props = &export["inputSchema"]["properties"];
assert_eq!(props["format"]["enum"], json!(["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "webp", "template"]));
assert_eq!(props["format"]["enum"], json!(["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8", "txt"]));
for k in ["artboard", "range", "options"] {
assert!(props.get(k).is_some(), "export takes {k}");
}
+1 -1
View File
@@ -264,7 +264,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"export",
"Export",
"Export the active document with the engine's document.export (the same bytes in the app and headless): svg (artboard viewBox), pdf (one page per artboard: all, or `artboard` / `range`), png/jpg/webp (one rendered artboard) or vectorcraft (a native copy; the document keeps its path). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.",
"Export the active document with the engine's document.export (the same bytes in the app and headless): svg (artboard viewBox), pdf (one page per artboard: all, or `artboard` / `range`), png/jpg/webp/gif/png8 (one rendered artboard; png8 is an indexed .png), txt (the stories, back to front) or vectorcraft (a native copy; the document keeps its path). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.",
obj(
json!({
"format": {"type": "string", "enum": FORMATS.iter().filter(|f| f.write).map(|f| f.id).collect::<Vec<_>>(), "description": "Default: from the path's extension"},
+2
View File
@@ -20,6 +20,8 @@ vello_cpu = { workspace = true }
vello_common = { version = "0.2", default-features = false }
image = { workspace = true }
flate2 = { workspace = true }
jpeg-encoder = { workspace = true }
gif = { workspace = true }
log = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+33
View File
@@ -0,0 +1,33 @@
//! GIF export: a palette image ([`super::quantize`]) as a one-frame GIF89a with its transparent
//! entry, optionally interlaced (rows in four passes, so the image builds up while it loads).
use std::borrow::Cow;
use super::quantize::Indexed;
/// Encode `img` as a GIF file.
pub fn encode(img: &Indexed, interlaced: bool) -> Result<Vec<u8>, String> {
let fail = |e: gif::EncodingError| format!("GIF encoding failed: {e}");
let (Ok(w), Ok(h)) = (u16::try_from(img.width), u16::try_from(img.height)) else {
return Err(format!("{} × {} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution", img.width, img.height));
};
if w == 0 || h == 0 || img.indices.len() != w as usize * h as usize || img.palette.is_empty() || img.palette.len() > 256 {
return Err("GIF encoding failed: the palette image is malformed".into());
}
let buffer = if interlaced { Cow::Owned(interlace(&img.indices, w as usize)) } else { Cow::Borrowed(img.indices.as_slice()) };
let mut enc = gif::Encoder::new(Vec::new(), w, h, &img.palette.concat()).map_err(fail)?;
let frame = gif::Frame { width: w, height: h, buffer, transparent: img.transparent, interlaced, ..gif::Frame::default() };
enc.write_frame(&frame).map_err(fail)?;
enc.into_inner().map_err(fail)
}
/// The rows of a `width`-wide image in GIF's interlaced order: every 8th row from 0, every 8th
/// from 4, every 4th from 2, then every 2nd from 1.
fn interlace(px: &[u8], width: usize) -> Vec<u8> {
let rows: Vec<&[u8]> = px.chunks(width.max(1)).collect();
[(0, 8), (4, 8), (2, 4), (1, 2)]
.into_iter()
.flat_map(|(start, step)| rows.iter().skip(start).step_by(step))
.flat_map(|r| r.iter().copied())
.collect()
}
+196
View File
@@ -0,0 +1,196 @@
//! JPEG export: RGB, CMYK or greyscale pixels, baseline, optimized or progressive (3–5 scans), the
//! resolution in the JFIF header and the colour profile in APP2 segments. Profiles come from the
//! CMS ([`cms::icc_bytes`]): sRGB for RGB (the renderer's pixels are sRGB), the working CMYK space
//! for CMYK, [`cms::GRAY`] for greyscale.
use std::collections::HashMap;
use jpeg_encoder::{ColorType, Density, Encoder};
use vectorcraft_color::cms::{self, lab};
use crate::Rendered;
/// The colour model a JPEG is written in.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ColorModel {
#[default]
Rgb,
/// Ink amounts in the working CMYK space.
Cmyk,
/// Grey with the lightness of the colour.
Gray,
}
impl ColorModel {
pub const ALL: [ColorModel; 3] = [ColorModel::Rgb, ColorModel::Cmyk, ColorModel::Gray];
pub fn id(self) -> &'static str {
match self {
ColorModel::Rgb => "rgb",
ColorModel::Cmyk => "cmyk",
ColorModel::Gray => "gray",
}
}
pub fn label(self) -> &'static str {
match self {
ColorModel::Rgb => "RGB",
ColorModel::Cmyk => "CMYK",
ColorModel::Gray => "Grayscale",
}
}
/// By id or label, ignoring case (`grey` and `grayscale` are grey too).
pub fn from_id(s: &str) -> Option<Self> {
let s = s.to_ascii_lowercase();
Self::ALL.into_iter().find(|m| m.id() == s || m.label().eq_ignore_ascii_case(&s)).or_else(|| (s == "grey").then_some(ColorModel::Gray))
}
/// Bytes per pixel of the pixels [`encode`] takes.
pub fn channels(self) -> usize {
match self {
ColorModel::Rgb => 3,
ColorModel::Cmyk => 4,
ColorModel::Gray => 1,
}
}
/// The profile that describes these pixels.
fn profile(self) -> String {
match self {
ColorModel::Rgb => cms::SRGB.into(),
ColorModel::Cmyk => cms::active_settings().cmyk,
ColorModel::Gray => cms::GRAY.into(),
}
}
}
/// How the image data is coded.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Method {
/// Standard Huffman tables: every decoder reads it.
#[default]
Baseline,
/// Huffman tables optimized for the image: smaller files.
Optimized,
/// Several scans, each adding detail: the image builds up while it loads.
Progressive,
}
impl Method {
pub const ALL: [Method; 3] = [Method::Baseline, Method::Optimized, Method::Progressive];
pub fn id(self) -> &'static str {
match self {
Method::Baseline => "baseline",
Method::Optimized => "optimized",
Method::Progressive => "progressive",
}
}
pub fn label(self) -> &'static str {
match self {
Method::Baseline => "Baseline (Standard)",
Method::Optimized => "Baseline Optimized",
Method::Progressive => "Progressive",
}
}
pub fn from_id(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|m| m.id().eq_ignore_ascii_case(s) || m.label().eq_ignore_ascii_case(s))
}
}
/// Progressive scans: 3 to 5.
pub const SCANS: std::ops::RangeInclusive<u8> = 3..=5;
/// The JPEG options of a raster export.
#[derive(Clone, Debug, PartialEq)]
pub struct JpegOptions {
pub color_model: ColorModel,
pub method: Method,
/// Progressive scans ([`SCANS`]).
pub scans: u8,
/// Embed the colour profile.
pub embed_icc: bool,
}
impl Default for JpegOptions {
fn default() -> Self {
Self { color_model: ColorModel::Rgb, method: Method::Baseline, scans: 3, embed_icc: true }
}
}
/// Encode `px` (`o.color_model`'s channels per pixel: RGB, CMYK ink amounts with 0 = no ink, or
/// grey) at `quality` 0–100, with the resolution `ppi` (if any) in the JFIF header.
pub fn encode(px: &[u8], width: u32, height: u32, quality: u8, ppi: Option<f64>, o: &JpegOptions) -> Result<Vec<u8>, String> {
let fail = |e: jpeg_encoder::EncodingError| format!("JPEG encoding failed: {e}");
let (Ok(w), Ok(h)) = (u16::try_from(width), u16::try_from(height)) else {
return Err(format!("{width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution"));
};
if px.len() as u64 != u64::from(width) * u64::from(height) * o.color_model.channels() as u64 {
return Err("JPEG encoding failed: pixel buffer doesn't match the image size".into());
}
let mut buf = Vec::new();
let mut enc = Encoder::new(&mut buf, quality.clamp(1, 100));
if let Some(ppi) = ppi {
let d = ppi.round().clamp(1.0, u16::MAX as f64) as u16;
enc.set_density(Density::Inch { x: d, y: d });
}
match o.method {
Method::Baseline => {}
Method::Optimized => enc.set_optimized_huffman_tables(true),
Method::Progressive => {
enc.set_optimized_huffman_tables(true);
enc.set_progressive_scans(o.scans.clamp(*SCANS.start(), *SCANS.end()));
}
}
if o.embed_icc {
let icc = cms::icc_bytes(&o.color_model.profile()).map_err(|e| format!("JPEG colour profile: {e}"))?;
enc.add_icc_profile(&icc).map_err(fail)?;
}
let ty = match o.color_model {
ColorModel::Rgb => ColorType::Rgb,
ColorModel::Cmyk => ColorType::Cmyk,
ColorModel::Gray => ColorType::Luma,
};
enc.encode(px, w, h, ty).map_err(fail)?;
Ok(buf)
}
/// `img` flattened on white as RGB, or as grey with each colour's lightness.
pub(crate) fn screen_pixels(img: &Rendered, gray: bool) -> Vec<u8> {
let px = img.to_straight();
let rgb = px.as_chunks::<4>().0.iter().map(|p| {
let a = p[3] as u32;
let mix = |c: u8| ((c as u32 * a + 255 * (255 - a)) / 255) as u8;
[mix(p[0]), mix(p[1]), mix(p[2])]
});
if !gray {
return rgb.flatten().collect();
}
let linear: [f32; 256] = std::array::from_fn(|v| lab::srgb_to_linear(v as f32 / 255.0));
// Relative luminance (sRGB primaries), encoded with sRGB's curve: a grey keeps its value.
rgb.map(|[r, g, b]| {
let y = 0.2126 * linear[r as usize] + 0.7152 * linear[g as usize] + 0.0722 * linear[b as usize];
(lab::linear_to_srgb(y).clamp(0.0, 1.0) * 255.0).round() as u8
})
.collect()
}
/// `img` flattened on white with each colour separated into the working CMYK space (ink amounts).
pub(crate) fn separated(img: &Rendered) -> Vec<u8> {
let cms = cms::active();
let intent = cms.settings().intent;
let mut seen: HashMap<[u8; 3], [u8; 4]> = HashMap::new();
screen_pixels(img, false)
.as_chunks::<3>()
.0
.iter()
.flat_map(|p| {
*seen
.entry(*p)
.or_insert_with(|| cms.srgb_to_cmyk(p.map(|v| v as f32 / 255.0), intent).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8))
})
.collect()
}
+54 -28
View File
@@ -1,8 +1,12 @@
//! Raster export: render a document region with [`RasterExportOptions`] (resolution, background,
//! anti-aliasing) and encode it as PNG ([`png`]: resolution in `pHYs`, Adam7 interlacing), JPEG
//! (resolution in the JFIF header) or lossless WebP.
//! ([`jpeg`]: RGB, CMYK or grey, progressive, resolution and colour profile), lossless WebP, or
//! a palette image ([`quantize`]) as PNG-8 or [`gif`].
pub mod gif;
pub mod jpeg;
pub mod png;
pub mod quantize;
use vectorcraft_doc::{Document, Node, NodeKind};
use vectorcraft_geom::Rect;
@@ -15,6 +19,9 @@ pub enum RasterFormat {
Png,
Jpeg,
WebP,
/// Indexed PNG: a palette of up to 256 colours.
Png8,
Gif,
}
/// How a raster export renders and encodes.
@@ -25,15 +32,27 @@ pub struct RasterExportOptions {
/// Opaque background colour (RGB); `None` = transparent (JPEG has no alpha: white).
pub background: Option<[u8; 3]>,
pub anti_alias: AntiAlias,
/// PNG: Adam7 interlacing.
/// PNG, PNG-8: Adam7 interlacing; GIF: interlaced rows.
pub interlaced: bool,
/// JPEG quality 1–100.
/// JPEG quality 0–100.
pub quality: u8,
/// JPEG colour model, coding and profile.
pub jpeg: jpeg::JpegOptions,
/// PNG-8 and GIF: the colour reduction.
pub palette: quantize::PaletteOptions,
}
impl Default for RasterExportOptions {
fn default() -> Self {
Self { ppi: 72.0, background: None, anti_alias: AntiAlias::Art, interlaced: false, quality: 90 }
Self {
ppi: 72.0,
background: None,
anti_alias: AntiAlias::Art,
interlaced: false,
quality: 90,
jpeg: jpeg::JpegOptions::default(),
palette: quantize::PaletteOptions::default(),
}
}
}
@@ -55,49 +74,52 @@ impl RasterExportOptions {
}
}
/// Encode a rendered image as `format`.
/// Encode a rendered image as `format`. A CMYK JPEG separates the screen colours;
/// [`Renderer::export_region`] draws the inks 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 => jpeg(img, self.quality, Some(self.ppi)),
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 }),
}
}
}
}
}
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`]).
/// Callers check the size first ([`crate::raster_size`]). A CMYK JPEG is drawn as ink amounts
/// ([`Renderer::render_region_inks`]).
pub fn export_region(&mut self, doc: &Document, region: Rect, format: RasterFormat, opts: &RasterExportOptions) -> Result<Vec<u8>, String> {
if format == RasterFormat::Jpeg && opts.jpeg.color_model == jpeg::ColorModel::Cmyk {
let (w, h) = crate::region_pixels(region, opts.scale());
let inks = self.render_region_inks(doc, region, opts.scale(), &opts.render_options(format));
return 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)
}
}
/// JPEG at `quality` 1–100, partly transparent pixels flattened on white, with the resolution
/// `ppi` (if any) in the JFIF header.
/// 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 rgb: Vec<u8> = img
.to_straight()
.as_chunks::<4>()
.0
.iter()
.flat_map(|p| {
let a = p[3] as u32;
let mix = |c: u8| ((c as u32 * a + 255 * (255 - a)) / 255) as u8;
[mix(p[0]), mix(p[1]), mix(p[2])]
})
.collect();
let mut buf = Vec::new();
let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100));
if let Some(ppi) = ppi {
enc.set_pixel_density(image::codecs::jpeg::PixelDensity::dpi(ppi.round().clamp(1.0, u16::MAX as f64) as u16));
}
image::ImageEncoder::write_image(enc, &rgb, img.width, img.height, image::ExtendedColorType::Rgb8)
.map_err(|e| format!("JPEG encoding failed: {e}"))?;
Ok(buf)
let o = jpeg::JpegOptions { embed_icc: false, ..Default::default() };
jpeg::encode(&jpeg::screen_pixels(img, false), img.width, img.height, quality, ppi, &o)
}
/// Lossless WebP.
@@ -131,3 +153,7 @@ fn drawn_bounds(n: &Node) -> Option<Rect> {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_jpeg;
#[cfg(test)]
mod tests_palette;
+89 -22
View File
@@ -1,6 +1,7 @@
//! A PNG writer for raster exports: 8-bit RGBA (RGB when every pixel is opaque), adaptive row
//! filters, the resolution as a `pHYs` chunk and optional Adam7 interlacing (the image builds up
//! progressively while it loads). Chunks are written here; flate2 compresses the image data.
//! A PNG writer for raster exports: 8-bit RGBA (RGB when every pixel is opaque) or indexed
//! (PNG-8: a palette of up to 256 colours, 1–8 bits per pixel), adaptive row filters, the
//! resolution as a `pHYs` chunk and optional Adam7 interlacing (the image builds up progressively
//! while it loads). Chunks are written here; flate2 compresses the image data.
use std::borrow::Cow;
use std::io::Write as _;
@@ -8,6 +9,8 @@ use std::io::Write as _;
use flate2::Crc;
use flate2::write::ZlibEncoder;
use super::quantize::Indexed;
/// What goes into the file besides the pixels.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct PngOptions {
@@ -39,9 +42,7 @@ pub fn phys_chunk(x: f64, y: f64) -> Vec<u8> {
/// Encode straight-alpha RGBA8 pixels (`width`×`height`, row-major) as a PNG file.
pub fn encode(rgba: &[u8], width: u32, height: u32, o: &PngOptions) -> Result<Vec<u8>, String> {
if width == 0 || height == 0 || width > i32::MAX as u32 || height > i32::MAX as u32 {
return Err(format!("PNG encoding failed: {width} × {height} pixels is not a valid image size"));
}
check_size(width, height)?;
if rgba.len() as u64 != u64::from(width) * u64::from(height) * 4 {
return Err("PNG encoding failed: pixel buffer doesn't match the image size".into());
}
@@ -49,32 +50,82 @@ pub fn encode(rgba: &[u8], width: u32, height: u32, o: &PngOptions) -> Result<Ve
let opaque = px.iter().all(|p| p[3] == 255);
let (channels, color_type) = if opaque { (3, 2) } else { (4, 6) };
let pixels: Cow<[u8]> = if opaque { Cow::Owned(px.iter().flat_map(|p| [p[0], p[1], p[2]]).collect()) } else { Cow::Borrowed(rgba) };
write(&pixels, width, height, Layout { channels, bits: 8, color_type }, &[], o)
}
/// Encode a palette image as an indexed PNG (colour type 3: `PLTE`, and `tRNS` for its
/// transparent entry) at 1, 2, 4 or 8 bits per pixel, as few as the palette needs.
pub fn encode_indexed(img: &Indexed, o: &PngOptions) -> Result<Vec<u8>, String> {
check_size(img.width, img.height)?;
if img.indices.len() as u64 != u64::from(img.width) * u64::from(img.height) || img.palette.is_empty() || img.palette.len() > 256 {
return Err("PNG encoding failed: the palette image is malformed".into());
}
let bits = match img.palette.len() {
0..=2 => 1,
3..=4 => 2,
5..=16 => 4,
_ => 8,
};
let plte = img.palette.concat();
// Alpha of the entries up to the transparent one (the rest are opaque).
let trns: Vec<u8> = img.transparent.map(|t| (0..=t).map(|i| if i == t { 0 } else { 255 }).collect()).unwrap_or_default();
let mut extra: Vec<(&[u8; 4], &[u8])> = vec![(b"PLTE", &plte)];
if !trns.is_empty() {
extra.push((b"tRNS", &trns));
}
write(&img.indices, img.width, img.height, Layout { channels: 1, bits, color_type: 3 }, &extra, o)
}
fn check_size(width: u32, height: u32) -> Result<(), String> {
if width == 0 || height == 0 || width > i32::MAX as u32 || height > i32::MAX as u32 {
return Err(format!("PNG encoding failed: {width} × {height} pixels is not a valid image size"));
}
Ok(())
}
/// How pixels are stored: bytes per pixel (one per index for palette images), bits per sample
/// in the file and the PNG colour type.
#[derive(Clone, Copy)]
struct Layout {
channels: usize,
bits: u8,
color_type: u8,
}
/// Write the file: signature, `IHDR`, `pHYs`, the `extra` chunks, the filtered and compressed
/// pixels (`layout.channels` bytes each, packed to `layout.bits` in the file) and `IEND`.
fn write(pixels: &[u8], width: u32, height: u32, layout: Layout, extra: &[(&[u8; 4], &[u8])], o: &PngOptions) -> Result<Vec<u8>, String> {
let Layout { channels, bits, color_type } = layout;
let mut out = Vec::with_capacity(pixels.len() / 2 + 64);
out.extend_from_slice(SIGNATURE);
let mut ihdr = Vec::with_capacity(13);
ihdr.extend_from_slice(&width.to_be_bytes());
ihdr.extend_from_slice(&height.to_be_bytes());
// Bit depth 8, colour type, deflate compression, adaptive filtering, interlace method.
ihdr.extend_from_slice(&[8, color_type, 0, 0, u8::from(o.interlaced)]);
// Bit depth, colour type, deflate compression, adaptive filtering, interlace method.
ihdr.extend_from_slice(&[bits, color_type, 0, 0, u8::from(o.interlaced)]);
chunk(&mut out, b"IHDR", &ihdr);
if let Some(ppi) = o.ppi {
out.extend_from_slice(&phys_chunk(ppi, ppi));
}
let raw = if o.interlaced {
let mut raw = Vec::with_capacity(pixels.len() + height as usize * 2);
for (ty, data) in extra {
chunk(&mut out, ty, data);
}
// Filters work on whole bytes: a sub-byte pixel counts as one.
let bpp = (channels * bits as usize / 8).max(1);
let mut raw = Vec::with_capacity(pixels.len() + height as usize * if o.interlaced { 2 } else { 1 });
let mut rows = |px: &[u8], w: u32, h: u32| {
let packed = pack(px, w, h, bits);
filter_rows(&packed, (w as usize * channels * bits as usize).div_ceil(8), h, bpp, &mut raw);
};
if o.interlaced {
for pass in ADAM7 {
let sub = sub_image(&pixels, width, height, channels, pass);
if let Some((w, h, px)) = sub {
filter_rows(&px, w, h, channels, &mut raw);
if let Some((w, h, px)) = sub_image(pixels, width, height, channels, pass) {
rows(&px, w, h);
}
}
raw
} else {
let mut raw = Vec::with_capacity(pixels.len() + height as usize);
filter_rows(&pixels, width, height, channels, &mut raw);
raw
};
rows(pixels, width, height);
}
let mut z = ZlibEncoder::new(Vec::with_capacity(raw.len() / 2), flate2::Compression::default());
let data = z.write_all(&raw).and_then(|()| z.finish()).map_err(|e| format!("PNG encoding failed: {e}"))?;
for part in data.chunks(IDAT_MAX) {
@@ -122,6 +173,23 @@ pub fn with_text(png: Vec<u8>, entries: &[(&str, Cow<str>)]) -> Vec<u8> {
out
}
/// Rows of one-byte pixels packed `bits` per pixel, most significant first (each row starts on a
/// byte); at 8 bits the pixels as they are.
fn pack(px: &[u8], w: u32, h: u32, bits: u8) -> Cow<'_, [u8]> {
if bits >= 8 {
return Cow::Borrowed(px);
}
let per = (8 / bits) as usize;
let mut out = Vec::with_capacity(h as usize * (w as usize).div_ceil(per));
for row in px.chunks(w.max(1) as usize) {
for group in row.chunks(per) {
let byte = group.iter().enumerate().fold(0u8, |b, (i, v)| b | (v & ((1 << bits) - 1)) << (8 - bits as usize * (i + 1)));
out.push(byte);
}
}
Cow::Owned(out)
}
/// Append one chunk: length, type, data, CRC of type and data.
fn chunk(out: &mut Vec<u8>, ty: &[u8; 4], data: &[u8]) {
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
@@ -150,10 +218,9 @@ fn sub_image(px: &[u8], width: u32, height: u32, channels: usize, (x0, y0, dx, d
Some((w, h, out))
}
/// Filter every row of a `w`×`h` image into `out` (filter type byte + filtered bytes), picking
/// per row the filter with the smallest sum of absolute values (the usual heuristic).
fn filter_rows(px: &[u8], w: u32, h: u32, bpp: usize, out: &mut Vec<u8>) {
let stride = w as usize * bpp;
/// Filter every row (`stride` bytes, `h` rows) into `out` (filter type byte + filtered bytes),
/// picking per row the filter with the smallest sum of absolute values (the usual heuristic).
fn filter_rows(px: &[u8], stride: usize, h: u32, bpp: usize, out: &mut Vec<u8>) {
let zero = vec![0u8; stride];
let mut cand: [Vec<u8>; 5] = std::array::from_fn(|_| vec![0u8; stride]);
for y in 0..h as usize {
+447
View File
@@ -0,0 +1,447 @@
//! Colour reduction for palette formats (PNG-8, GIF): a palette of at most 256 colours chosen by
//! median cut over a 15-bit histogram (refined by a few k-means passes) or a fixed palette, then
//! each pixel mapped to its nearest entry with optional dithering (Floyd–Steinberg diffusion, an
//! 8×8 ordered pattern, or noise). Pixels under half opacity can become one transparent entry;
//! the others are blended over the matte colour.
use std::collections::HashMap;
/// How the palette is chosen.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Reduction {
/// Median cut weighted towards the colours the eye tells apart best.
Perceptual,
/// Like perceptual, keeping rarer distinct colours too, and snapping near web colours to them.
#[default]
Selective,
/// Median cut by how often colours occur.
Adaptive,
/// The 216 web-safe colours (the most used of them when fewer are allowed).
Web,
/// Black and white.
BlackWhite,
/// Evenly spaced greys.
Gray,
}
impl Reduction {
pub const ALL: [Reduction; 6] =
[Reduction::Perceptual, Reduction::Selective, Reduction::Adaptive, Reduction::Web, Reduction::BlackWhite, Reduction::Gray];
pub fn id(self) -> &'static str {
match self {
Reduction::Perceptual => "perceptual",
Reduction::Selective => "selective",
Reduction::Adaptive => "adaptive",
Reduction::Web => "web",
Reduction::BlackWhite => "blackWhite",
Reduction::Gray => "gray",
}
}
pub fn label(self) -> &'static str {
match self {
Reduction::Perceptual => "Perceptual",
Reduction::Selective => "Selective",
Reduction::Adaptive => "Adaptive",
Reduction::Web => "Web",
Reduction::BlackWhite => "Black & White",
Reduction::Gray => "Grayscale",
}
}
pub fn from_id(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|r| r.id().eq_ignore_ascii_case(s) || r.label().eq_ignore_ascii_case(s))
}
/// Channel weights of colour distances.
fn weights(self) -> [f32; 3] {
match self {
Reduction::Perceptual | Reduction::Selective => [0.55, 0.77, 0.34],
_ => [1.0; 3],
}
}
}
/// How colours between palette entries are approximated.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Dither {
None,
/// Floyd–Steinberg error diffusion.
#[default]
Diffusion,
/// An 8×8 ordered (Bayer) pattern.
Pattern,
/// Random noise.
Noise,
}
impl Dither {
pub const ALL: [Dither; 4] = [Dither::None, Dither::Diffusion, Dither::Pattern, Dither::Noise];
pub fn id(self) -> &'static str {
match self {
Dither::None => "none",
Dither::Diffusion => "diffusion",
Dither::Pattern => "pattern",
Dither::Noise => "noise",
}
}
pub fn label(self) -> &'static str {
match self {
Dither::None => "No Dither",
Dither::Diffusion => "Diffusion",
Dither::Pattern => "Pattern",
Dither::Noise => "Noise",
}
}
pub fn from_id(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|d| d.id().eq_ignore_ascii_case(s) || d.label().eq_ignore_ascii_case(s))
}
}
/// The palette options of a raster export (PNG-8, GIF).
#[derive(Clone, Debug, PartialEq)]
pub struct PaletteOptions {
/// Most palette entries, the transparent one included: 2–256.
pub colors: u16,
pub reduction: Reduction,
pub dither: Dither,
/// Dither strength 0–100.
pub dither_amount: u8,
/// Pixels under half opacity become a transparent entry; otherwise every pixel is opaque.
pub transparency: bool,
/// The colour partly transparent pixels are blended over. `None`: with transparency, they
/// keep their own colour (hard edges); without, white.
pub matte: Option<[u8; 3]>,
}
impl Default for PaletteOptions {
fn default() -> Self {
Self {
colors: 256,
reduction: Reduction::Selective,
dither: Dither::Diffusion,
dither_amount: 100,
transparency: true,
matte: Some([255; 3]),
}
}
}
/// An image as palette indices.
#[derive(Clone, Debug, PartialEq)]
pub struct Indexed {
pub width: u32,
pub height: u32,
pub palette: Vec<[u8; 3]>,
/// The transparent entry (always 0 when there is one).
pub transparent: Option<u8>,
/// One index per pixel, row-major.
pub indices: Vec<u8>,
}
/// Reduce straight-alpha RGBA pixels (`width`×`height`) to a palette.
pub fn quantize(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions) -> Indexed {
let px = rgba.as_chunks::<4>().0;
let clear = |p: &[u8; 4]| o.transparency && p[3] < 128;
let gray = matches!(o.reduction, Reduction::Gray | Reduction::BlackWhite);
let matte = o.matte.unwrap_or([255; 3]);
let blend = o.matte.is_some() || !o.transparency;
// The opaque colours: blended over the matte, greys for the grey reductions.
let colors: Vec<[u8; 3]> = px
.iter()
.map(|p| {
let a = if blend { p[3] as u32 } else { 255 };
let c = [0, 1, 2].map(|i| ((p[i] as u32 * a + matte[i] as u32 * (255 - a) + 127) / 255) as u8);
if gray { [luma(c); 3] } else { c }
})
.collect();
let has_clear = px.iter().any(clear);
let k = (o.colors.clamp(2, 256) as usize - usize::from(has_clear)).max(1);
let opaque = || colors.iter().zip(px).filter(|(_, p)| !clear(p)).map(|(c, _)| *c);
let (mut palette, exact) = match o.reduction {
Reduction::BlackWhite => (vec![[0; 3], [255; 3]], false),
Reduction::Gray => ((0..k).map(|i| [(i * 255 / (k - 1).max(1)) as u8; 3]).collect(), false),
Reduction::Web => (web(opaque(), k), false),
r => match distinct(opaque(), k) {
Some(exact) => (exact, true),
None => (median_cut(opaque(), k, r), false),
},
};
if palette.is_empty() {
palette.push(matte);
}
let weights = o.reduction.weights();
let dither = if exact { Dither::None } else { o.dither };
let mut indices = map(&colors, width as usize, &palette, weights, dither, f32::from(o.dither_amount.min(100)) / 100.0, |i| clear(&px[i]));
// Drop the entries no pixel uses (fixed palettes), keeping the order.
let mut used = [false; 256];
for (i, &ix) in indices.iter().enumerate() {
if !clear(&px[i]) {
used[ix as usize] = true;
}
}
let mut remap = [0u8; 256];
let mut kept = Vec::with_capacity(palette.len() + 1);
if has_clear {
kept.push(matte);
}
for (i, c) in palette.iter().enumerate() {
if used[i] {
remap[i] = kept.len() as u8;
kept.push(*c);
}
}
for (i, ix) in indices.iter_mut().enumerate() {
*ix = if clear(&px[i]) { 0 } else { remap[*ix as usize] };
}
Indexed { width, height, palette: kept, transparent: has_clear.then_some(0), indices }
}
/// Lightness as a grey value (Rec. 601 luma).
fn luma([r, g, b]: [u8; 3]) -> u8 {
((r as u32 * 299 + g as u32 * 587 + b as u32 * 114 + 500) / 1000) as u8
}
/// The colours themselves, most used first, when there are at most `k`.
fn distinct(colors: impl Iterator<Item = [u8; 3]>, k: usize) -> Option<Vec<[u8; 3]>> {
let mut seen: HashMap<[u8; 3], u32> = HashMap::with_capacity(k + 1);
for c in colors {
*seen.entry(c).or_insert(0) += 1;
if seen.len() > k {
return None;
}
}
let mut v: Vec<([u8; 3], u32)> = seen.into_iter().collect();
v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
Some(v.into_iter().map(|(c, _)| c).collect())
}
/// A web-safe colour: each channel a multiple of 51.
fn web_safe(c: [u8; 3]) -> [u8; 3] {
c.map(|v| ((v as u32 + 25) / 51 * 51) as u8)
}
/// The `k` most used web-safe colours.
fn web(colors: impl Iterator<Item = [u8; 3]>, k: usize) -> Vec<[u8; 3]> {
let mut count: HashMap<[u8; 3], u32> = HashMap::new();
for c in colors {
*count.entry(web_safe(c)).or_insert(0) += 1;
}
let mut v: Vec<([u8; 3], u32)> = count.into_iter().collect();
v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
v.into_iter().take(k).map(|(c, _)| c).collect()
}
/// One histogram cell: its mean colour and pixel count.
#[derive(Clone, Copy)]
struct Cell {
mean: [f32; 3],
count: u32,
}
fn dist(a: [f32; 3], b: [f32; 3], w: [f32; 3]) -> f32 {
(0..3).map(|i| (w[i] * (a[i] - b[i])).powi(2)).sum()
}
/// `k` colours by median cut over a 5-bit-per-channel histogram, then k-means passes.
fn median_cut(colors: impl Iterator<Item = [u8; 3]>, k: usize, r: Reduction) -> Vec<[u8; 3]> {
let mut hist = vec![(0u32, [0u64; 3]); 1 << 15];
for c in colors {
let h = &mut hist[(c[0] as usize >> 3) << 10 | (c[1] as usize >> 3) << 5 | c[2] as usize >> 3];
h.0 += 1;
for (sum, v) in h.1.iter_mut().zip(c) {
*sum += v as u64;
}
}
let mut cells: Vec<Cell> = hist.iter().filter(|h| h.0 > 0).map(|&(n, s)| Cell { mean: s.map(|v| v as f32 / n as f32), count: n }).collect();
let w = r.weights();
// How much a box gains from a split: its widest weighted range, times its pixel count (how
// often its colours occur) or a root of it (keeping rarer colours apart too).
let score = |b: &[Cell]| -> (f32, usize) {
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
let mut n = 0u64;
for c in b {
for i in 0..3 {
lo[i] = lo[i].min(c.mean[i]);
hi[i] = hi[i].max(c.mean[i]);
}
n += c.count as u64;
}
let axis = (0..3).max_by(|&a, &b| (w[a] * (hi[a] - lo[a])).total_cmp(&(w[b] * (hi[b] - lo[b])))).unwrap_or(0);
let range = w[axis] * (hi[axis] - lo[axis]);
let weight = match r {
Reduction::Adaptive => n as f32,
Reduction::Perceptual => (n as f32).sqrt(),
_ => (n as f32).sqrt().sqrt(),
};
(if b.len() < 2 { 0.0 } else { range * weight }, axis)
};
// (start, end, score, axis) over `cells`.
let mut boxes = vec![{
let (s, axis) = score(&cells);
(0, cells.len(), s, axis)
}];
while boxes.len() < k {
let Some(at) = boxes.iter().enumerate().max_by(|x, y| x.1.2.total_cmp(&y.1.2)).map(|(i, _)| i) else { break };
let (a, b, s, axis) = boxes[at];
if s <= 0.0 {
break;
}
let part = &mut cells[a..b];
part.sort_by(|x, y| x.mean[axis].total_cmp(&y.mean[axis]));
// Split at the median pixel, leaving at least one cell on each side.
let total: u64 = part.iter().map(|c| c.count as u64).sum();
let mut acc = 0u64;
let mut mid = 1;
for (i, c) in part.iter().enumerate() {
acc += c.count as u64;
if acc * 2 >= total {
mid = (i + 1).clamp(1, part.len() - 1);
break;
}
}
for (x, y) in [(a, a + mid), (a + mid, b)] {
let (s, axis) = score(&cells[x..y]);
if x == a {
boxes[at] = (x, y, s, axis);
} else {
boxes.push((x, y, s, axis));
}
}
}
let mean = |b: &[Cell]| {
let n: f64 = b.iter().map(|c| c.count as f64).sum();
[0, 1, 2].map(|i| (b.iter().map(|c| c.mean[i] as f64 * c.count as f64).sum::<f64>() / n.max(1.0)) as f32)
};
let mut palette: Vec<[f32; 3]> = boxes.iter().map(|&(a, b, ..)| mean(&cells[a..b])).collect();
// k-means: move each entry to the mean of the cells nearest to it.
for _ in 0..3 {
let mut sums = vec![([0f64; 3], 0f64); palette.len()];
for c in &cells {
let i = nearest(&palette, c.mean, w);
for ch in 0..3 {
sums[i].0[ch] += c.mean[ch] as f64 * c.count as f64;
}
sums[i].1 += c.count as f64;
}
for (p, (s, n)) in palette.iter_mut().zip(sums) {
if n > 0.0 {
*p = s.map(|v| (v / n) as f32);
}
}
}
let mut out: Vec<[u8; 3]> = palette.iter().map(|p| p.map(|v| v.round().clamp(0.0, 255.0) as u8)).collect();
if r == Reduction::Selective {
// Colours close to a web-safe one become it (they display without dithering in a browser
// limited to those).
for c in &mut out {
let s = web_safe(*c);
if (0..3).all(|i| c[i].abs_diff(s[i]) <= 6) {
*c = s;
}
}
}
out.sort_unstable();
out.dedup();
out
}
fn nearest(palette: &[[f32; 3]], c: [f32; 3], w: [f32; 3]) -> usize {
palette.iter().enumerate().min_by(|a, b| dist(*a.1, c, w).total_cmp(&dist(*b.1, c, w))).map_or(0, |(i, _)| i)
}
/// The 8×8 ordered-dither threshold at (x, y), in 0..1.
fn bayer(x: usize, y: usize) -> f32 {
let mut v = 0;
for i in 0..3 {
let (xb, yb) = ((x >> i) & 1, (y >> i) & 1);
v |= ((xb ^ yb) << 1 | yb) << (2 * (2 - i));
}
(v as f32 + 0.5) / 64.0
}
/// A noise threshold at (x, y), in 0..1 (a hash, so exports are reproducible).
fn noise(x: usize, y: usize) -> f32 {
let mut h = (x as u32).wrapping_mul(0x9E37_79B1) ^ (y as u32).wrapping_mul(0x85EB_CA77);
h ^= h >> 15;
h = h.wrapping_mul(0x2C1B_3C6D);
h ^= h >> 12;
(h >> 8) as f32 / (1 << 24) as f32
}
/// Map each colour to its nearest palette entry, dithering by `amount` (0–1). Pixels `skip`
/// names (transparent) get index 0 and take no diffused error.
fn map(colors: &[[u8; 3]], width: usize, palette: &[[u8; 3]], w: [f32; 3], dither: Dither, amount: f32, skip: impl Fn(usize) -> bool) -> Vec<u8> {
let pal: Vec<[f32; 3]> = palette.iter().map(|c| c.map(f32::from)).collect();
// Nearest entries by colour: exact colours without dithering, a 6-bit grid with it.
let mut exact: HashMap<[u8; 3], u8> = HashMap::new();
let mut grid = vec![u16::MAX; if dither == Dither::None { 0 } else { 1 << 18 }];
let mut lookup = |c: [f32; 3]| -> u8 {
let q = c.map(|v| v.round().clamp(0.0, 255.0) as u8);
if grid.is_empty() {
return *exact.entry(q).or_insert_with(|| nearest(&pal, c, w) as u8);
}
let key = (q[0] as usize >> 2) << 12 | (q[1] as usize >> 2) << 6 | q[2] as usize >> 2;
match grid.get(key) {
Some(&v) if v != u16::MAX => v as u8,
_ => {
let i = nearest(&pal, c, w) as u8;
if let Some(g) = grid.get_mut(key) {
*g = i as u16;
}
i
}
}
};
// Ordered and noise dithering spread a pixel by about one palette step.
let step = 255.0 / (pal.len() as f32).cbrt().max(1.0) * amount;
let width = width.max(1);
let mut out = vec![0u8; colors.len()];
let mut err = vec![[0f32; 3]; 2 * (width + 2)];
for (y, row) in colors.chunks(width).enumerate() {
let (cur, next) = err.split_at_mut(width + 2);
if dither == Dither::Diffusion {
next.fill([0.0; 3]);
}
for (x, c) in row.iter().enumerate() {
let i = y * width + x;
if skip(i) {
continue;
}
let mut v = c.map(f32::from);
match dither {
Dither::None => {}
Dither::Diffusion => {
for ch in 0..3 {
v[ch] = (v[ch] + cur[x + 1][ch]).clamp(0.0, 255.0);
}
}
Dither::Pattern | Dither::Noise => {
let t = if dither == Dither::Pattern { bayer(x % 8, y % 8) } else { noise(x, y) };
v = v.map(|ch| (ch + (t - 0.5) * step).clamp(0.0, 255.0));
}
}
let ix = lookup(v);
out[i] = ix;
if dither == Dither::Diffusion {
let p = pal.get(ix as usize).copied().unwrap_or_default();
let e = [0, 1, 2].map(|ch| (v[ch] - p[ch]) * amount);
// Floyd–Steinberg: 7/16 right, 3/16 below left, 5/16 below, 1/16 below right.
for ch in 0..3 {
cur[x + 2][ch] += e[ch] * 7.0 / 16.0;
next[x][ch] += e[ch] * 3.0 / 16.0;
next[x + 1][ch] += e[ch] * 5.0 / 16.0;
next[x + 2][ch] += e[ch] / 16.0;
}
}
}
if dither == Dither::Diffusion {
cur.copy_from_slice(next);
}
}
out
}
+156
View File
@@ -0,0 +1,156 @@
use vectorcraft_color::cms::{self, IccProfile, ProfileKind};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Node};
use vectorcraft_geom::shapes;
use super::jpeg::{self, ColorModel, JpegOptions, Method};
use super::*;
/// The segments before the image data: (marker, payload).
fn segments(file: &[u8]) -> Vec<(u8, &[u8])> {
let mut out = vec![];
let mut i = 2;
while i + 4 <= file.len() && file[i] == 0xFF {
let marker = file[i + 1];
let len = u16::from_be_bytes([file[i + 2], file[i + 3]]) as usize;
out.push((marker, &file[i + 4..i + 2 + len]));
if marker == 0xDA {
break;
}
i += 2 + len;
}
out
}
/// The frame header (SOF0/SOF2…): (marker, component count).
fn frame(file: &[u8]) -> (u8, u8) {
let (m, p) = segments(file).into_iter().find(|(m, _)| (0xC0..=0xC2).contains(m)).expect("SOF");
(m, p[5])
}
/// Every APP2 ICC chunk joined in order.
fn icc(file: &[u8]) -> Option<Vec<u8>> {
let chunks: Vec<&[u8]> =
segments(file).into_iter().filter(|(m, p)| *m == 0xE2 && p.starts_with(b"ICC_PROFILE\0")).map(|(_, p)| &p[14..]).collect();
(!chunks.is_empty()).then(|| chunks.concat())
}
fn noise(w: u32, h: u32, channels: usize) -> Vec<u8> {
let mut x: u32 = 0x9e37_79b9;
(0..w as usize * h as usize * channels)
.map(|i| {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
// Smooth ramps plus a little noise, as photos and gradients are.
((i / channels) as u32 % w * 255 / w) as u8 ^ (x >> 29) as u8
})
.collect()
}
#[test]
fn size_falls_with_quality() {
let px = noise(64, 48, 3);
let size = |q| jpeg::encode(&px, 64, 48, q, None, &JpegOptions::default()).unwrap().len();
assert!(size(10) < size(50) && size(50) < size(95), "{} {} {}", size(10), size(50), size(95));
assert_eq!(size(0), size(1), "quality 0 is the lowest the coder has");
}
#[test]
fn methods_write_baseline_or_progressive_frames() {
let px = noise(40, 30, 3);
let enc = |method, scans| jpeg::encode(&px, 40, 30, 80, None, &JpegOptions { method, scans, ..Default::default() }).unwrap();
let sos = |f: &[u8]| f.windows(2).filter(|w| w == &[0xFF, 0xDA]).count();
let base = enc(Method::Baseline, 3);
assert_eq!((frame(&base).0, sos(&base)), (0xC0, 1));
let opt = enc(Method::Optimized, 3);
assert_eq!(frame(&opt).0, 0xC0, "optimized tables are still baseline");
assert!(opt.len() < base.len(), "optimized tables are smaller");
let (p3, p5) = (enc(Method::Progressive, 3), enc(Method::Progressive, 5));
assert_eq!(frame(&p3).0, 0xC2, "SOF2");
assert!(sos(&p3) > 1 && sos(&p5) > sos(&p3), "more scans: {} {}", sos(&p3), sos(&p5));
for f in [&base, &p3, &p5] {
let back = image::load_from_memory(f).unwrap();
assert_eq!((back.width(), back.height()), (40, 30));
}
}
#[test]
fn colour_models_write_their_components_and_profiles() {
for (model, comps, kind) in
[(ColorModel::Rgb, 3, ProfileKind::Rgb), (ColorModel::Cmyk, 4, ProfileKind::Cmyk), (ColorModel::Gray, 1, ProfileKind::Gray)]
{
let o = JpegOptions { color_model: model, ..Default::default() };
let file = jpeg::encode(&noise(16, 8, model.channels()), 16, 8, 90, Some(150.0), &o).unwrap();
assert_eq!(frame(&file).1, comps, "{model:?}");
let p = IccProfile::from_bytes(None, &icc(&file).expect("APP2 ICC")).unwrap();
assert_eq!(p.kind, kind, "{model:?}");
let app0 = segments(&file).into_iter().find(|(m, _)| *m == 0xE0).unwrap().1;
assert_eq!(&app0[..5], b"JFIF\0");
assert_eq!(app0[7..12], [1, 0, 150, 0, 150], "dots per inch");
let none = jpeg::encode(&noise(16, 8, model.channels()), 16, 8, 90, None, &JpegOptions { embed_icc: false, ..o }).unwrap();
assert!(icc(&none).is_none());
}
// The CMYK file carries the working CMYK space's profile.
let o = JpegOptions { color_model: ColorModel::Cmyk, ..Default::default() };
let file = jpeg::encode(&[0; 4], 1, 1, 90, None, &o).unwrap();
assert_eq!(&icc(&file).unwrap()[..], &cms::icc_bytes(&cms::active_settings().cmyk).unwrap()[..]);
assert!(jpeg::encode(&[0; 3], 70_000, 1, 90, None, &JpegOptions::default()).is_err(), "too wide");
assert!(jpeg::encode(&[0; 2], 1, 1, 90, None, &JpegOptions::default()).is_err(), "short buffer");
}
#[test]
fn model_and_method_ids_round_trip() {
for m in ColorModel::ALL {
assert_eq!(ColorModel::from_id(m.id()), Some(m));
assert_eq!(ColorModel::from_id(m.label()), Some(m));
}
assert_eq!(ColorModel::from_id("grey"), Some(ColorModel::Gray));
for m in Method::ALL {
assert_eq!(Method::from_id(m.id()), Some(m));
}
assert_eq!(Method::from_id("bogus"), None);
}
/// A 20×10 pt document: a black-ink rectangle on the left half, an RGB red one on the right.
fn ink_doc() -> Document {
let mut d = Document::new(20.0, 10.0);
let l = d.layers[0].id;
let black = Paint::solid(Color::Cmyk { c: 0.0, m: 0.0, y: 0.0, k: 1.0 });
let red = Paint::solid(Color::Rgb { r: 1.0, g: 0.0, b: 0.0 });
for (rect, paint) in [(Rect::new(0.0, 0.0, 10.0, 10.0), black), (Rect::new(10.0, 0.0, 20.0, 10.0), red)] {
let id = d.alloc_id();
d.insert(Some(l), 0, Node::path(id, shapes::rectangle(rect), Appearance::basic(paint, Paint::None, 0.0))).unwrap();
}
d
}
#[test]
fn cmyk_exports_keep_the_inks_of_cmyk_colours() {
let d = ink_doc();
let o = RasterExportOptions { jpeg: JpegOptions { color_model: ColorModel::Cmyk, ..Default::default() }, ..Default::default() };
let inks = Renderer::new().render_region_inks(&d, d.artboards[0].rect, 1.0, &o.render_options(RasterFormat::Jpeg));
assert_eq!(inks.len(), 20 * 10 * 4);
let at = |x: usize, y: usize| &inks[(y * 20 + x) * 4..][..4];
assert_eq!(at(3, 5), [0, 0, 0, 255], "black ink alone, not a rich black");
let red = at(15, 5);
assert!(red[1] > 200 && red[2] > 200 && red[0] < 40, "RGB red separates to magenta and yellow: {red:?}");
let file = Renderer::new().export_region(&d, d.artboards[0].rect, RasterFormat::Jpeg, &o).unwrap();
assert_eq!(frame(&file).1, 4, "4 components");
let back = image::load_from_memory(&file).unwrap().to_rgb8();
assert!(back.get_pixel(3, 5).0.iter().all(|v| *v < 60), "black reads back dark: {:?}", back.get_pixel(3, 5));
let r = back.get_pixel(15, 5).0;
assert!(r[0] > 180 && r[1] < 90, "red reads back red: {r:?}");
}
#[test]
fn gray_exports_keep_the_lightness() {
let d = ink_doc();
let o = RasterExportOptions { jpeg: JpegOptions { color_model: ColorModel::Gray, ..Default::default() }, ..Default::default() };
let file = Renderer::new().export_region(&d, d.artboards[0].rect, RasterFormat::Jpeg, &o).unwrap();
assert_eq!(frame(&file).1, 1);
let back = image::load_from_memory(&file).unwrap().to_luma8();
let red = back.get_pixel(15, 5)[0];
// sRGB red's relative luminance 0.2126, encoded: about 127.
assert!((115..=140).contains(&red), "{red}");
}
+180
View File
@@ -0,0 +1,180 @@
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Node};
use vectorcraft_geom::shapes;
use super::png::{PngOptions, encode_indexed};
use super::quantize::{Dither, Indexed, PaletteOptions, Reduction, quantize};
use super::*;
/// A `w`×`h` opaque image of `f(x, y)` colours.
fn image(w: u32, h: u32, f: impl Fn(u32, u32) -> [u8; 3]) -> Vec<u8> {
(0..h)
.flat_map(|y| (0..w).map(move |x| (x, y)))
.flat_map(|(x, y)| {
let [r, g, b] = f(x, y);
[r, g, b, 255]
})
.collect()
}
/// Smooth colour ramps: thousands of colours.
fn ramps(w: u32, h: u32) -> Vec<u8> {
image(w, h, |x, y| [(x * 255 / w) as u8, (y * 255 / h) as u8, ((x + y) * 127 / (w + h)) as u8])
}
fn rgb(px: &[u8]) -> Vec<[u8; 3]> {
px.chunks(4).map(|p| [p[0], p[1], p[2]]).collect()
}
/// The colour each pixel gets back.
fn colours(ix: &Indexed) -> Vec<[u8; 3]> {
ix.indices.iter().map(|&i| ix.palette[i as usize]).collect()
}
/// The PNG chunk of type `ty` (first match).
fn chunk<'a>(file: &'a [u8], ty: &[u8; 4]) -> Option<&'a [u8]> {
let mut i = 8;
while i + 8 <= file.len() {
let len = u32::from_be_bytes(file[i..i + 4].try_into().unwrap()) as usize;
if &file[i + 4..i + 8] == ty {
return Some(&file[i + 8..i + 8 + len]);
}
i += 12 + len;
}
None
}
#[test]
fn two_colour_art_gives_two_entries() {
let px = image(9, 5, |x, _| if x < 4 { [200, 30, 40] } else { [10, 10, 90] });
for reduction in [Reduction::Perceptual, Reduction::Selective, Reduction::Adaptive] {
let ix = quantize(&px, 9, 5, &PaletteOptions { reduction, ..Default::default() });
assert_eq!(ix.palette.len(), 2, "{reduction:?}");
assert_eq!(ix.transparent, None);
assert_eq!(colours(&ix), rgb(&px), "exact, no dithering");
}
// A hard-edged circle exported on white: black and white only.
let mut d = Document::new(40.0, 30.0);
let id = d.alloc_id();
let l = d.layers[0].id;
let n = Node::path(id, shapes::ellipse(Rect::new(5.3, 4.7, 31.1, 26.2)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
d.insert(Some(l), 0, n).unwrap();
let o = RasterExportOptions { background: Some([255; 3]), anti_alias: AntiAlias::None, ..Default::default() };
let file = Renderer::new().export_region(&d, d.artboards[0].rect, RasterFormat::Png8, &o).unwrap();
assert_eq!(file[25], 3, "colour type 3");
assert_eq!(file[24], 1, "two entries fit one bit per pixel");
assert_eq!(chunk(&file, b"PLTE").unwrap().len(), 6);
}
#[test]
fn colours_cap_the_palette() {
let px = ramps(64, 48);
for colors in [2, 16, 64, 256] {
for dither in Dither::ALL {
for reduction in [Reduction::Perceptual, Reduction::Selective, Reduction::Adaptive, Reduction::Web, Reduction::Gray] {
let ix = quantize(&px, 64, 48, &PaletteOptions { colors, dither, reduction, ..Default::default() });
assert!(ix.palette.len() <= colors as usize, "{colors} {dither:?} {reduction:?}: {}", ix.palette.len());
assert!(ix.indices.iter().all(|&i| (i as usize) < ix.palette.len()));
}
}
}
// Many colours use most of the palette.
assert!(quantize(&px, 64, 48, &PaletteOptions { colors: 16, ..Default::default() }).palette.len() >= 12);
}
#[test]
fn fixed_palettes() {
let px = ramps(32, 32);
let web = quantize(&px, 32, 32, &PaletteOptions { reduction: Reduction::Web, ..Default::default() });
assert!(web.palette.iter().flatten().all(|v| v % 51 == 0), "web-safe");
let gray = quantize(&px, 32, 32, &PaletteOptions { reduction: Reduction::Gray, colors: 8, ..Default::default() });
assert!(gray.palette.iter().all(|[r, g, b]| r == g && g == b) && gray.palette.len() <= 8);
let bw = quantize(&px, 32, 32, &PaletteOptions { reduction: Reduction::BlackWhite, ..Default::default() });
assert_eq!(bw.palette, [[0, 0, 0], [255, 255, 255]]);
}
#[test]
fn dithering_keeps_the_average_colour() {
let px = image(64, 16, |x, _| [(x * 4) as u8; 3]);
let mean = |c: &[[u8; 3]]| c.iter().map(|p| p[0] as f64).sum::<f64>() / c.len().max(1) as f64;
// Per 8-pixel column band, how far the result's mean is from the original's.
let err = |dither| {
let ix = quantize(&px, 64, 16, &PaletteOptions { reduction: Reduction::BlackWhite, dither, ..Default::default() });
let (got, want) = (colours(&ix), rgb(&px));
let band = |c: &[[u8; 3]], b: usize| c.iter().enumerate().filter(|(i, _)| (i % 64) / 8 == b).map(|(_, p)| *p).collect::<Vec<_>>();
(0..8).map(|b| (mean(&band(&got, b)) - mean(&band(&want, b))).abs()).sum::<f64>()
};
let none = err(Dither::None);
for d in [Dither::Diffusion, Dither::Pattern, Dither::Noise] {
assert!(err(d) < none / 2.0, "{d:?}: {} vs {none}", err(d));
}
let noisy = PaletteOptions { dither: Dither::Noise, colors: 8, ..Default::default() };
assert_eq!(quantize(&px, 64, 16, &noisy), quantize(&px, 64, 16, &noisy), "noise is reproducible");
}
#[test]
fn transparent_pixels_get_their_own_entry() {
let px: Vec<u8> = (0..16u8).flat_map(|i| if i % 2 == 0 { [255, 0, 0, 255] } else { [0, 0, 255, if i < 8 { 0 } else { 100 }] }).collect();
let ix = quantize(&px, 4, 4, &PaletteOptions::default());
assert_eq!(ix.transparent, Some(0));
assert_eq!(ix.palette.len(), 2, "red and the transparent entry: alpha 100 is under half");
assert!(ix.indices.iter().enumerate().all(|(i, &v)| (v == 0) == (i % 2 == 1)));
let opaque = quantize(&px, 4, 4, &PaletteOptions { transparency: false, matte: Some([0, 0, 0]), ..Default::default() });
assert_eq!(opaque.transparent, None);
assert!(opaque.palette.contains(&[0, 0, 0]) && opaque.palette.contains(&[0, 0, 100]), "blended over the matte: {:?}", opaque.palette);
let file = encode_indexed(&ix, &PngOptions::default()).unwrap();
assert_eq!(chunk(&file, b"tRNS").unwrap(), [0]);
let back = image::load_from_memory(&file).unwrap().to_rgba8();
assert_eq!(back.get_pixel(0, 0).0, [255, 0, 0, 255]);
assert_eq!(back.get_pixel(1, 0)[3], 0);
}
#[test]
fn png8_decodes_at_every_bit_depth_and_interlaced() {
for (colors, depth) in [(2, 1), (4, 2), (16, 4), (200, 8)] {
let px = ramps(13, 11);
let ix = quantize(&px, 13, 11, &PaletteOptions { colors, ..Default::default() });
for interlaced in [false, true] {
let file = encode_indexed(&ix, &PngOptions { ppi: Some(144.0), interlaced }).unwrap();
assert_eq!((file[24], file[25], file[28]), (depth, 3, u8::from(interlaced)));
let back = image::load_from_memory(&file).unwrap().to_rgb8();
let want: Vec<u8> = colours(&ix).into_iter().flatten().collect();
assert_eq!(back.into_raw(), want, "{colors} colours, interlaced {interlaced}");
assert!(chunk(&file, b"pHYs").is_some());
}
}
}
#[test]
fn gif_decodes() {
let px: Vec<u8> = ramps(21, 17).chunks(4).enumerate().flat_map(|(i, p)| [p[0], p[1], p[2], if i % 5 == 0 { 0 } else { 255 }]).collect();
let ix = quantize(&px, 21, 17, &PaletteOptions { colors: 32, ..Default::default() });
for interlaced in [false, true] {
let file = gif::encode(&ix, interlaced).unwrap();
assert_eq!(&file[..6], b"GIF89a");
let back = image::load_from_memory_with_format(&file, image::ImageFormat::Gif).unwrap().to_rgba8();
assert_eq!(back.dimensions(), (21, 17));
for (i, p) in back.pixels().enumerate() {
match ix.indices[i] {
0 => assert_eq!(p[3], 0, "transparent at {i}"),
v => {
let [r, g, b] = ix.palette[v as usize];
assert_eq!(p.0, [r, g, b, 255], "{i} interlaced {interlaced}");
}
}
}
}
let wide = Indexed { width: 70_000, height: 1, palette: vec![[0; 3]], transparent: None, indices: vec![0; 70_000] };
assert!(gif::encode(&wide, false).is_err());
}
#[test]
fn ids_round_trip() {
for r in Reduction::ALL {
assert_eq!(Reduction::from_id(r.id()), Some(r));
}
for d in Dither::ALL {
assert_eq!(Dither::from_id(d.id()), Some(d));
}
}
+12
View File
@@ -225,6 +225,18 @@ pub(crate) fn compose(cmy: &[u8], k: &[u8], background: Option<[u8; 4]>) -> Vec<
out
}
/// Each pixel's ink amounts (C, M, Y, K bytes, 0 = no ink) from its ink planes (premultiplied
/// RGBA8 drawn with [`Ink::Cmy`] and [`Ink::K`]): a plane holds the complement of its inks, so
/// the ink is coverage minus plane value (partly covered pixels carry that much ink).
pub(crate) fn amounts(cmy: &[u8], k: &[u8]) -> Vec<u8> {
cmy.as_chunks::<4>()
.0
.iter()
.zip(k.as_chunks::<4>().0)
.flat_map(|(p, q)| [p[3].saturating_sub(p[0]), p[3].saturating_sub(p[1]), p[3].saturating_sub(p[2]), q[3].saturating_sub(q[0])])
.collect()
}
impl Renderer {
/// Placed image `key` (decoded as `pm`) as painted with `ink`, cached.
pub(crate) fn ink_image(&mut self, key: &str, pm: &Arc<Pixmap>, ink: Ink) -> Arc<Pixmap> {
+21 -3
View File
@@ -397,6 +397,12 @@ impl Renderer {
/// Render `doc` into a `width`×`height` image using `view` (document → pixel transform).
pub fn render(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions) -> Rendered {
self.render_as(doc, width, height, view, opts, false)
}
/// [`Self::render`], or with `inks` the ink amounts in the working CMYK space instead of
/// screen colours (4 bytes a pixel, see [`Self::render_region_inks`]).
fn render_as(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions, inks: bool) -> Rendered {
let start = now();
let prepared = proof::prepare(doc, opts);
let doc: &Document = &prepared;
@@ -417,13 +423,13 @@ impl Renderer {
self.stats.micros = now().saturating_sub(start);
return Rendered { width: w as u32, height: h as u32, pixels };
}
let mut pixels = if ink::blends_in_cmyk(doc, opts) {
let mut pixels = if inks || ink::blends_in_cmyk(doc, opts) {
// Blending in CMYK: one frame per ink plane, then their inks shown on screen.
let cmy = self.draw_frame(&Frame { ink: Ink::Cmy, ..frame }, w, h);
let stats = self.stats;
let k = self.draw_frame(&Frame { ink: Ink::K, ..frame }, w, h);
self.stats = stats;
ink::compose(&cmy, &k, opts.background)
if inks { ink::amounts(&cmy, &k) } else { ink::compose(&cmy, &k, opts.background) }
} else {
self.draw_frame(&frame, w, h)
};
@@ -441,7 +447,9 @@ impl Renderer {
if self.shadows.len() > 256 {
self.shadows.retain(|_, e| g - e.stamp <= 3);
}
proof::post(&mut pixels, opts);
if !inks {
proof::post(&mut pixels, opts);
}
self.stats.micros = now().saturating_sub(start);
Rendered { width: w as u32, height: h as u32, pixels }
}
@@ -536,6 +544,16 @@ impl Renderer {
self.render(doc, w, h, view, opts)
}
/// `region` of `doc` as ink amounts in the working CMYK space, 4 bytes a pixel (0 = no ink):
/// drawn as the two ink planes of [`ink`], so CMYK colours keep their own inks (black type
/// stays black ink alone) and RGB ones are separated with the colour settings. Where nothing
/// is drawn (no background) there is no ink. CMYK exports draw this.
pub fn render_region_inks(&mut self, doc: &Document, region: Rect, scale: f64, opts: &RenderOptions) -> Vec<u8> {
let (w, h) = region_pixels(region, scale);
let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0));
self.render_as(doc, w, h, view, opts, true).pixels
}
/// Render a single node (thumbnails, previews) fitted into `size`×`size` pixels.
pub fn render_thumbnail(&mut self, doc: &Document, id: NodeId, size: u32) -> Option<Rendered> {
self.render_node_thumbnail(doc, doc.node(id)?, size, None)
+7 -3
View File
@@ -1,14 +1,14 @@
//! File → Export → Export As…: the format and Use Artboards (All or a range: one file per
//! artboard, or one page each in a PDF); without artboards the export covers the visible art. OK
//! picks the file, then the format's options dialog follows (PNG/JPEG/WebP Options, SVG Options,
//! and Save PDF for artboards as pages), else the file is written.
//! picks the file, then the format's options dialog follows (PNG/JPEG/WebP/PNG-8/GIF Options, SVG
//! Options, Text Export Options, and Save PDF for artboards as pages), else the file is written.
use std::sync::LazyLock;
use serde_json::{Value, json};
use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, Format};
use super::{DialogSpec, form, png_options, save_pdf, svg_options};
use super::{DialogSpec, form, png_options, save_pdf, svg_options, text_export};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, io, widgets};
@@ -109,6 +109,10 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
}
// Artboards as pages go on in Save PDF; the art's bounds are written straight away.
"pdf" if use_artboards => save_pdf::open(app, &json!({ "path": path, "range": (!all).then_some(range) })),
"txt" => {
text_export::open(app, &path);
Ok(Value::Null)
}
_ => {
let mut params = json!({ "format": f.id, "path": path, "useArtboards": use_artboards });
if use_artboards {
@@ -102,7 +102,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
let cur = f["format"].as_str().unwrap_or("png").to_string();
egui::ComboBox::from_id_salt(("efs-fmt", i)).selected_text(cur.to_uppercase()).width(70.0).show_ui(ui, |ui| {
for fm in ["png", "jpg", "webp", "svg", "pdf"] {
for fm in ["png", "jpg", "webp", "gif", "png8", "svg", "pdf"] {
if ui.selectable_label(cur == fm, fm.to_uppercase()).clicked() {
f["format"] = json!(fm);
}
+7 -1
View File
@@ -31,6 +31,7 @@ pub mod missing_links;
pub mod new_color_group;
mod new_document;
pub mod new_swatch;
pub mod office_export;
mod path_ops;
pub mod pdf_presets;
pub mod place;
@@ -48,6 +49,7 @@ pub mod spot_colors;
pub(crate) mod svg_options;
pub mod swatch_conflict;
pub mod swatch_options;
mod text_export;
pub mod text_import;
pub mod tile_edge_color;
mod tools;
@@ -192,7 +194,7 @@ registry! {
SvgOptions: [svg_options::KIND] => svg_options::SPEC,
NewDocumentMore: [new_document::MORE] => new_document::MORE_SPEC,
Place: [place::KIND] => place::SPEC,
RasterOptions: ["pngOptions", "jpgOptions", "webpOptions"] => png_options::SPEC,
RasterOptions: ["pngOptions", "jpgOptions", "webpOptions", "gifOptions", "png8Options"] => png_options::SPEC,
ExportAs: ["exportAs"] => export_as::SPEC,
ImportPdf: [import_pdf::KIND] => import_pdf::SPEC,
SwatchConflict: [swatch_conflict::KIND] => swatch_conflict::SPEC,
@@ -203,6 +205,8 @@ registry! {
PdfPresets: [pdf_presets::KIND] => pdf_presets::SPEC,
PdfPreset: [save_pdf::PRESET_KIND] => save_pdf::PRESET_SPEC,
SaveOptions: [save_options::KIND] => save_options::SPEC,
TextExport: [text_export::KIND] => text_export::SPEC,
OfficeExport: [office_export::KIND] => office_export::SPEC,
}
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
@@ -308,3 +312,5 @@ mod tests;
mod tests_export;
#[cfg(test)]
mod tests_import_pdf;
#[cfg(test)]
mod tests_raster_formats;
@@ -0,0 +1,64 @@
//! File → Save for Office Documents…: an artboard as a PNG for office documents and slides, at
//! 72, 150 or 300 ppi, on white or transparent. Fields are `document.exportForOffice` params.
use serde_json::{Value, json};
use super::png_options::{RESOLUTION_LABELS, RESOLUTIONS};
use super::{DialogSpec, form};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, io, widgets};
pub(crate) const KIND: &str = "saveForOffice";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Save for Office Documents".into(), body, confirm, ok: Some("Save…"), min_width: 340.0, ..DialogSpec::FORM };
/// Open the dialog (150 ppi on white, the first artboard).
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
app.session.active().ok_or("no document")?;
app.ui.dialog = Some(Dialog::new(KIND, json!({"ppi": RESOLUTIONS[1], "transparent": false, "artboard": 0})));
Ok(Value::Null)
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let label = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(text).color(t.text_dim));
let Some(doc) = app.session.active().map(|st| &st.doc) else { return true };
let ppi = d.f64("ppi", RESOLUTIONS[1]);
let board = (d.f64("artboard", 0.0).max(0.0) as usize).min(doc.artboards.len().saturating_sub(1));
egui::Grid::new("office-export").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
label(ui, "Resolution:");
let at = RESOLUTIONS.iter().position(|r| *r == ppi).unwrap_or(1);
if let Some(i) = widgets::dropdown(ui, "office-ppi", RESOLUTION_LABELS[at], &RESOLUTION_LABELS[..RESOLUTIONS.len()], 160.0)
&& let Some(r) = RESOLUTIONS.get(i)
{
d.fields.insert("ppi".into(), json!(r));
}
ui.end_row();
if doc.artboards.len() > 1 {
label(ui, "Artboard:");
let names: Vec<&str> = doc.artboards.iter().map(|a| a.name.as_str()).collect();
if let Some(i) = widgets::dropdown(ui, "office-artboard", names.get(board).copied().unwrap_or_default(), &names, 160.0) {
d.fields.insert("artboard".into(), json!(i));
}
ui.end_row();
}
if let Some(a) = doc.artboards.get(board) {
label(ui, "Size:");
let (w, h) = vectorcraft_render::region_pixels(a.rect, ppi / 72.0);
ui.label(egui::RichText::new(format!("{w} × {h} px")).color(t.text));
ui.end_row();
}
});
ui.add_space(8.0);
form::check(ui, d, "transparent", "Transparent Background");
false
}
/// Pick the file and write it (the command runs first: bad options keep the dialog open).
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let path = io::save_command_output(app, "document.exportForOffice", "png", form::params(d))?;
app.ui.dialog = None;
Ok(json!({ "path": path }))
}
+169 -20
View File
@@ -1,11 +1,15 @@
//! The raster export options: PNG Options (resolution, background, anti-aliasing, interlaced),
//! JPEG Options (quality instead of interlacing; no transparency) and WebP Options. Fields are
//! `document.export` params (format, path, artboard choice…); `__`-prefixed ones are the dialog's.
//! JPEG Options (no transparency; colour model, quality 0–10, method and scans, profile, image
//! map), WebP Options, and PNG-8 and GIF Options (the palette: colour reduction, colours, dither,
//! transparency and matte). Fields are `document.export` params (format, path, artboard
//! choice…); `__`-prefixed ones are the dialog's.
use serde::Deserialize;
use serde_json::{Value, json};
use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, Format};
use vectorcraft_render::AntiAlias;
use vectorcraft_render::encode::jpeg::{self, ColorModel, Method};
use vectorcraft_render::encode::quantize::{Dither, PaletteOptions, Reduction};
use super::{DialogSpec, form};
use crate::state::Dialog;
@@ -15,15 +19,26 @@ use crate::{VectorcraftApp, io, widgets};
pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, ok: Some("Export"), min_width: 360.0, ..DialogSpec::FORM };
/// Resolution presets (pixels per inch).
const RESOLUTIONS: [f64; 3] = [72.0, 150.0, 300.0];
pub(super) const RESOLUTIONS: [f64; 3] = [72.0, 150.0, 300.0];
/// Their labels, then `Other` (any resolution).
const RESOLUTION_LABELS: [&str; 4] = ["Screen (72 ppi)", "Medium (150 ppi)", "High (300 ppi)", "Other"];
pub(super) const RESOLUTION_LABELS: [&str; 4] = ["Screen (72 ppi)", "Medium (150 ppi)", "High (300 ppi)", "Other"];
/// Background param values.
const BACKGROUNDS: [&str; 3] = ["transparent", "white", "black"];
/// Their labels, then `Other` (a colour).
const BACKGROUND_LABELS: [&str; 4] = ["Transparent", "White", "Black", "Other"];
/// JPEG image map param values and their labels.
const IMAGE_MAPS: [&str; 3] = ["none", "client", "server"];
const IMAGE_MAP_LABELS: [&str; 3] = ["None", "Client-side (.html)", "Server-side (.map)"];
/// Palette sizes offered (any 2–256 can be typed).
const COLOR_COUNTS: [&str; 8] = ["2", "4", "8", "16", "32", "64", "128", "256"];
/// Matte param values and their labels (`Other`: a colour).
const MATTES: [&str; 3] = ["none", "white", "black"];
const MATTE_LABELS: [&str; 4] = ["None", "White", "Black", "Other"];
/// JPEG quality on the dialog's 0–10 scale: the band each step falls in.
const QUALITY_BANDS: [&str; 11] = ["Low", "Low", "Low", "Medium", "Medium", "Medium", "High", "High", "Maximum", "Maximum", "Maximum"];
/// The dialog kind of a raster format (`pngOptions`, `jpgOptions`, `webpOptions`).
/// The dialog kind of a raster format (`pngOptions`, `jpgOptions`, `webpOptions`, `gifOptions`,
/// `png8Options`).
fn kind(f: &Format) -> String {
format!("{}Options", f.id)
}
@@ -43,10 +58,37 @@ pub fn open(app: &mut VectorcraftApp, f: &Format, mut params: Value) {
o.insert("background".into(), json!(if white { "white" } else { "transparent" }));
o.insert("antiAlias".into(), json!(AntiAlias::default().id()));
match f.id {
"png" => o.insert("interlaced".into(), json!(false)),
"jpg" => o.insert("quality".into(), json!(90)),
_ => None,
};
"png" => {
o.insert("interlaced".into(), json!(false));
}
"jpg" => {
// A CMYK document exports CMYK by default.
let cmyk = app.session.active().is_some_and(|st| st.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk);
let model = if cmyk { ColorModel::Cmyk } else { ColorModel::Rgb };
let defaults = jpeg::JpegOptions::default();
o.extend([
("quality".into(), json!(90)),
("colorModel".into(), json!(model.id())),
("method".into(), json!(defaults.method.id())),
("scans".into(), json!(defaults.scans)),
("embedIcc".into(), json!(defaults.embed_icc)),
("imageMap".into(), json!(IMAGE_MAPS[0])),
]);
}
"gif" | "png8" => {
let p = PaletteOptions::default();
o.extend([
("reduction".into(), json!(p.reduction.id())),
("colors".into(), json!(p.colors)),
("dither".into(), json!(p.dither.id())),
("ditherAmount".into(), json!(p.dither_amount)),
("transparency".into(), json!(p.transparency)),
("matte".into(), json!(MATTES[1])),
("interlaced".into(), json!(false)),
]);
}
_ => {}
}
if let Some((w, h)) = size {
o.insert("__size".into(), json!([w, h]));
}
@@ -109,11 +151,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
d.fields.insert("background".into(), json!(value));
}
if choice == 3 {
let c = vectorcraft_color::Color::from_hex(&bg).map_or([128, 128, 128, 255], |c| c.to_rgba8(1.0));
let mut rgb = [c[0], c[1], c[2]];
if ui.color_edit_button_srgb(&mut rgb).changed() {
d.fields.insert("background".into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])));
}
color_button(ui, d, "background");
}
});
ui.end_row();
@@ -131,12 +169,11 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
form::check(ui, d, "interlaced", "Interlaced");
ui.end_row();
}
"jpg" => {
label(ui, "Quality:");
let mut q = d.f64("quality", 90.0).round() as u8;
if ui.add(egui::Slider::new(&mut q, 1..=100)).changed() {
d.fields.insert("quality".into(), json!(q));
}
"jpg" => jpeg_rows(ui, d, &label),
"gif" | "png8" => palette_rows(ui, d, &label),
"webp" => {
ui.label("");
label(ui, "Lossless (lossy WebP isn't available yet)");
ui.end_row();
}
_ => {}
@@ -154,6 +191,118 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
false
}
/// The JPEG Options rows: colour model, quality (0–10, stored ×10), method and scans, profile and
/// image map.
fn jpeg_rows(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui, &str) -> egui::Response) {
label(ui, "Color Model:");
choice(ui, d, "colorModel", &ColorModel::ALL.map(ColorModel::id), &ColorModel::ALL.map(ColorModel::label));
ui.end_row();
label(ui, "Quality:");
let mut q = (d.f64("quality", 90.0) / 10.0).round().clamp(0.0, 10.0) as u8;
ui.horizontal(|ui| {
if ui.add(egui::Slider::new(&mut q, 0..=10)).changed() {
d.fields.insert("quality".into(), json!(u32::from(q) * 10));
}
label(ui, QUALITY_BANDS[q as usize]);
});
ui.end_row();
label(ui, "Method:");
ui.horizontal(|ui| {
choice(ui, d, "method", &Method::ALL.map(Method::id), &Method::ALL.map(Method::label));
if Method::from_id(&d.str("method")) == Some(Method::Progressive) {
label(ui, "Scans:");
let mut scans = d.f64("scans", 3.0).round().clamp(*jpeg::SCANS.start() as f64, *jpeg::SCANS.end() as f64) as u8;
if ui.add(egui::DragValue::new(&mut scans).range(jpeg::SCANS)).changed() {
d.fields.insert("scans".into(), json!(scans));
}
}
});
ui.end_row();
label(ui, "Image Map:");
choice(ui, d, "imageMap", &IMAGE_MAPS, &IMAGE_MAP_LABELS);
ui.end_row();
ui.label("");
form::check(ui, d, "embedIcc", "Embed ICC Profile");
ui.end_row();
}
/// The PNG-8 and GIF Options rows: the palette (reduction, colours, dither and amount),
/// transparency and matte, interlacing.
fn palette_rows(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui, &str) -> egui::Response) {
label(ui, "Color Reduction:");
choice(ui, d, "reduction", &Reduction::ALL.map(Reduction::id), &Reduction::ALL.map(Reduction::label));
ui.end_row();
label(ui, "Colors:");
let mut n = d.f64("colors", 256.0).round().clamp(2.0, 256.0) as u16;
ui.horizontal(|ui| {
if let Some(i) = widgets::dropdown(ui, "ro-colors", &n.to_string(), &COLOR_COUNTS, 70.0) {
n = COLOR_COUNTS.get(i).and_then(|c| c.parse().ok()).unwrap_or(n);
d.fields.insert("colors".into(), json!(n));
}
if ui.add(egui::DragValue::new(&mut n).range(2..=256)).changed() {
d.fields.insert("colors".into(), json!(n));
}
});
ui.end_row();
label(ui, "Dither:");
ui.horizontal(|ui| {
choice(ui, d, "dither", &Dither::ALL.map(Dither::id), &Dither::ALL.map(Dither::label));
if Dither::from_id(&d.str("dither")).is_some_and(|x| x != Dither::None) {
let mut amount = d.f64("ditherAmount", 100.0).round().clamp(0.0, 100.0) as u8;
if ui.add(egui::DragValue::new(&mut amount).range(0..=100).suffix("%")).changed() {
d.fields.insert("ditherAmount".into(), json!(amount));
}
}
});
ui.end_row();
label(ui, "Matte:");
let matte = d.str("matte");
let at = MATTES.iter().position(|v| matte.eq_ignore_ascii_case(v)).unwrap_or(3);
ui.horizontal(|ui| {
if let Some(i) = widgets::dropdown(ui, "ro-matte", MATTE_LABELS[at], &MATTE_LABELS, 150.0) {
d.fields.insert("matte".into(), json!(MATTES.get(i).copied().unwrap_or("#808080")));
}
if at == 3 {
color_button(ui, d, "matte");
}
});
ui.end_row();
ui.label("");
ui.horizontal(|ui| {
form::check(ui, d, "transparency", "Transparency");
form::check(ui, d, "interlaced", "Interlaced");
});
ui.end_row();
}
/// A colour button bound to `d.fields[key]` (`"#rrggbb"`; grey when unset).
pub(super) fn color_button(ui: &mut egui::Ui, d: &mut Dialog, key: &str) {
let c = vectorcraft_color::Color::from_hex(&d.str(key)).map_or([128, 128, 128, 255], |c| c.to_rgba8(1.0));
let mut rgb = [c[0], c[1], c[2]];
if ui.color_edit_button_srgb(&mut rgb).changed() {
d.fields.insert(key.into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])));
}
}
/// A dropdown bound to `d.fields[key]`: one of `ids`, shown by its label (the first when unknown).
pub(super) fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, ids: &[&str], labels: &[&str]) {
let cur = d.str(key);
let at = ids.iter().position(|v| v.eq_ignore_ascii_case(&cur)).unwrap_or(0);
if let Some(i) = widgets::dropdown(ui, ("ro", key), labels.get(at).copied().unwrap_or_default(), labels, 150.0)
&& let Some(v) = ids.get(i)
{
d.fields.insert(key.into(), json!(v));
}
}
/// Write the file(s) with the chosen options.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let (format, path) = (d.str("format"), d.str("path"));
+5 -5
View File
@@ -10,11 +10,11 @@ use vectorcraft_engine::cmd::fileio;
use super::*;
use crate::Services;
type Written = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
pub(super) type Written = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
/// An app with `artboards` 60×40 pt artboards whose save dialog answers `/out/<suggested>` and
/// whose writer records what it is given.
fn app(artboards: usize) -> (VectorcraftApp, Written) {
pub(super) fn app(artboards: usize) -> (VectorcraftApp, Written) {
let written = Written::default();
let w = written.clone();
let services = Services {
@@ -33,18 +33,18 @@ fn app(artboards: usize) -> (VectorcraftApp, Written) {
}
/// One headless frame of the dialog layer.
fn frame(app: &mut VectorcraftApp) {
pub(super) fn frame(app: &mut VectorcraftApp) {
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
let mut out = ctx.run_ui(Default::default(), |ui| show(app, ui.ctx()));
out.textures_delta.clear();
}
fn kind(app: &VectorcraftApp) -> Option<&str> {
pub(super) fn kind(app: &VectorcraftApp) -> Option<&str> {
app.ui.dialog.as_ref().map(|d| d.kind.as_str())
}
fn set(app: &mut VectorcraftApp, field: &str, value: serde_json::Value) {
pub(super) fn set(app: &mut VectorcraftApp, field: &str, value: serde_json::Value) {
app.ui.dialog.as_mut().expect("a dialog is open").fields.insert(field.into(), value);
}
@@ -0,0 +1,135 @@
//! The options of the export formats beyond PNG, drawn headlessly: JPEG, PNG-8, GIF and WebP, Text
//! Export Options and Save for Office Documents.
use serde_json::json;
use super::tests_export::{app, frame, kind, set};
use super::*;
/// A JPEG's SOF marker and component count.
fn jpeg_frame(file: &[u8]) -> (u8, u8) {
let mut i = 2;
while !(0xC0..=0xC2).contains(&file[i + 1]) {
i += 2 + u16::from_be_bytes([file[i + 2], file[i + 3]]) as usize;
}
(file[i + 1], file[i + 9])
}
#[test]
fn jpeg_options_pick_the_colour_model_method_and_profile() {
let (mut app, written) = app(1);
app.run("file.exportAs", json!({"format": "jpg"})).unwrap();
confirm(&mut app).unwrap();
assert_eq!(kind(&app), Some("jpgOptions"));
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!(
(d.str("colorModel"), d.str("method"), d.f64("scans", 0.0), d.bool("embedIcc"), d.str("imageMap")),
("rgb".into(), "baseline".into(), 3.0, true, "none".into())
);
frame(&mut app);
set(&mut app, "method", json!("progressive"));
set(&mut app, "scans", json!(4));
set(&mut app, "colorModel", json!("cmyk"));
set(&mut app, "quality", json!(30));
frame(&mut app);
confirm(&mut app).unwrap();
let file = written.borrow()[0].1.clone();
assert_eq!(jpeg_frame(&file), (0xC2, 4), "progressive CMYK");
assert!(file.windows(12).any(|w| w == b"ICC_PROFILE\0"));
// A CMYK document exports CMYK by default.
app.run("object.convertDocumentColorMode", json!({"mode": "cmyk"})).unwrap();
app.run("file.exportAs", json!({"format": "jpg"})).unwrap();
confirm(&mut app).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().str("colorModel"), "cmyk");
}
#[test]
fn palette_options_reduce_the_colours() {
let (mut app, written) = app(1);
app.run("file.exportAs", json!({"format": "gif"})).unwrap();
confirm(&mut app).unwrap();
assert_eq!(kind(&app), Some("gifOptions"));
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((spec(&d.kind).heading)(d), "GIF Options");
assert_eq!(
(d.str("reduction"), d.f64("colors", 0.0), d.str("dither"), d.bool("transparency"), d.str("matte")),
("selective".into(), 256.0, "diffusion".into(), true, "white".into())
);
frame(&mut app);
set(&mut app, "colors", json!(4));
set(&mut app, "dither", json!("pattern"));
set(&mut app, "matte", json!("#336699"));
set(&mut app, "interlaced", json!(true));
frame(&mut app);
confirm(&mut app).unwrap();
let (path, gif) = written.borrow()[0].clone();
assert_eq!(path, "/out/Untitled-1.gif");
assert_eq!(&gif[..6], b"GIF89a");
assert_eq!(gif[10] & 0x07, 1, "a global colour table of 4 entries");
app.run("file.exportAs", json!({"format": "png8"})).unwrap();
confirm(&mut app).unwrap();
assert_eq!(kind(&app), Some("png8Options"));
set(&mut app, "reduction", json!("gray"));
frame(&mut app);
confirm(&mut app).unwrap();
let (path, png) = written.borrow()[1].clone();
assert_eq!(path, "/out/Untitled-1.png");
assert_eq!(png[25], 3, "indexed");
}
#[test]
fn text_export_options_write_the_stories() {
let (mut app, written) = app(1);
app.run("text.create", json!({"x": 5, "y": 30, "text": "Hello\nthere"})).unwrap();
app.run("file.exportAs", json!({"format": "txt"})).unwrap();
confirm(&mut app).unwrap();
assert_eq!(kind(&app), Some("txtOptions"));
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.str("path"), d.str("encoding")), ("/out/Untitled-1.txt".into(), "utf8".into()));
frame(&mut app);
set(&mut app, "lineEndings", json!("crlf"));
set(&mut app, "encoding", json!("bogus"));
assert!(confirm(&mut app).is_err());
assert_eq!(kind(&app), Some("txtOptions"), "a bad option keeps the dialog open");
set(&mut app, "encoding", json!("utf8"));
frame(&mut app);
assert_eq!(confirm(&mut app).unwrap()["path"], "/out/Untitled-1.txt");
assert!(app.ui.dialog.is_none());
let (path, bytes) = written.borrow().last().cloned().unwrap();
assert_eq!((path.as_str(), bytes.as_slice()), ("/out/Untitled-1.txt", &b"Hello\r\nthere\r\n"[..]));
}
#[test]
fn webp_options_say_the_file_is_lossless() {
let (mut app, _) = app(1);
app.run("file.exportAs", json!({"format": "webp"})).unwrap();
confirm(&mut app).unwrap();
assert_eq!(kind(&app), Some("webpOptions"));
frame(&mut app);
}
#[test]
fn save_for_office_documents_picks_the_resolution() {
let (mut app, written) = app(2);
let item = crate::menus::menu_entries(&app).into_iter().find(|e| e.command.as_deref() == Some("document.exportForOffice")).unwrap();
assert_eq!((item.path[0].as_str(), item.label.as_str(), item.enabled), ("File", "Save for Office Documents…", true));
app.run("document.exportForOffice", json!({})).unwrap();
assert_eq!(kind(&app), Some("saveForOffice"));
frame(&mut app);
set(&mut app, "ppi", json!(300));
set(&mut app, "artboard", json!(1));
set(&mut app, "transparent", json!(true));
frame(&mut app);
assert_eq!(confirm(&mut app).unwrap()["path"], "/out/Untitled-1.png");
let img = image::load_from_memory(&written.borrow()[0].1).unwrap().to_rgba8();
assert_eq!(img.dimensions(), (250, 167), "60 × 40 pt at 300 ppi");
assert_eq!(img.get_pixel(0, 0)[3], 0, "transparent");
// With a path (agents), no dialog.
let r = app.run("document.exportForOffice", json!({"path": "/x/o.png", "ppi": 72})).unwrap();
assert_eq!(r["path"], "/x/o.png");
assert!(app.ui.dialog.is_none());
let img = image::load_from_memory(&written.borrow()[1].1).unwrap().to_rgba8();
assert_eq!((img.dimensions(), img.get_pixel(0, 0).0), ((60, 40), [255, 255, 255, 255]));
}
+52
View File
@@ -0,0 +1,52 @@
//! Export As → Text: the Text Export Options (encoding, line endings, selection only) for a
//! picked `.txt` path. Fields are `document.export` params (`format`, `path`…).
use serde_json::{Value, json};
use super::png_options::choice;
use super::{DialogSpec, form};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, io};
pub(crate) const KIND: &str = "txtOptions";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Text Export Options".into(), body, confirm, ok: Some("Export"), min_width: 340.0, ..DialogSpec::FORM };
const ENCODINGS: [&str; 2] = ["utf8", "utf16"];
const ENCODING_LABELS: [&str; 2] = ["UTF-8", "UTF-16 (Unicode)"];
const LINE_ENDINGS: [&str; 2] = ["lf", "crlf"];
const LINE_ENDING_LABELS: [&str; 2] = ["LF (macOS, Linux)", "CRLF (Windows)"];
/// Open the options for exporting the document's text to `path`.
pub fn open(app: &mut VectorcraftApp, path: &str) {
// The platform's own line endings by default.
let eol = if cfg!(windows) { LINE_ENDINGS[1] } else { LINE_ENDINGS[0] };
let fields = json!({"format": "txt", "path": path, "encoding": ENCODINGS[0], "lineEndings": eol, "selectionOnly": false});
app.ui.dialog = Some(Dialog::new(KIND, fields));
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let label = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(text).color(t.text_dim));
egui::Grid::new("text-export").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
label(ui, "Encoding:");
choice(ui, d, "encoding", &ENCODINGS, &ENCODING_LABELS);
ui.end_row();
label(ui, "Line Endings:");
choice(ui, d, "lineEndings", &LINE_ENDINGS, &LINE_ENDING_LABELS);
ui.end_row();
});
ui.add_space(8.0);
let selected = app.session.active().is_some_and(|st| !st.selection.objects.is_empty());
ui.add_enabled_ui(selected, |ui| form::check(ui, d, "selectionOnly", "Selection Only"));
false
}
/// Write the text file (the command runs first, so a bad option keeps the dialog open).
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let path = io::save_command_output(app, "document.export", "txt", form::params(d))?;
app.ui.dialog = None;
Ok(json!({ "path": path }))
}
+4
View File
@@ -790,6 +790,9 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
Ok(Value::Null)
}
"ui.pdfPresetDialog" => crate::dialogs::open_pdf_preset(app, p),
// Save for Office Documents…: its dialog; with options, pick the file (or write `path`).
"document.exportForOffice" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::office_export::open(app),
"document.exportForOffice" => io::save_command_output(app, id, "png", p.clone()).map(|path| json!({ "path": path })),
_ => return None,
};
Some(r)
@@ -1057,6 +1060,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
c("Save a Copy…", "file.saveCopy"),
c("Save as Template…", "file.saveAsTemplate"),
c("Save as PDF…", "file.export.pdf"),
c("Save for Office Documents…", "document.exportForOffice"),
c("Revert", "file.revert"),
Sep,
c("Place…", "file.place"),
+4
View File
@@ -26,6 +26,10 @@
| SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options |
| `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the canvas are placed at the drop point (linked; Shift embeds), elsewhere or with no document open they open. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them |
| `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in the file; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` |
| `app.export` JPEG options | `{path?, format: jpg, quality?: 0–100, colorModel?: rgb \| cmyk \| gray, method?: baseline \| optimized \| progressive, scans?: 3–5, embedIcc?, imageMap?: none \| client \| server}` | `cmyk` writes ink amounts in the working CMYK space (CMYK colours keep their inks; RGB ones are separated with the colour settings); `embedIcc` (default true) embeds sRGB, the working CMYK profile or a grey profile, generated by the CMS; `imageMap` also writes `<stem>.html` (client-side `<map>`) or `<stem>.map` (NCSA) beside the image for the objects with a URL and an Image Map shape (`attributes.set`), returned in `linked`. The `jpgOptions` dialog has these fields too (`quality` is shown 0–10; a CMYK document defaults to `cmyk`) |
| `app.export` PNG-8 and GIF | `{path?, format: png8 \| gif, colors?: 2–256, reduction?: perceptual \| selective \| adaptive \| web \| blackWhite \| gray, dither?: none \| diffusion \| pattern \| noise, ditherAmount?: 0–100, transparency?, matte?, interlaced?, …raster options}` | a palette of at most `colors` entries (art with that many colours or fewer keeps them exactly); pixels under half opacity become one transparent entry unless `transparency` is false, partly transparent edges are blended over `matte` (`none` keeps their colour). `png8` writes an indexed `.png` (1–8 bits per pixel); Export for Screens rows take both. The `png8Options` / `gifOptions` dialogs have these fields |
| `app.export` Text and WebP | `{path?, format: txt, encoding?: utf8 \| utf16, lineEndings?: lf \| crlf, selectionOnly?}` / `{format: webp, lossless?, quality?}` | Text writes the stories in stacking order (back to front; a thread once; hidden objects and template layers left out), each line ended by `lineEndings`; `utf16` is little-endian with a byte order mark. Export As → Text opens `txtOptions` (fields `path`, `encoding`, `lineEndings`, `selectionOnly`; OK writes the file). WebP is always lossless: `lossless: false` adds a warning |
| `engine.execute` `document.exportForOffice` | `{}` / `{path?, ppi?: 72 \| 150 \| 300, transparent?, artboard?}` | File → Save for Office Documents…: without params opens `saveForOffice` (fields `ppi`, `transparent`, `artboard`; OK picks the file); with params writes the artboard as a PNG (`path`, else a picked file) → `{path}`. Headless it returns `{dataBase64, width, height}` |
| `ui.dialog.*` on `importPdf` | `{field: "page" \| "allPages" \| "range" \| "cropTo" \| "password", value}` | opening (`app.open`, `file.open`) a PDF or `.ai` with several pages or a password, or placing one (`file.place` without `page`), opens the Import PDF dialog (`mode`: `open` or `place`; `pageCount`). While `__locked` is true, set `password` and `ui.dialog.confirm` to unlock it (a one-page file then opens or is placed). Then set `allPages: false` and `range` ("2-3, 5") to open some pages, or `page` to place one, and `cropTo` (`bounding`, `art`, `crop`, `trim`, `bleed`, `media`); `ui.dialog.confirm` opens or places them (placing keeps the other `file.place` params, such as `link`, `template` and `at`, in `__place`). `document.open {pages, cropTo, password}` and `file.place {page, crop, password}` do the same without the dialog |
+1 -1
View File
@@ -64,7 +64,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
| `screenshot` | `{path?, scale?, artboard?, window?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard; `window:true` captures the app window (remote only). |
| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, PNG, JPEG, GIF, WebP, TIFF, BMP (an image opens as a document of its pixel size). Templates open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. `run_command document.formats` lists the formats. |
| `save_file` | `{path?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). |
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `pdf`, `png`, `jpg`, `webp` or `vectorcraft` (the list comes from `document.formats`). When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds; `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `pdf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `txt` (the document's text) or `vectorcraft` (the list comes from `document.formats`). When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds; `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |
| `add_text` | `{text, x?, y?, width?, height?, path?, mode?, pathEffect?, size?, font?, color?}` | Point type at (x, y); area type with `width`/`height`; or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`). |
| `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. |
| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group. |