mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 03:37:00 -06:00
Part of #868: Save As .ai offers Legacy (version 7), with Transparent Images and an artboard range
aiVersion 7 saves through the legacy writer, one file per artboard; range picks the artboards
and document.save writes them again. Appearances are expanded first. Transparent images
(see-through pixels, opacity or a blending mode) are flattened with the art under them into
opaque images clipped to where they change it (transparentImages "flatten", the default), or
left out ("omit"); both warn.
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
//! Save As › `.ai` with `aiVersion: "7"`: legacy `.ai` files of version 7 (the PostScript-form `.ai`
|
||||
//! that `vectorcraft_eps::export_legacy_ai` writes), which keep the layers, groups, compound
|
||||
//! paths and clipping groups that a PDF-compatible `.ai` loses in apps reading only its PDF part
|
||||
//! (#868).
|
||||
//!
|
||||
//! The format has one artboard: each chosen artboard (default all) is a file of its own, holding
|
||||
//! the art that touches it, named `{stem}-{artboard}.ai` beside the path when there are several.
|
||||
//! Appearances the format can't hold (several fills or strokes, effects, brushes) are expanded into
|
||||
//! objects. Transparency isn't flattened: flattening merges the art that overlaps transparent
|
||||
//! objects, across layers, into one flattened result, which empties the layers under them, and
|
||||
//! editable layers are what this format is saved for. Transparent objects are written opaque (a
|
||||
//! warning says so).
|
||||
//!
|
||||
//! Images are the exception (`transparentImages`): an image with see-through pixels, opacity or a
|
||||
//! blending mode written opaque is a box hiding the art under it (version 7 images have no alpha
|
||||
//! channel, §8.1). With `flatten` (the default) each becomes an opaque image of itself and the art
|
||||
//! under it, rendered from the document as it is, clipped to the area where it changes what shows;
|
||||
//! the art under it stays as it was. With `omit` they are left out.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_color::BlendMode;
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
|
||||
use vectorcraft_geom::{Anchor, FillRule, PathData, Point, Rect, SubPath};
|
||||
|
||||
use super::super::*;
|
||||
use super::{ArtboardPick, Encoded, Format};
|
||||
|
||||
/// Does `p` ask for a legacy `.ai` file of version 7 (`aiVersion: "7"`)? An unknown version is an error.
|
||||
pub(super) fn wanted(cmd: &str, p: &Value) -> Result<bool> {
|
||||
match p.get("aiVersion") {
|
||||
None | Some(Value::Null) => Ok(false),
|
||||
Some(Value::String(v)) if v.trim().is_empty() || v.eq_ignore_ascii_case("pdf") => Ok(false),
|
||||
Some(Value::String(v)) if v.trim() == "7" => Ok(true),
|
||||
Some(Value::Number(n)) if n.as_f64() == Some(7.0) => Ok(true),
|
||||
Some(v) => Err(bad(cmd, format!("aiVersion {v}: \"pdf\" (PDF-compatible, the default) or \"7\" (legacy .ai, version 7)"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a legacy `.ai` does with the images it can't hold (`transparentImages`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum SeeThrough {
|
||||
Flatten,
|
||||
Omit,
|
||||
}
|
||||
|
||||
fn see_through(cmd: &str, p: &Value) -> Result<SeeThrough> {
|
||||
match p.get("transparentImages") {
|
||||
None | Some(Value::Null) => Ok(SeeThrough::Flatten),
|
||||
Some(Value::String(v)) if v.trim().is_empty() || v.trim().eq_ignore_ascii_case("flatten") => Ok(SeeThrough::Flatten),
|
||||
Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("omit") => Ok(SeeThrough::Omit),
|
||||
Some(v) => Err(bad(cmd, format!("transparentImages {v}: \"flatten\" (the default) or \"omit\""))),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the format loses against a VectorCraft document, reported with every save.
|
||||
const FIDELITY: &str = "a legacy .ai (version 7) keeps layers, groups, compound paths, clipping groups, colours, gradients and images, not the rest: save as VectorCraft to keep everything editable";
|
||||
const FLATTENED: &str =
|
||||
"transparent images (see-through pixels, opacity or a blending mode) are flattened with the art under them into opaque images";
|
||||
const OMITTED: &str = "transparent images (see-through pixels, opacity or a blending mode) are left out";
|
||||
const SEPARATE: &str = "legacy .ai files have one artboard: each artboard is saved to a file of its own";
|
||||
|
||||
/// Encode `doc` as legacy `.ai` files of version 7: one per chosen artboard (`artboard`, `artboards` or
|
||||
/// `range`; default all).
|
||||
pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Encoded> {
|
||||
let n = doc.artboards.len();
|
||||
if n == 0 {
|
||||
return Err(bad(cmd, "the document has no artboard"));
|
||||
}
|
||||
let mode = see_through(cmd, p)?;
|
||||
let picked: ArtboardPick = super::encode::options(f, p)?;
|
||||
let boards = picked.resolve(n).map_err(|e| bad(cmd, e))?.unwrap_or_else(|| (0..n).collect());
|
||||
let mut enc = Encoded { warnings: vec![FIDELITY.to_string()], ..Encoded::default() };
|
||||
if boards.len() > 1 {
|
||||
enc.warnings.push(SEPARATE.to_string());
|
||||
}
|
||||
let created = vectorcraft_doc::metadata::now_unix();
|
||||
for b in boards {
|
||||
let one = super::save::artboard_doc(doc, b);
|
||||
let (full, placed) = crate::cmd::place::document::full_documents(&one);
|
||||
let mut d = full.into_owned();
|
||||
expand_appearances(&mut d);
|
||||
let images = see_through_images(&d);
|
||||
if !images.is_empty() {
|
||||
note(&mut enc.warnings, if mode == SeeThrough::Flatten { FLATTENED } else { OMITTED });
|
||||
if mode == SeeThrough::Flatten {
|
||||
flatten_images(&mut d, &images)?;
|
||||
} else {
|
||||
for (_, id) in images {
|
||||
let _ = d.remove(id); // Inside an image left out before: gone already.
|
||||
}
|
||||
}
|
||||
}
|
||||
let o = vectorcraft_eps::LegacyAiOptions { title: doc.title.clone(), created };
|
||||
let out = vectorcraft_eps::export_legacy_ai(&d, &o).map_err(|e| bad(cmd, e))?;
|
||||
for w in placed.iter().chain(&out.warnings) {
|
||||
note(&mut enc.warnings, w);
|
||||
}
|
||||
enc.files.push((Some(b), out.bytes));
|
||||
}
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
/// Expand Appearance of every layer's art whose appearance the format can't hold.
|
||||
fn expand_appearances(d: &mut Document) {
|
||||
let brushes = vectorcraft_brush::library(d);
|
||||
let layers: Vec<NodeId> = d.layers.iter().map(|l| l.id).collect();
|
||||
let mut out = vec![];
|
||||
for id in layers {
|
||||
super::super::effectcmd::expand_node(d, id, &brushes, &mut out);
|
||||
}
|
||||
}
|
||||
|
||||
/// The images of `d` a legacy `.ai` can't hold, in paint order with their places in it (as
|
||||
/// [`Document::walk`] counts): visible ones with see-through pixels, opacity or a blending mode.
|
||||
fn see_through_images(d: &Document) -> Vec<(usize, NodeId)> {
|
||||
let keys = super::super::flatten::see_through_images(d);
|
||||
let mut out = vec![];
|
||||
let mut at = 0;
|
||||
d.walk(|n| {
|
||||
if let NodeKind::Image(im) = &n.kind
|
||||
&& (keys.contains(&im.key) || n.opacity < 1.0 || n.blend != BlendMode::Normal)
|
||||
{
|
||||
out.push((at, n.id));
|
||||
}
|
||||
at += 1;
|
||||
});
|
||||
out.retain(|(_, id)| d.is_visible(*id));
|
||||
out
|
||||
}
|
||||
|
||||
/// Replace each of `images` (from [`see_through_images`]) with an opaque image of what shows
|
||||
/// where it is, clipped to where it changes that; one that changes nothing is left out. Each is
|
||||
/// rendered from `d` as it was, so images over one another flatten as they look.
|
||||
fn flatten_images(d: &mut Document, images: &[(usize, NodeId)]) -> Result<()> {
|
||||
let src = d.clone();
|
||||
for &(at, id) in images {
|
||||
let Some((parent, idx, _)) = d.position(id) else { continue };
|
||||
let flat = flattened(&src, at, id)?;
|
||||
d.remove(id)?;
|
||||
if let Some(flat) = flat {
|
||||
let png = flat.png;
|
||||
let key = super::super::menucmds::unique_key(d, "flattened");
|
||||
d.images.insert(key.clone(), ImageBlob::new("image/png", png));
|
||||
let image = ImageObject { key, width: flat.width, height: flat.height, xf: flat.xf, link: None, placement: Default::default() };
|
||||
let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)));
|
||||
let mut group = super::super::rasterfx::clip_group(d, flat.clip, FillRule::NonZero, image);
|
||||
group.name = src.node(id).and_then(|n| n.name.clone());
|
||||
d.insert(parent, idx, group)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An image made by [`flattened`]: PNG pixels, their place, and the outline they are clipped to.
|
||||
struct Flat {
|
||||
png: Vec<u8>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
xf: Affine,
|
||||
clip: PathData,
|
||||
}
|
||||
|
||||
/// Highest and lowest resolution of a flattened image (pixels per inch); it takes the image's own
|
||||
/// in between.
|
||||
const MAX_PPI: f64 = 600.0;
|
||||
const MIN_PPI: f64 = 72.0;
|
||||
/// A rendered pixel channel changes when it changes by more than this.
|
||||
const CHANGED: u8 = 2;
|
||||
/// The clip outline follows cells of about this many points.
|
||||
const CELL: f64 = 2.0;
|
||||
|
||||
/// The image `id` (the `at`th node in paint order of `src`) flattened: `src` drawn up to and
|
||||
/// including it, over white, cropped and clipped to the cells where drawing it changes the
|
||||
/// result. `None` when it changes nothing.
|
||||
fn flattened(src: &Document, at: usize, id: NodeId) -> Result<Option<Flat>> {
|
||||
let (Some(n), Some(bounds)) = (src.node(id), src.bounds_of(&[id], true)) else { return Ok(None) };
|
||||
let NodeKind::Image(im) = &n.kind else { return Ok(None) };
|
||||
// The image's own resolution (points per pixel from its placement).
|
||||
let pt = im.xf.determinant().abs().sqrt();
|
||||
let ppi = if pt.is_finite() && pt > 0.0 { (72.0 / pt).clamp(MIN_PPI, MAX_PPI) } else { MIN_PPI };
|
||||
let area = (bounds.width() * bounds.height()).max(1.0);
|
||||
let scale = (ppi / 72.0).min((super::super::menucmds::MAX_PIXELS / area).sqrt());
|
||||
if !(scale.is_finite() && scale > 0.0) {
|
||||
return Ok(None);
|
||||
}
|
||||
let region = Rect::new(
|
||||
bounds.x0,
|
||||
bounds.y0,
|
||||
bounds.x0 + (bounds.width() * scale).ceil().max(1.0) / scale,
|
||||
bounds.y0 + (bounds.height() * scale).ceil().max(1.0) / scale,
|
||||
);
|
||||
let mut with = src.clone();
|
||||
let mut i = 0;
|
||||
with.layers = src.layers.iter().filter_map(|l| up_to(l, at, &mut i)).map(Arc::new).collect();
|
||||
let mut without = with.clone();
|
||||
without.remove(id)?;
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let shown = r.render_region(&with, region, scale, true);
|
||||
let under = r.render_region(&without, region, scale, true);
|
||||
let (w, h) = (shown.width as usize, shown.height as usize);
|
||||
if w == 0 || h == 0 || shown.pixels.len() != w * h * 4 || under.pixels.len() != shown.pixels.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
// The cells where it changes anything.
|
||||
let c = (CELL * scale).round().max(1.0) as usize;
|
||||
let (cw, ch) = (w.div_ceil(c), h.div_ceil(c));
|
||||
let mut cells = vec![false; cw * ch];
|
||||
for (k, (a, b)) in shown.pixels.as_chunks::<4>().0.iter().zip(under.pixels.as_chunks::<4>().0).enumerate() {
|
||||
if a.iter().zip(b).any(|(a, b)| a.abs_diff(*b) > CHANGED) {
|
||||
let (x, y) = (k % w, k / w);
|
||||
if let Some(cell) = cells.get_mut(y / c * cw + x / c) {
|
||||
*cell = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let marked = || cells.iter().enumerate().filter(|(_, on)| **on).map(|(k, _)| (k % cw, k / cw));
|
||||
let (Some(x0), Some(x1), Some(y0), Some(y1)) =
|
||||
(marked().map(|p| p.0).min(), marked().map(|p| p.0).max(), marked().map(|p| p.1).min(), marked().map(|p| p.1).max())
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
// Crop to them.
|
||||
let (px0, py0) = (x0 * c, y0 * c);
|
||||
let (px1, py1) = (((x1 + 1) * c).min(w), ((y1 + 1) * c).min(h));
|
||||
let mut pixels = Vec::with_capacity((px1 - px0) * (py1 - py0) * 4);
|
||||
for y in py0..py1 {
|
||||
pixels.extend_from_slice(shown.pixels.get((y * w + px0) * 4..(y * w + px1) * 4).unwrap_or_default());
|
||||
}
|
||||
let crop = vectorcraft_render::Rendered { width: (px1 - px0) as u32, height: (py1 - py0) as u32, pixels };
|
||||
let png = crop.to_png().map_err(EngineError::Other)?;
|
||||
let at = |x: usize, y: usize| Point::new(region.x0 + (x.min(w)) as f64 / scale, region.y0 + (y.min(h)) as f64 / scale);
|
||||
let mut clip = PathData::new(vec![]);
|
||||
for comp in vectorcraft_trace::trace_mask(&cells, cw, ch) {
|
||||
for lp in std::iter::once(&comp.outer).chain(&comp.holes) {
|
||||
let pts: Vec<(i32, i32)> = corners(&lp.pts);
|
||||
let anchors = pts.iter().map(|&(x, y)| Anchor::corner(at(x.max(0) as usize * c, y.max(0) as usize * c))).collect();
|
||||
clip.subpaths.push(SubPath::new(anchors, true));
|
||||
}
|
||||
}
|
||||
let xf = Affine::translate(at(px0, py0).to_vec2()) * Affine::scale(1.0 / scale);
|
||||
Ok(Some(Flat { png, width: crop.width, height: crop.height, xf, clip }))
|
||||
}
|
||||
|
||||
/// `n` without the nodes after the `last`th in paint order (`i` counts them, as
|
||||
/// [`Document::walk`] does); `None` when `n` itself comes after it.
|
||||
fn up_to(n: &Node, last: usize, i: &mut usize) -> Option<Node> {
|
||||
if *i > last {
|
||||
return None;
|
||||
}
|
||||
*i += 1;
|
||||
let mut n = n.clone();
|
||||
if let Some(ch) = n.children_mut() {
|
||||
let kept: Vec<Arc<Node>> = ch.iter().filter_map(|c| up_to(c, last, i)).map(Arc::new).collect();
|
||||
*ch = kept;
|
||||
}
|
||||
Some(n)
|
||||
}
|
||||
|
||||
/// A loop's corners: its points without those in the middle of a straight run.
|
||||
fn corners(pts: &[(i32, i32)]) -> Vec<(i32, i32)> {
|
||||
let n = pts.len();
|
||||
let get = |k: usize| pts.get(k % n.max(1)).copied();
|
||||
(0..n)
|
||||
.filter_map(|k| {
|
||||
let (a, b, c) = (get(k + n - 1)?, get(k)?, get(k + 1)?);
|
||||
let straight = (a.0 == b.0 && b.0 == c.0) || (a.1 == b.1 && b.1 == c.1);
|
||||
(!straight).then_some(b)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn note(warnings: &mut Vec<String>, w: &str) {
|
||||
if !warnings.iter().any(|x| x == w) {
|
||||
warnings.push(w.to_string());
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ mod encode;
|
||||
pub mod eps;
|
||||
mod export;
|
||||
pub(crate) mod imagemap;
|
||||
mod legacy_ai;
|
||||
mod load;
|
||||
pub(crate) use load::{MAX_RASTER_ALLOC, too_large_note};
|
||||
mod metafile;
|
||||
|
||||
@@ -99,6 +99,18 @@ pub const AI_OPTIONS: &[FormatOption] = &[
|
||||
default: "true",
|
||||
description: "compress the PDF's content and the native document it carries (the PDF option compression.compressText)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "aiVersion",
|
||||
ty: "string",
|
||||
default: "\"pdf\"",
|
||||
description: "pdf (a PDF-compatible .ai carrying the VectorCraft document) or 7 (the legacy, PostScript-form .ai of version 7, which keeps layers, groups, compound paths and clipping groups in apps that read it; one file per artboard, transparent objects opaque, type as outlines; the other .ai options don't apply)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "transparentImages",
|
||||
ty: "string",
|
||||
default: "\"flatten\"",
|
||||
description: "with aiVersion 7, the images version 7 can't hold (see-through pixels, opacity or a blending mode): flatten (the default: each becomes an opaque image of itself and the art under it, clipped to where it changes what shows; that art stays as it is) or omit (left out)",
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
|
||||
@@ -335,7 +335,8 @@ impl<W: std::io::Write> std::io::Write for Counted<W> {
|
||||
/// The artboards a native or `.ai` save also writes to files of their own: with
|
||||
/// `separateArtboards`, those `range` names (default all).
|
||||
fn separate_boards(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<usize>> {
|
||||
if !is_native(f) || !bool_or(p, "separateArtboards", false) {
|
||||
// A legacy .ai file is a file per artboard already.
|
||||
if !is_native(f) || !bool_or(p, "separateArtboards", false) || super::legacy_ai::wanted(cmd, p)? {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let range = str_param(p, "range").filter(|r| !r.trim().is_empty()).map(str::to_string);
|
||||
@@ -346,7 +347,7 @@ fn separate_boards(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<V
|
||||
|
||||
/// `doc` reduced to artboard `i` and the art on it (Save each artboard to a separate file):
|
||||
/// objects whose bounds touch the artboard, in their layers; resources are kept.
|
||||
fn artboard_doc(doc: &Document, i: usize) -> Document {
|
||||
pub(super) fn artboard_doc(doc: &Document, i: usize) -> Document {
|
||||
let mut d = doc.clone();
|
||||
let Some(a) = doc.artboards.get(i) else { return d };
|
||||
let r = a.rect;
|
||||
@@ -387,6 +388,9 @@ const NOT_PDF_COMPATIBLE: &str = "saved without PDF content: VectorCraft opens i
|
||||
|
||||
/// The shared Save and Export encoder for PDF-compatible `.ai` files.
|
||||
pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Encoded> {
|
||||
if super::legacy_ai::wanted(cmd, p)? {
|
||||
return super::legacy_ai::encode(cmd, f, doc, p);
|
||||
}
|
||||
let mut params = if p.is_object() { p.clone() } else { json!({}) };
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.insert("preserveEditing".into(), json!(true));
|
||||
@@ -463,9 +467,14 @@ fn job_for_stamped(s: &Session, st: &DocState, plan: SavePlan, stamped: Option<i
|
||||
}
|
||||
let boards = separate_boards(cmd, plan.format, &doc, ¶ms)?;
|
||||
let mut params = super::pdf::expand_preset(s, cmd, ¶ms)?.into_owned();
|
||||
let legacy = super::legacy_ai::wanted(cmd, ¶ms)?;
|
||||
if let (Some(o), "ai") = (params.as_object_mut(), plan.format.id) {
|
||||
// Save keeps every artboard; exports may choose a subset instead.
|
||||
o.insert("range".into(), json!("all"));
|
||||
// Save keeps every artboard; exports may choose a subset instead. A legacy .ai file per
|
||||
// artboard keeps a range given: the artboards to save.
|
||||
let chosen = legacy && o.get("range").and_then(Value::as_str).is_some_and(|r| !r.trim().is_empty());
|
||||
if !chosen {
|
||||
o.insert("range".into(), json!("all"));
|
||||
}
|
||||
}
|
||||
Ok(SaveJob { plan, doc, params, snapshot, source: st.doc.clone(), stamped, boards })
|
||||
}
|
||||
@@ -507,7 +516,9 @@ impl SaveJob {
|
||||
fn encode_one(&self, doc: &Document) -> Result<Encoded> {
|
||||
let (cmd, f) = (self.plan.mode.command(), self.plan.format);
|
||||
let enc = if f.id == "ai" { encode_ai(cmd, f, doc, &self.params)? } else { encode_all(doc, f.id, &self.params)? };
|
||||
if enc.files.len() != 1 {
|
||||
// A legacy .ai file per artboard.
|
||||
let per_artboard = f.id == "ai" && super::legacy_ai::wanted(cmd, &self.params)?;
|
||||
if enc.files.len() != 1 && !per_artboard {
|
||||
return Err(bad(cmd, "Save writes one artboard: name one, or export several with document.export"));
|
||||
}
|
||||
Ok(enc)
|
||||
|
||||
Reference in New Issue
Block a user