M4.48: JPEG Options — RGB/CMYK/gray, baseline/optimized/progressive, embedded ICC profiles and image maps
document.export {format: "jpg"} takes colorModel (rgb|cmyk|gray), quality 0–100, method
(baseline|optimized|progressive), scans (3–5), embedIcc and imageMap (none|client|server).
JPEGs are written with jpeg-encoder. CMYK files hold ink amounts drawn as the renderer's ink
planes, so CMYK colours keep their inks (black type stays black ink alone). Profiles are made
in code from the CMS (cms::icc_bytes): the RGB spaces from their primaries, the CMYK spaces as
Lab lookup tables sampled from their model, a grey with sRGB's tone curve, and loaded
profiles as loaded. Image maps write <stem>.html or <stem>.map beside the image for objects
with a URL and an Image Map shape. The JPEG Options dialog gains Color Model, Quality 0–10,
Method and Scans, Image Map and Embed ICC Profile; CMYK documents default to CMYK.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
db9d937e1a
commit
ba4ed2ff93
|
|
@ -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"
|
||||
|
|
@ -4239,6 +4245,7 @@ version = "0.1.1"
|
|||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
"log",
|
||||
"proptest",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ 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"] }
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
|
|
|
|||
3
NOTICE
3
NOTICE
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
IccProfile::from_profile(Some(name.to_string()), p).ok()
|
||||
})
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
})
|
||||
};
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[..]);
|
||||
}
|
||||
|
|
@ -1,16 +1,20 @@
|
|||
//! 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::{RasterExportOptions, RasterFormat};
|
||||
|
||||
use super::super::*;
|
||||
use super::Format;
|
||||
use super::imagemap::{self, MapKind};
|
||||
|
||||
const C: &str = "document.export";
|
||||
|
||||
|
|
@ -65,6 +69,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 +82,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 +97,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 +139,11 @@ 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>,
|
||||
}
|
||||
|
||||
impl RasterOptions {
|
||||
|
|
@ -141,11 +162,22 @@ 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),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
|
|
@ -276,12 +308,18 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("&"),
|
||||
'"' => o.push_str("""),
|
||||
'<' => o.push_str("<"),
|
||||
'>' => o.push_str(">"),
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@
|
|||
mod batch;
|
||||
mod encode;
|
||||
mod export;
|
||||
mod imagemap;
|
||||
mod load;
|
||||
pub mod pdf;
|
||||
mod pdfimport;
|
||||
|
|
@ -63,7 +64,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|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), 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); 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
|
||||
),
|
||||
|
|
@ -181,7 +182,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 +210,31 @@ 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)",
|
||||
};
|
||||
|
||||
/// 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,7 +285,22 @@ 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 {
|
||||
|
|
@ -459,7 +501,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 +570,6 @@ mod tests_raster;
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests_exportas;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_jpeg;
|
||||
|
|
|
|||
|
|
@ -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&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());
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ vello_cpu = { workspace = true }
|
|||
vello_common = { version = "0.2", default-features = false }
|
||||
image = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
jpeg-encoder = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
//! 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) or lossless WebP.
|
||||
|
||||
pub mod jpeg;
|
||||
pub mod png;
|
||||
|
||||
use vectorcraft_doc::{Document, Node, NodeKind};
|
||||
|
|
@ -27,13 +28,15 @@ pub struct RasterExportOptions {
|
|||
pub anti_alias: AntiAlias,
|
||||
/// PNG: Adam7 interlacing.
|
||||
pub interlaced: bool,
|
||||
/// JPEG quality 1–100.
|
||||
/// JPEG quality 0–100.
|
||||
pub quality: u8,
|
||||
/// JPEG colour model, coding and profile.
|
||||
pub jpeg: jpeg::JpegOptions,
|
||||
}
|
||||
|
||||
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() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -55,13 +58,20 @@ 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),
|
||||
}
|
||||
}
|
||||
|
|
@ -69,35 +79,24 @@ impl RasterExportOptions {
|
|||
|
||||
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 +130,5 @@ fn drawn_bounds(n: &Node) -> Option<Rect> {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_jpeg;
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -308,3 +308,5 @@ mod tests;
|
|||
mod tests_export;
|
||||
#[cfg(test)]
|
||||
mod tests_import_pdf;
|
||||
#[cfg(test)]
|
||||
mod tests_raster_formats;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
//! 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) and WebP Options. 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 super::{DialogSpec, form};
|
||||
use crate::state::Dialog;
|
||||
|
|
@ -22,6 +24,11 @@ const RESOLUTION_LABELS: [&str; 4] = ["Screen (72 ppi)", "Medium (150 ppi)", "Hi
|
|||
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)"];
|
||||
/// 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`).
|
||||
fn kind(f: &Format) -> String {
|
||||
|
|
@ -43,10 +50,25 @@ 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])),
|
||||
]);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some((w, h)) = size {
|
||||
o.insert("__size".into(), json!([w, h]));
|
||||
}
|
||||
|
|
@ -131,14 +153,7 @@ 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));
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
"jpg" => jpeg_rows(ui, d, &label),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
|
|
@ -154,6 +169,56 @@ 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();
|
||||
}
|
||||
|
||||
/// A dropdown bound to `d.fields[key]`: one of `ids`, shown by its label (the first when unknown).
|
||||
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"));
|
||||
|
|
|
|||
|
|
@ -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,44 @@
|
|||
//! The options of the raster formats beyond PNG, drawn headlessly: JPEG.
|
||||
|
||||
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");
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@
|
|||
| 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`) |
|
||||
|
||||
| `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 |
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue