mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 13:47:21 -06:00
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:
co-authored by
Claude Opus 5.5
parent
db9d937e1a
commit
ba4ed2ff93
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user