Merge pull request #1794 from danngreen/feat/legacy-ai-export

Add Legacy .ai export (keeps groups and layers)
This commit is contained in:
BFlat
2026-10-16 01:58:03 +07:00
committed by GitHub
47 changed files with 4090 additions and 49 deletions
+1 -1
View File
@@ -211,7 +211,7 @@ and what's next; [`docs/target-app-parity.md`](docs/target-app-parity.md) the fu
all of Illustrator): everyday
vector illustration is close to usable: drawing and path tools,
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package).
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package).
Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open
and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping
and masks, text and images, with what didn't come in (effects, adjustments, brushes, master pages…) listed in the
+16 -2
View File
@@ -4,7 +4,7 @@
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg]... [--scale 2]
//! vectorcraft-cli commands
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data]
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
//! vectorcraft-cli info FILE
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
//! vectorcraft-cli perf [--paths 50000] [--deep]
@@ -84,7 +84,7 @@ USAGE:
vectorcraft-cli commands
Print the command catalogue as JSON.
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data]
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
Open IN (any readable format) and export OUT in the format its extension picks (see Writable
formats). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
unless one of them is given, EPS the bounds of the art, the other formats the first artboard.
@@ -95,6 +95,11 @@ USAGE:
--replace-lossy is given. --editing-data opens a .ai from its editing copy even where that
draws differently from its PDF part (which can be older), as long as it can be read. The
result has the export's warnings, and importWarnings for what reading IN left out.
--ai-version 7 writes a .ai as the legacy version 7 (layers, groups and type kept; one file
per artboard, OUT-<artboard>.ai when there are several; default pdf, PDF-compatible), with
--hidden-objects (default layer: a hidden layer above each layer's hidden objects; omit;
visible), --transparent-images (default flatten, with the art under them; omit) and
--outline-text (type as outlines instead of text).
vectorcraft-cli info FILE
Print a JSON summary: the import warnings (what didn't come in as it was, such as an EPS
@@ -223,6 +228,9 @@ fn convert(args: &[String]) -> Result<(), String> {
let (mut scale, mut artboard, mut range, mut outline_text, mut replace_lossy, mut editing_data) =
(1.0f64, None::<u64>, None::<String>, false, false, false);
let mut decimals = None::<u64>;
// Legacy `.ai` options, passed on as the export's own (`aiVersion`, `hiddenObjects`,
// `transparentImages`); the engine checks their values.
let mut legacy: Vec<(&str, String)> = vec![];
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
@@ -233,6 +241,9 @@ fn convert(args: &[String]) -> Result<(), String> {
"--decimals" => decimals = Some(it.next().and_then(|v| v.parse::<u64>().ok()).ok_or("--decimals needs a number")?),
"--replace-lossy" => replace_lossy = true,
"--editing-data" => editing_data = true,
"--ai-version" => legacy.push(("aiVersion", it.next().cloned().ok_or("--ai-version needs 7 or pdf")?)),
"--hidden-objects" => legacy.push(("hiddenObjects", it.next().cloned().ok_or("--hidden-objects needs layer, omit or visible")?)),
"--transparent-images" => legacy.push(("transparentImages", it.next().cloned().ok_or("--transparent-images needs flatten or omit")?)),
f => files.push(f.to_string()),
}
}
@@ -250,6 +261,9 @@ fn convert(args: &[String]) -> Result<(), String> {
if let Some(decimals) = decimals {
params["decimals"] = json!(decimals);
}
for (key, value) in legacy {
params[key] = json!(value);
}
let mut r = h.call("engine.execute", json!({"command": "document.export", "params": params})).map_err(|e| format!("export {output}: {e}"))?;
// What reading the input left out (a missing linked file, an unsupported filter…) matters as
// much as what writing the output did.
+54
View File
@@ -115,3 +115,57 @@ fn convert_reports_import_warnings() {
let w = out["importWarnings"].as_array().unwrap_or_else(|| panic!("{out}"));
assert!(w.iter().any(|w| w.as_str().unwrap().contains("nowhere-to-be-found.png")), "{out}");
}
#[test]
fn convert_writes_legacy_ai_with_its_options() {
// Two artboards, a shown rectangle and a hidden one.
let src = tmp("legacy.vectorcraft");
ok(&[
"run",
"--cmd",
"file.new",
"--params",
r#"{"width":120,"height":80,"artboards":2}"#,
"--cmd",
"shape.rectangle",
"--params",
r#"{"x":10,"y":10,"width":20,"height":10}"#,
"--cmd",
"shape.rectangle",
"--params",
r#"{"x":40,"y":10,"width":20,"height":10}"#,
"--cmd",
"object.hide",
"--export",
&src,
]);
// One file per artboard, named after it; hidden objects in a hidden layer by default.
let out = tmp("legacy.ai");
let r: Value = serde_json::from_str(&ok(&["convert", &src, &out, "--ai-version", "7"])).unwrap();
let files: Vec<&str> = r["files"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
assert_eq!(files.len(), 2, "{r}");
assert!(files.iter().all(|f| std::fs::read(f).unwrap().starts_with(b"%!PS-Adobe-3.0")), "{r}");
assert!(r["warnings"].to_string().contains("hidden layer"), "{r}");
// One artboard is the file itself; the hidden rectangle left out.
let one = tmp("legacy-one.ai");
let r: Value = serde_json::from_str(&ok(&[
"convert",
&src,
&one,
"--ai-version",
"7",
"--range",
"1",
"--hidden-objects",
"omit",
"--transparent-images",
"omit",
]))
.unwrap();
assert!(r["warnings"].to_string().contains("hidden objects are left out"), "{r}");
assert!(std::fs::read(&one).unwrap().starts_with(b"%!PS-Adobe-3.0"));
// A value the engine doesn't take is an error.
let bad = Command::new(BIN).args(["convert", &src, &tmp("bad.ai"), "--ai-version", "7", "--hidden-objects", "behind"]).output().unwrap();
assert!(!bad.status.success());
assert!(String::from_utf8_lossy(&bad.stderr).contains("hiddenObjects"), "{}", String::from_utf8_lossy(&bad.stderr));
}
+1 -1
View File
@@ -407,7 +407,7 @@ fn expand_pieces(d: &mut Document, m: &mut Node, all: bool, stroke_art: effects:
/// Expand Appearance of `id` (and, recursively, of a group's or layer's members): every fill and
/// stroke becomes an object of its own (strokes outlined, brushes as their art), geometry effects
/// are baked, raster effects become an embedded image, and the object keeps its transparency.
fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush], out: &mut Vec<NodeId>) {
pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush], out: &mut Vec<NodeId>) {
let Some(mut n) = d.node(id).cloned() else { return };
if !expandable(&n) {
return;
+310
View File
@@ -0,0 +1,310 @@
//! 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.
//!
//! The format can't hide objects, only layers (`hiddenObjects`): with `layer` (the default) each
//! layer's hidden objects go to a hidden layer of their own just above it, with `omit` they are
//! left out, with `visible` they show where they are. Type is written as text the format's reader
//! lays out again, in the fonts it names; `outlineText` writes its outlines instead (as type that
//! 8-bit text can't hold always is).
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,
}
/// Where a legacy `.ai`'s hidden objects go (`hiddenObjects`): the format hides only layers.
fn hidden_objects(cmd: &str, p: &Value) -> Result<vectorcraft_eps::HiddenObjects> {
use vectorcraft_eps::HiddenObjects;
match p.get("hiddenObjects") {
None | Some(Value::Null) => Ok(HiddenObjects::Layer),
Some(Value::String(v)) if v.trim().is_empty() || v.trim().eq_ignore_ascii_case("layer") => Ok(HiddenObjects::Layer),
Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("omit") => Ok(HiddenObjects::Omit),
Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("visible") => Ok(HiddenObjects::Visible),
Some(v) => Err(bad(cmd, format!("hiddenObjects {v}: \"layer\" (the default), \"omit\" or \"visible\""))),
}
}
/// Type as outlines (`outlineText`), not text objects.
fn outline_text(cmd: &str, p: &Value) -> Result<bool> {
match p.get("outlineText") {
None | Some(Value::Null) => Ok(false),
Some(Value::Bool(b)) => Ok(*b),
Some(v) => Err(bad(cmd, format!("outlineText {v}: true or false"))),
}
}
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, type, 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 (hidden, outline_text) = (hidden_objects(cmd, p)?, outline_text(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, mode);
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, hidden, outline_text };
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): those with see-through pixels, opacity or a blending mode. Only
/// visible ones flatten (a hidden one, which the hidden objects option may write, is written over
/// white); all are left out with `omit`.
fn see_through_images(d: &Document, mode: SeeThrough) -> 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;
});
if mode == SeeThrough::Flatten {
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());
}
}
+1
View File
@@ -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;
+24
View File
@@ -99,6 +99,30 @@ 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; 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)",
},
FormatOption {
name: "hiddenObjects",
ty: "string",
default: "\"layer\"",
description: "with aiVersion 7, hidden objects, which version 7 can't hide (it hides only layers): layer (the default: each layer's hidden objects in a hidden layer of their own just above it, out of their groups), omit (left out) or visible (shown where they are)",
},
FormatOption {
name: "outlineText",
ty: "boolean",
default: "false",
description: "with aiVersion 7, type as its outlines instead of text (which apps reading the file set in the fonts it names: they need them installed); type 8-bit text can't hold (characters outside Windows Latin-1, vertical type, inline graphics, gradient or pattern fills) is always outlines",
},
];
#[derive(Default, Deserialize)]
+16 -5
View File
@@ -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, &params)?;
let mut params = super::pdf::expand_preset(s, cmd, &params)?.into_owned();
let legacy = super::legacy_ai::wanted(cmd, &params)?;
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)
+2
View File
@@ -1854,6 +1854,8 @@ mod tests_layerclip;
#[cfg(test)]
mod tests_layers;
#[cfg(test)]
mod tests_legacy_ai;
#[cfg(test)]
mod tests_library;
#[cfg(test)]
mod tests_linked_stops;
+297
View File
@@ -0,0 +1,297 @@
//! Save As › `.ai` with `aiVersion: "7"`: legacy `.ai` files of version 7, one per artboard, that open with
//! their layers and groups (#868).
use serde_json::{Value, json};
use super::*;
use crate::tests_links::Folder;
/// Three artboards (named A, B and C) with a rectangle on each, on two layers: the first
/// rectangle's group (with a second one) on `Layer 1`, the others on `Top`.
fn three_boards() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
s.execute("artboard.setProps", &json!({"index": 0, "name": "A"})).unwrap();
for (i, name) in ["B", "C"].iter().enumerate() {
let x = 200.0 * (i + 1) as f64;
s.execute("artboard.new", &json!({"x": x, "y": 0, "width": 100, "height": 100, "name": name})).unwrap();
}
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
s.execute("shape.rectangle", &json!({"x": 50, "y": 10, "width": 30, "height": 30})).unwrap();
s.execute("select.all", &json!({})).unwrap();
s.execute("object.group", &json!({})).unwrap();
s.execute("layer.new", &json!({"name": "Top"})).unwrap();
for x in [210, 410] {
s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 30, "height": 30})).unwrap();
}
s
}
fn layer_names(d: &vectorcraft_doc::Document) -> Vec<String> {
d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect()
}
fn warnings(r: &Value) -> Vec<String> {
r["warnings"].as_array().map(|w| w.iter().filter_map(|w| w.as_str().map(str::to_string)).collect()).unwrap_or_default()
}
#[test]
fn each_artboard_is_a_file_that_opens_with_its_layers() {
let dir = Folder::new("ai8");
let mut s = three_boards();
let path = dir.file("Poster.ai");
let r = s.execute("file.saveAs", &json!({"path": path, "format": "ai", "aiVersion": "7"})).unwrap();
let files: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect();
assert_eq!(files, [dir.file("Poster-A.ai"), dir.file("Poster-B.ai"), dir.file("Poster-C.ai")], "{r}");
assert!(warnings(&r).iter().any(|w| w.contains("file of its own")), "{r}");
for (f, objects) in [(files[0], 1), (files[1], 1), (files[2], 1)] {
let bytes = std::fs::read(f).unwrap();
assert!(bytes.starts_with(b"%!PS-Adobe-3.0\n"), "{f}");
s.execute("document.open", &json!({"path": f})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.artboards.len(), 1, "{f}");
assert_eq!(layer_names(d), ["Layer 1", "Top"], "{f}");
let count: usize = d.layers.iter().map(|l| l.children().map_or(0, Vec::len)).sum();
assert_eq!(count, objects, "{f}");
}
// Artboard A's file: the group of two rectangles in Layer 1, nothing on Top.
s.execute("document.open", &json!({"path": files[0]})).unwrap();
let d = &s.doc().unwrap().doc;
let group = &d.layers[0].children().unwrap()[0];
assert!(matches!(&group.kind, vectorcraft_doc::NodeKind::Group { children, clip: false } if children.len() == 2), "{:?}", group.kind);
assert!(d.layers[1].children().unwrap().is_empty());
}
#[test]
fn one_artboard_is_the_file_itself_and_save_writes_it_again() {
let dir = Folder::new("ai8-one");
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
let path = dir.file("One.ai");
let r = s.execute("file.saveAs", &json!({"path": path, "format": "ai", "aiVersion": "7"})).unwrap();
assert!(r["files"].is_null(), "{r}");
assert!(std::fs::read(&path).unwrap().starts_with(b"%!PS"));
// Save remembers the version.
std::fs::remove_file(&path).unwrap();
s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 30, "height": 30})).unwrap();
s.execute("document.save", &json!({})).unwrap();
assert!(std::fs::read(&path).unwrap().starts_with(b"%!PS"));
}
#[test]
fn the_default_stays_pdf_compatible_and_other_versions_are_refused() {
let mut s = three_boards();
for p in [json!({"format": "ai"}), json!({"format": "ai", "aiVersion": "pdf"})] {
let r = s.execute("file.saveCopy", &p).unwrap();
let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
assert!(bytes.starts_with(b"%PDF"), "{p}");
}
assert!(s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "3"})).is_err());
let v = s.execute("file.formatOptions", &json!({"format": "ai"})).unwrap();
assert_eq!(v["options"]["aiVersion"]["value"], "pdf", "{v}");
}
#[test]
fn transparency_and_appearances_are_written_as_plain_art_and_type_as_text() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
s.execute("shape.rectangle", &json!({"x": 30, "y": 30, "width": 50, "height": 50})).unwrap();
s.execute("transparency.set", &json!({"opacity": 50})).unwrap();
s.execute("shape.rectangle", &json!({"x": 120, "y": 10, "width": 30, "height": 30})).unwrap();
s.execute("appearance.addFill", &json!({})).unwrap();
s.execute("text.create", &json!({"x": 100, "y": 80, "text": "Hi", "size": 24})).unwrap();
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap();
let w = warnings(&r);
assert!(w.iter().any(|w| w.contains("written opaque")), "{w:?}");
assert!(!w.iter().any(|w| w.contains("type") && w.contains("outlines")), "{w:?}");
let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
let text = String::from_utf8(bytes.clone()).unwrap();
// No transparency operators: version 7 has none.
assert!(!text.contains(" Xy\n"), "{text}");
s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap();
let d = &s.doc().unwrap().doc;
assert!(d.layers.iter().all(|l| l.children().is_some_and(|c| !c.is_empty())));
let mut opaque = true;
d.walk(|n| opaque &= n.opacity == 1.0);
assert!(opaque);
assert_eq!(texts(d), ["Hi"]);
}
/// The plain text of every text object of `d`.
fn texts(d: &vectorcraft_doc::Document) -> Vec<String> {
let mut out = vec![];
d.walk(|n| {
if let vectorcraft_doc::NodeKind::Text(t) = &n.kind {
out.push(t.plain_text());
}
});
out
}
#[test]
fn type_can_be_written_as_outlines() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
s.execute("text.create", &json!({"x": 20, "y": 50, "text": "Café", "size": 24})).unwrap();
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "outlineText": true})).unwrap();
s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap();
let d = &s.doc().unwrap().doc;
assert!(texts(d).is_empty());
let mut paths = 0;
d.walk(|n| paths += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. } | vectorcraft_doc::NodeKind::Path { .. })));
assert!(paths > 0);
assert!(s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "outlineText": "yes"})).is_err());
let v = s.execute("file.formatOptions", &json!({"format": "ai"})).unwrap();
assert_eq!(v["options"]["outlineText"]["value"], false, "{v}");
}
#[test]
fn hidden_objects_go_to_a_hidden_layer_or_are_left_out_or_shown() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
s.execute("shape.rectangle", &json!({"x": 60, "y": 10, "width": 30, "height": 30})).unwrap();
s.execute("object.hide", &json!({})).unwrap();
let saved = |s: &mut Session, hidden: Value| {
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "hiddenObjects": hidden})).unwrap();
s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap();
let d = s.doc().unwrap().doc.clone();
s.execute("file.close", &json!({})).unwrap();
(d, warnings(&r))
};
let (d, w) = saved(&mut s, Value::Null);
assert_eq!(layer_names(&d), ["Layer 1", "Layer 1 (hidden)"], "{w:?}");
assert!(d.layers[0].visible && !d.layers[1].visible);
assert!(w.iter().any(|w| w.contains("hidden layer")), "{w:?}");
let (d, _) = saved(&mut s, json!("omit"));
assert_eq!(layer_names(&d), ["Layer 1"]);
assert_eq!(d.layers[0].children().map(Vec::len), Some(1));
let (d, _) = saved(&mut s, json!("visible"));
assert_eq!(layer_names(&d), ["Layer 1"]);
assert_eq!(d.layers[0].children().map(|c| c.iter().filter(|n| n.visible).count()), Some(2));
assert!(s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "hiddenObjects": "behind"})).is_err());
let v = s.execute("file.formatOptions", &json!({"format": "ai"})).unwrap();
assert_eq!(v["options"]["hiddenObjects"]["value"], "layer", "{v}");
}
#[test]
fn art_under_transparent_art_stays_in_its_own_layer() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap();
s.execute("layer.new", &json!({"name": "Shade"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap();
s.execute("transparency.set", &json!({"opacity": 50, "blend": "multiply"})).unwrap();
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap();
s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(layer_names(d), ["Layer 1", "Shade"]);
assert!(d.layers.iter().all(|l| l.children().is_some_and(|c| c.len() == 1)), "{:?}", d.layers);
}
/// A `w`×`h` PNG of one colour (`rgba`), 72 ppi.
fn png(w: u32, h: u32, rgba: [u8; 4]) -> Vec<u8> {
let mut out = vec![];
image::RgbaImage::from_pixel(w, h, image::Rgba(rgba)).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
/// A red square (10–50 × 10–50) half under a half-transparent black image (30–70 × 10–50), and
/// an opaque blue image (140–160 × 40–60).
fn shadow_over_square() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap();
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
for (bytes, at) in [(png(40, 40, [0, 0, 0, 128]), [50, 30]), (png(20, 20, [0, 0, 255, 255]), [150, 50])] {
let p = json!({"name": "i.png", "dataBase64": vectorcraft_format::base64_encode(&bytes), "at": at});
s.execute("file.place", &p).unwrap();
}
s
}
/// The colour `d` shows at (`x`, `y`), over white.
fn colour_at(d: &vectorcraft_doc::Document, x: f64, y: f64) -> [u8; 3] {
let px = vectorcraft_render::Renderer::new().render_region(d, vectorcraft_geom::Rect::new(x, y, x + 1.0, y + 1.0), 1.0, true).pixels;
[px[0], px[1], px[2]]
}
fn images(d: &vectorcraft_doc::Document) -> (usize, usize) {
let (mut all, mut clipped) = (0, 0);
d.walk(|n| {
if let vectorcraft_doc::NodeKind::Group { children, clip: true } = &n.kind {
clipped += children.iter().filter(|c| matches!(c.kind, vectorcraft_doc::NodeKind::Image(_))).count();
}
all += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Image(_)));
});
(all, clipped)
}
#[test]
fn a_transparent_image_is_flattened_with_the_art_under_it_which_stays() {
let mut s = shadow_over_square();
let before = [colour_at(&s.doc().unwrap().doc, 40.0, 30.0), colour_at(&s.doc().unwrap().doc, 60.0, 30.0)];
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap();
assert!(warnings(&r).iter().any(|w| w.contains("flattened with the art under them")), "{r}");
assert!(!warnings(&r).iter().any(|w| w.contains("transparent image pixels are written over white")), "{r}");
s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap();
let d = &s.doc().unwrap().doc;
// The square is still there, under an opaque image clipped to the transparent one's place;
// the opaque image stays as it was.
let mut squares = 0;
d.walk(|n| squares += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { clipping: false, .. })));
assert_eq!(squares, 1);
assert_eq!(images(d), (2, 1));
let after = [colour_at(d, 40.0, 30.0), colour_at(d, 60.0, 30.0)];
for (a, b) in before.iter().zip(&after) {
assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(*b) <= 3), "{before:?} {after:?}");
}
assert_eq!(colour_at(d, 20.0, 30.0), [255, 0, 0], "the square outside the image");
assert_eq!(colour_at(d, 80.0, 30.0), [255, 255, 255], "nothing beyond the image");
// Run as a PostScript program (the file's prolog draws it), it prints the same: two images
// and the square, on a page that is the art's box (10 pt in from the artboard's top left).
s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"], "editingData": false})).unwrap();
let page = &s.doc().unwrap().doc;
assert_eq!(images(page).0, 2);
let printed = [colour_at(page, 30.0, 20.0), colour_at(page, 50.0, 20.0)];
for (a, b) in before.iter().zip(&printed) {
assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(*b) <= 3), "{before:?} {printed:?}");
}
assert_eq!(colour_at(page, 10.0, 20.0), [255, 0, 0], "the square outside the image");
}
#[test]
fn transparent_images_can_be_left_out_and_opacity_makes_one_transparent() {
let mut s = shadow_over_square();
s.execute("transparency.set", &json!({"opacity": 50})).unwrap(); // the blue image
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "transparentImages": "omit"})).unwrap();
assert!(warnings(&r).iter().any(|w| w.contains("left out")), "{r}");
s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(images(d), (0, 0));
assert_eq!(colour_at(d, 40.0, 30.0), [255, 0, 0]);
assert!(s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "transparentImages": "blur"})).is_err());
let v = s.execute("file.formatOptions", &json!({"format": "ai"})).unwrap();
assert_eq!(v["options"]["transparentImages"]["value"], "flatten", "{v}");
}
#[test]
fn a_range_saves_those_artboards_and_save_writes_them_again() {
let dir = Folder::new("ai7-range");
let mut s = three_boards();
let path = dir.file("Poster.ai");
let r = s.execute("file.saveAs", &json!({"path": path, "format": "ai", "aiVersion": "7", "range": "1, 3"})).unwrap();
let files: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect();
assert_eq!(files, [dir.file("Poster-A.ai"), dir.file("Poster-C.ai")], "{r}");
// One artboard: the file itself. An empty range: every artboard.
let r = s.execute("file.saveAs", &json!({"path": path, "format": "ai", "aiVersion": "7", "range": "2"})).unwrap();
assert!(r["files"].is_null(), "{r}");
let r = s.execute("file.saveAs", &json!({"path": path, "format": "ai", "aiVersion": "7", "range": ""})).unwrap();
assert_eq!(r["files"].as_array().map(Vec::len), Some(3), "{r}");
// Save keeps the range it was saved with.
s.execute("file.saveAs", &json!({"path": path, "format": "ai", "aiVersion": "7", "range": "1-2"})).unwrap();
let r = s.execute("document.save", &json!({})).unwrap();
assert_eq!(r["files"].as_array().map(Vec::len), Some(2), "{r}");
}
+31
View File
@@ -2152,6 +2152,37 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
"\n%AI3_BeginPattern: (Dots)\n(Dots) 0 0 6 6\n",
"\n%AI3_EndPattern\n",
"To",
// Legacy type (version 7 §15): text objects, their paths, attributes and strings.
"0 To",
"1 To",
"2 To",
"1e308 To",
"TO",
"1 0 0 1 10 10 0 Tp",
"0 0 0 0 1e308 -1e308 1e308 Tp",
"-1 0 0 -1 5 5 99.5 Tp",
"TP",
"/_Helvetica 12 9 3 Tf",
"/_ 1e308 Tf",
"-5 Tf",
"(H\\351llo\\r) Tx",
"(\\r) TX",
"() Tj",
"(x) Tx",
"7 Tr",
"-1e308 Ts",
"1e308 0 Tz",
"1e308 Tt",
"0 TA",
"1e308 -1e308 Tl",
"9 Ta",
"1e308 -1e308 1e308 Ti",
"0 1 Tk",
"[39/quotesingle 128/Euro/eacute/nosuch 300/x TE",
"[1e308/a -1/b TE",
"\n%AI3_BeginEncoding: _Helvetica Helvetica\n[/_Helvetica/Helvetica 0 0 1 TZ\n%AI3_EndEncoding AdobeType\n",
"\n%AI3_BeginEncoding:\n",
"800 Ar",
"frobnicate",
"\n%AI5_BeginLayer\n",
"\n%AI5_EndLayer--\n",
+310
View File
@@ -0,0 +1,310 @@
//! Type in the legacy format (version 7 specification §15): text objects `type To` … `TO`, their
//! text path (`a b c d tx ty startPt Tp`, an area's frame or the path type runs along, `TP`), the
//! modal character and paragraph attributes (`Tr Tf Ts Tz Tt TA Tl Ta Ti`), the paint style, and
//! the characters (`Tx`, `Tj`, overflow `TX`; `\r` ends a paragraph). Final-form operators (the
//! computed positions, §15.1) are skipped: the type is laid out again. Strings are decoded by the
//! file's `TE` encoding, else as Windows-1252; a re-encoded font `/_name` is the font its
//! `%AI3_BeginEncoding` names (else `name`).
use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen};
use vectorcraft_color::Paint;
use vectorcraft_doc::{CharStyle, Justify, NodeKind, ParaStyle, TextKind, TextObject, TextRun};
use vectorcraft_geom::PathData;
use super::super::obj::V;
use super::{FLIP, GState, Reader};
use crate::latin;
/// Most characters of type read in one file.
const MAX_CHARS: usize = 1 << 22;
/// Largest type size and indent read (points).
const MAX_SIZE: f64 = 1e4;
/// A text object being read.
#[derive(Debug, Default)]
pub(super) struct LegacyText {
/// `To`'s type: 0 point type, 1 area type, 2 type on a path.
kind: i64,
/// `Tp`'s matrix: text space (y up) → art space.
matrix: Option<Affine>,
/// Where type on a path starts: a segment's index plus how far along it.
start: f64,
/// Between `Tp` and `TP`: the path read is the text path.
in_path: bool,
/// The frame or path, in art space.
path: Option<BezPath>,
runs: Vec<TextRun>,
/// Each paragraph's attributes, as they were at its first character.
paras: Vec<ParaStyle>,
/// The current paragraph's attributes are in `paras`.
para_taken: bool,
/// An overflow paragraph break (`(\r) TX`) not yet followed by type: the story's end.
pending_break: bool,
/// The state when it began (lock, visibility), and whether it began on a `%_` line.
gs: GState,
hidden: bool,
}
/// The modal type attributes of the legacy format, which last from one text object to the next.
#[derive(Debug)]
pub(super) struct TypeState {
style: CharStyle,
para: ParaStyle,
/// `Tr`.
render: i64,
/// The encoding strings are in, by code (`TE`); None where it says nothing.
encoding: Vec<Option<char>>,
/// Re-encoded fonts' original names (`%AI3_BeginEncoding: _new old`).
fonts: Vec<(String, String)>,
chars: usize,
}
impl Default for TypeState {
fn default() -> Self {
Self { style: CharStyle::default(), para: ParaStyle::default(), render: 0, encoding: vec![None; 256], fonts: vec![], chars: 0 }
}
}
impl TypeState {
/// `%AI3_BeginEncoding: _new old` (`line` the comment).
pub(super) fn encoding_comment(&mut self, line: &str) {
let Some((_, names)) = line.split_once(':') else { return };
let mut words = names.split_whitespace();
if let (Some(new), Some(old)) = (words.next(), words.next())
&& self.fonts.len() < 4096
{
self.fonts.push((new.trim_start_matches('/').to_string(), old.trim_start_matches('/').to_string()));
}
}
}
fn num(vals: &[V], i: usize) -> Option<f64> {
vals.iter().filter_map(V::num).nth(i).filter(|v| v.is_finite())
}
impl Reader<'_> {
/// `[encodingPairs TE`: codes, each followed by the glyph names from it on (§3.3.1).
pub(super) fn te(&mut self, vals: &[V]) {
let mut code: Option<usize> = None;
for v in vals {
match v {
V::Num(n) if n.is_finite() && (0.0..256.0).contains(n) => code = Some(*n as usize),
V::Name(name) => {
if let Some(c) = code {
if let (Some(slot), Ok(b)) = (self.type_state.encoding.get_mut(c), u8::try_from(c)) {
*slot = latin::glyph(b, name);
}
code = Some(c + 1);
}
}
_ => {}
}
}
}
/// Is a text object's path being read (so the path painted is its frame or path)?
pub(super) fn in_text_path(&self) -> bool {
self.text.as_ref().is_some_and(|t| t.in_path)
}
/// `type To`: a text object begins.
pub(super) fn begin_text(&mut self, vals: &[V], hidden: bool) {
self.end_path_quietly();
let kind = num(vals, 0).map_or(0, |k| k as i64);
self.text = Some(LegacyText { kind, gs: self.gs.clone(), hidden, ..LegacyText::default() });
}
/// `a b c d tx ty startPt Tp`: the text path begins.
pub(super) fn begin_text_path(&mut self, vals: &[V]) {
self.end_path_quietly();
let n: Vec<f64> = vals.iter().filter_map(V::num).collect();
let Some(t) = self.text.as_mut() else { return };
if let Some(m) = n.get(..6).and_then(|m| <[f64; 6]>::try_from(m).ok()) {
let m = Affine::new(m);
let det = m.determinant();
if m.as_coeffs().iter().all(|v| v.is_finite()) && det.is_finite() && det.abs() > 1e-12 {
t.matrix = Some(m);
}
}
t.start = n.get(6).copied().filter(|s| s.is_finite() && *s >= 0.0).unwrap_or(0.0);
t.in_path = true;
}
/// The path painted (or `TP`, ending the text path): the text object's frame or path.
pub(super) fn text_path(&mut self) {
let path = std::mem::take(&mut self.path);
self.end_path_quietly();
if let Some(t) = self.text.as_mut()
&& t.path.is_none()
&& !path.elements().is_empty()
{
t.path = Some(path);
}
}
/// `TP`.
pub(super) fn end_text_path(&mut self) {
self.text_path();
if let Some(t) = self.text.as_mut() {
t.in_path = false;
}
}
/// A character or paragraph attribute.
pub(super) fn text_attr(&mut self, w: &str, vals: &[V]) {
let st = &mut self.type_state;
let n0 = num(vals, 0);
match w {
"Tr" => st.render = n0.map_or(0, |v| v as i64),
"Tf" => {
if let Some(name) = vals.iter().find_map(|v| if let V::Name(n) = v { Some(n.clone()) } else { None }) {
let name = st
.fonts
.iter()
.find(|(new, _)| *new == name)
.map_or_else(|| name.trim_start_matches('_').to_string(), |(_, old)| old.clone());
let (family, style) = super::super::super::text::family_style(&name);
st.style.font_family = family;
st.style.font_style = style;
st.style.font_version = None;
}
if let Some(size) = num(vals, 0).filter(|s| *s > 0.0) {
st.style.size = size.min(MAX_SIZE);
}
}
"Ts" => st.style.baseline_shift = n0.unwrap_or(0.0).clamp(-MAX_SIZE, MAX_SIZE),
"Tz" => {
let scale = |v: f64| v.clamp(1.0, 10_000.0);
st.style.h_scale = n0.map_or(100.0, scale);
st.style.v_scale = num(vals, 1).map_or(100.0, scale);
}
"Tt" => st.style.tracking = n0.unwrap_or(0.0).clamp(-10_000.0, 10_000.0),
"TA" => st.style.kerning = if n0 == Some(0.0) { Some(0.0) } else { None },
"Tl" => {
if let Some(l) = n0 {
let l = l.clamp(0.0, MAX_SIZE);
// The automatic leading, 120% of the size, stays automatic.
st.style.leading = if (l - st.style.size * 1.2).abs() < 1e-3 { None } else { Some(l) };
}
st.para.space_before = num(vals, 1).unwrap_or(0.0).clamp(-MAX_SIZE, MAX_SIZE);
}
"Ta" => {
st.para.justify = match n0.map(|v| v as i64) {
Some(1) => Justify::Center,
Some(2) => Justify::Right,
Some(3) => Justify::JustifyLeft,
Some(4) => Justify::JustifyAll,
_ => Justify::Left,
}
}
"Ti" => {
let indent = |i: usize| num(vals, i).unwrap_or(0.0).clamp(-MAX_SIZE, MAX_SIZE);
(st.para.first_line_indent, st.para.left_indent, st.para.right_indent) = (indent(0), indent(1), indent(2));
}
_ => {}
}
}
/// `(…) Tx` (`Tj`, overflow `TX`): characters in the current style and paint.
pub(super) fn text_body(&mut self, w: &str, vals: &[V]) {
let Some(bytes) = vals.iter().rev().find_map(|v| if let V::Str(s) = v { Some(s.clone()) } else { None }) else { return };
let st = &mut self.type_state;
let Some(t) = self.text.as_mut() else { return };
let decoded: String = bytes
.iter()
.map(|&b| if b == b'\r' { '\n' } else { st.encoding.get(usize::from(b)).copied().flatten().unwrap_or_else(|| latin::decode(b)) })
.collect();
if decoded.is_empty() {
return;
}
// Overflow text ending the story with a paragraph break (`(\r) TX`) adds no paragraph.
if w == "TX" && decoded == "\n" {
t.pending_break = true;
return;
}
if st.chars >= MAX_CHARS {
return;
}
st.chars += decoded.chars().count();
let gs = &self.gs;
let mut style = st.style.clone();
let (fill, stroke) = match st.render {
0 | 4 => (true, false),
1 | 5 => (false, true),
2 | 6 => (true, true),
_ => (false, false),
};
style.fill = if fill { gs.fill.clone() } else { Paint::None };
style.stroke = if stroke { gs.stroke.clone() } else { Paint::None };
style.stroke_width = if stroke { gs.width } else { 0.0 };
(style.stroke_cap, style.stroke_join, style.stroke_miter_limit, style.stroke_dash) = (gs.cap, gs.join, gs.miter, gs.dash.clone());
(style.overprint_fill, style.overprint_stroke) = (gs.overprint_fill, gs.overprint_stroke);
let mut text = String::new();
if std::mem::take(&mut t.pending_break) {
text.push('\n');
t.para_taken = false;
}
for c in decoded.chars() {
if c != '\n' && !t.para_taken {
t.paras.push(st.para.clone());
t.para_taken = true;
}
text.push(c);
if c == '\n' {
if !t.para_taken {
t.paras.push(st.para.clone());
}
t.para_taken = false;
}
}
match t.runs.last_mut() {
Some(last) if last.style == style => last.text.push_str(&text),
_ => t.runs.push(TextRun::new(text, style)),
}
}
/// `TO`: the text object ends.
pub(super) fn end_text(&mut self, hidden: bool) -> Result<(), String> {
let Some(t) = self.text.take() else { return Ok(()) };
if t.runs.is_empty() {
return Ok(());
}
let m = t.matrix.unwrap_or(Affine::IDENTITY);
let to_doc = self.space();
// Art space → text space (y down).
let to_text = FLIP * m.inverse();
let kind = match (t.kind, &t.path) {
(1, Some(p)) => TextKind::Area { frame: PathData::from_bezpath(&(to_text * p.clone())) },
(2, Some(p)) => TextKind::OnPath { path: PathData::from_bezpath(&(to_text * p.clone())), start: start_fraction(p, t.start), end: None },
_ => TextKind::Point,
};
let mut obj = TextObject::point(kurbo::Point::ZERO, "", CharStyle::default());
obj.kind = kind;
obj.xf = to_doc * m * FLIP;
obj.runs = t.runs;
let mut paras = t.paras;
if paras.is_empty() {
paras.push(self.type_state.para.clone());
}
obj.set_paragraph_styles(paras);
let node = self.new_node(NodeKind::Text(Box::new(obj)), &t.gs);
self.add(node, t.hidden || hidden)
}
}
/// Where type on `path` starts (`index.fraction`: a segment and how far along it, §15.5.2), as a
/// fraction of its length.
fn start_fraction(path: &BezPath, start: f64) -> f64 {
const ACCURACY: f64 = 1e-3;
let segments: Vec<kurbo::PathSeg> = path.segments().collect();
let lengths: Vec<f64> = segments.iter().map(|s| s.arclen(ACCURACY)).collect();
let total: f64 = lengths.iter().sum();
if !(total.is_finite() && total > 0.0 && start.is_finite()) {
return 0.0;
}
let index = start.floor().max(0.0) as usize;
let Some(seg) = segments.get(index) else { return 1.0 };
let before: f64 = lengths.iter().take(index).sum();
let along = seg.subsegment(0.0..(start - start.floor()).clamp(0.0, 1.0)).arclen(ACCURACY);
((before + along) / total).clamp(0.0, 1.0)
}
+14 -2
View File
@@ -3,7 +3,9 @@
//! - A line starting with `%_` holds tokens like any other line (they are marked [`Token::hidden`]:
//! readers of the printed format skip them as comments).
//! - Any other line starting with `%` is a [`Tok::Comment`] (the section markers).
//! - `%` later in a line starts a comment that is skipped.
//! - `%_` later in a line starts tokens too, hidden (a pseudo-comment operator after its
//! operands, as a gradient stop's `… 50 100 %_Bs`: AI7 spec §1.4.3); any other `%` later in a
//! line starts a comment that is skipped.
//! - The binary image data after `%%BeginData:` is one [`Tok::Data`]; the binary preview of a placed
//! file is one [`Tok::Preview`].
@@ -118,7 +120,8 @@ impl<'a> Lexer<'a> {
}
if b == b'%' {
let start = self.at_line_start();
if start && self.rest().starts_with(b"%_") {
// A pseudo-comment: `%_` at the line's start, or after operands.
if self.rest().starts_with(b"%_") && (start || self.rest().get(2).is_some_and(|b| b.is_ascii_alphabetic())) {
self.pos += 2;
self.hidden = true;
continue;
@@ -313,11 +316,20 @@ mod tests {
t(Tok::Word(b":"), true),
t(Tok::Word(b";"), true),
t(Tok::Num(0.0), false),
// A pseudo-comment operator after its operands.
t(Tok::Word(b"BS"), true),
t(Tok::Hex(b"AB".to_vec()), false),
]
);
}
#[test]
fn a_percent_later_in_a_line_is_a_comment_unless_an_operator_follows_it() {
let toks = all(b"1 % a note (\n2 %_Bs\n3 %_ 4\n");
let words: Vec<_> = toks.iter().map(|t| (t.tok.clone(), t.hidden)).collect();
assert_eq!(words, [(Tok::Num(1.0), false), (Tok::Num(2.0), false), (Tok::Word(b"Bs"), true), (Tok::Num(3.0), false)]);
}
#[test]
fn image_data_is_one_token() {
// The count starts after the comment's `\r`: `\nXI\n` and 3 samples; an alpha channel
+94 -6
View File
@@ -30,9 +30,12 @@
//! `(name) x y scaleX scaleY angle reflect axis shear shearAxis [matrix] p` (`P`), the matrix
//! taking the pattern's space to art space (the other operands are what it is made of).
//!
//! - Type in the legacy format (version 7 specification §15): `type To` … `TO`, read by
//! [`legacy_text`].
//!
//! Sections the reader doesn't need (brushes, symbols, styles, swatches, filters) are skipped;
//! type, symbols, fills with a pattern it can't read, placed files without a preview, legacy type
//! and operators it doesn't know are errors.
//! type, symbols, fills with a pattern it can't read, placed files without a preview and operators
//! it doesn't know are errors.
use std::collections::{BTreeSet, HashMap, HashSet};
use std::rc::Rc;
@@ -54,6 +57,9 @@ use super::obj::{self, Obj, V};
use super::paint::{self, GradientDef, Instance, Named};
use crate::ps;
#[path = "legacy_text.rs"]
mod legacy_text;
/// Deepest nesting of layers, groups and dictionaries.
const MAX_DEPTH: usize = 256;
/// Most operands on the stack.
@@ -64,6 +70,8 @@ const MAX_NODES: usize = 4_000_000;
const MAX_POINTS: usize = 4_000_000;
/// Largest image read (pixels).
const MAX_PIXELS: u64 = 1 << 25;
/// Most hex digits of an image's samples read from comment lines (CMYK and alpha at the largest size).
const MAX_HEX: usize = (MAX_PIXELS as usize) * 10;
/// Largest side of the document and its artboards (points).
const MAX_SIDE: f64 = 1e6;
/// Art space (y up) → a pattern's space in the document's terms (y down).
@@ -291,6 +299,11 @@ struct PatternHead {
#[derive(Debug, Default)]
struct Raster {
space: Option<String>,
/// `XI`'s operands when its samples follow as hex in comment lines (`%FF00…`) up to
/// `%AI5_EndRaster`, the form the version 7 specification documents (§8).
operands: Option<Vec<V>>,
/// Those hex digits.
hex: Vec<u8>,
}
/// A placed file's operands, from `` ` `` and its preview, until `~`.
@@ -382,6 +395,9 @@ struct Reader<'a> {
pattern: Option<PatternHead>,
/// The tile's top left (y down) of each pattern read, by name.
pattern_tiles: HashMap<String, Point>,
/// The legacy text object being read (`To` … `TO`), and the modal type attributes.
text: Option<legacy_text::LegacyText>,
type_state: legacy_text::TypeState,
}
/// Operators that change nothing the reader keeps.
@@ -404,7 +420,8 @@ const IGNORED: &[&str] = &[
"Np",
// Flatness (`i`): how finely curves print.
"i",
"TE",
// Path resolution (`Ar`, §12): how finely curves print.
"Ar",
"TZ",
"Xs",
"Xt",
@@ -510,6 +527,8 @@ impl<'a> Reader<'a> {
template_center: None,
pattern: None,
pattern_tiles: HashMap::new(),
text: None,
type_state: legacy_text::TypeState::default(),
}
}
@@ -566,6 +585,16 @@ impl<'a> Reader<'a> {
fn comment(&mut self, c: &[u8]) -> Result<(), String> {
let line = String::from_utf8_lossy(c);
let line = line.trim_end();
if let Some(r) = self.raster.as_mut()
&& r.operands.is_some()
&& !line.is_empty()
&& line.bytes().all(|b| b.is_ascii_hexdigit())
{
if r.hex.len() < MAX_HEX {
r.hex.extend_from_slice(line.as_bytes());
}
return Ok(());
}
let m = marker(line);
// A pattern's definition (one inside another's isn't read).
if m == "BeginPattern" && self.pattern.is_none() {
@@ -578,6 +607,9 @@ impl<'a> Reader<'a> {
self.styles = super::style::art_styles(section);
return Ok(());
}
if m == "BeginEncoding" {
self.type_state.encoding_comment(line);
}
if let Some((_, end)) = SKIPPED.iter().find(|(b, _)| *b == m) {
return self.skip_to(end);
}
@@ -597,7 +629,9 @@ impl<'a> Reader<'a> {
if line.starts_with("AI5_BeginRaster") {
self.raster = Some(Raster::default());
} else if line.starts_with("AI5_EndRaster") {
self.raster = None;
if let Some(Raster { operands: Some(vals), hex, .. }) = self.raster.take() {
return self.image_from(vals, &hex);
}
} else if line.starts_with("AI5_BeginPlace") {
self.place = Some(Place::default());
} else if line.starts_with("AI5_EndPlace") {
@@ -984,6 +1018,8 @@ impl<'a> Reader<'a> {
// Ends a path that stays open.
"H" => {}
"W" => self.clip_next = true,
// A text object's frame or path.
"N" | "n" | "F" | "f" | "S" | "s" | "B" | "b" if self.in_text_path() => self.text_path(),
"N" | "n" | "F" | "f" | "S" | "s" | "B" | "b" => self.paint(w, hidden, false)?,
"*" => {
let op = vals.iter().rev().find_map(V::text).unwrap_or_else(|| "N".into());
@@ -1067,6 +1103,10 @@ impl<'a> Reader<'a> {
{
self.set_instance(&i);
}
// `1 BB` strokes the path the gradient just filled, `2 BB` closes it first (§3.5.3).
if matches!(Self::nums(&vals).last(), Some(f) if *f == 1.0 || *f == 2.0) {
self.stroke_last();
}
}
// Images.
"XN" => {
@@ -1087,7 +1127,23 @@ impl<'a> Reader<'a> {
}
"~" if self.place.is_some() => self.end_place(hidden)?,
// Type in the legacy format.
"To" | "TO" | "Tp" | "TP" | "Tx" | "TX" | "Tj" | "Tk" => self.unreadable("type in the legacy format"),
"To" => self.begin_text(&vals, hidden),
"Tp" => self.begin_text_path(&vals),
"TP" => self.end_text_path(),
"TO" => self.end_text(hidden)?,
"Tx" | "Tj" | "TX" => self.text_body(w, &vals),
"Tr" | "Tf" | "Ts" | "Tz" | "Tt" | "TA" | "Tl" | "Ta" | "Ti" => self.text_attr(w, &vals),
// Attributes the reader doesn't keep (kerning pairs, spacing, hanging punctuation,
// Far-Eastern type) and final-form positions (§15.3).
"Tk" | "TK" | "TC" | "TW" | "Tq" | "Tc" | "Tw" | "Tm" | "Td" | "T*" | "TR" | "T+" | "T-" | "TV" | "Tv" | "Ty" | "TY" | "TG" | "Tg"
| "Tu" | "Xu" => {}
"TE" => self.te(&vals),
// An image whose samples follow in comment lines (read at `%AI5_EndRaster`).
"XI" if self.raster.is_some() => {
if let Some(r) = self.raster.as_mut() {
r.operands = Some(vals);
}
}
_ if IGNORED.contains(&w) => {}
// Before the art, the setup runs procedures of the printing format.
_ if self.frames.is_empty() => {}
@@ -1612,6 +1668,24 @@ impl<'a> Reader<'a> {
self.add(node, hidden)
}
/// Stroke the path just painted (a gradient-filled one, `1 BB` or `2 BB`) with the stroke's state.
fn stroke_last(&mut self) {
let gs = self.gs.clone();
let Some(n) = self.last_object() else { return };
if !matches!(n.kind, NodeKind::Path { clipping: false, .. }) || n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_))) {
return;
}
let mut st = StrokeLayer::new(gs.stroke.clone(), gs.width);
st.cap = gs.cap;
st.join = gs.join;
st.miter_limit = gs.miter;
st.dash = gs.dash.clone();
st.overprint = gs.overprint_stroke;
let mut a = (*n.appearance).clone();
a.items.push(AppearanceItem::Stroke(st));
n.appearance = a.into();
}
fn set_instance(&mut self, i: &Instance) {
let to_doc = self.space();
match i.paint(&self.gradients, to_doc) {
@@ -1629,6 +1703,16 @@ impl<'a> Reader<'a> {
/// The samples after `XI`, its operands on the stack.
fn image(&mut self, data: &[u8]) -> Result<(), String> {
let vals = self.operands();
// Operands that came before an `XI` on their own line.
let vals = match self.raster.as_mut().and_then(|r| r.operands.take()) {
Some(before) if !vals.iter().any(|v| matches!(v, V::Arr(_))) => before,
_ => vals,
};
self.image_from(vals, data)
}
/// An image of operands `vals` and samples `data`.
fn image_from(&mut self, vals: Vec<V>, data: &[u8]) -> Result<(), String> {
let space = self.raster.as_ref().and_then(|r| r.space.clone()).unwrap_or_default();
let Some(m) = vals.iter().find_map(|v| if let V::Arr(a) = v { Some(Self::nums(a)) } else { None }) else { return Ok(()) };
let n = Self::nums(&vals);
@@ -1753,7 +1837,11 @@ impl<'a> Reader<'a> {
/// box's centre (half a point off the grid).
fn sized_board(&self) -> Option<Rect> {
let ((w, h), (cx, cy)) = (self.art_size?, self.template_center?);
let (x0, y1) = ((cx - w / 2.0).floor(), (cy + h / 2.0).ceil());
// Rounded out, but not off a whole point the decimals in the file only just miss.
let near = |v: f64| (v - v.round()).abs() < 1e-6;
let (left, top) = (cx - w / 2.0, cy + h / 2.0);
let x0 = if near(left) { left.round() } else { left.floor() };
let y1 = if near(top) { top.round() } else { top.ceil() };
let r = Rect::new(x0, y1 - h, x0 + w, y1);
(sane(r) && w > 0.0 && h > 0.0).then_some(r)
}
+18 -1
View File
@@ -18,7 +18,9 @@
//! `%%Creator`) and these operators: Adobe Illustrator File Format Specification, version 7.0
//! (1998), listed by PRONOM at https://www.nationalarchives.gov.uk/PRONOM/fmt/423. A file in that
//! format whose program has its layers (`%AI5_BeginLayer`) comes in through them, as `native` reads
//! an Illustrator EPS's editing copy (#1027).
//! an Illustrator EPS's editing copy (#1027). One VectorCraft wrote (its prolog the procedure set
//! [`crate::legacy_ai::PROCSET`], which prints its gradients flat and leaves its type out) comes in
//! from those layers alone, without its page.
//!
//! The page is the file's `%%BoundingBox` (`%%HiResBoundingBox` when it has one), the first page
//! of a PostScript file without one. A file whose program can't be read (an operator the
@@ -92,6 +94,9 @@ struct Dsc {
/// In the legacy Illustrator format: its header has `%AI…` comments or names the app as the
/// creator (see the module docs for the specification).
illustrator: bool,
/// Its prolog is the one VectorCraft's legacy `.ai` writer includes
/// ([`crate::legacy_ai::PROCSET`] in `%%DocumentSuppliedResources`).
own_legacy: bool,
}
/// The four numbers of a bounding box comment's value, when it has them and they make a box.
@@ -126,6 +131,9 @@ impl Dsc {
if header && (line.starts_with("%AI") || line.starts_with("%%AI") || creator) {
d.illustrator = true;
}
if header && value("%%DocumentSuppliedResources:").is_some_and(|v| v.strip_prefix("procset ") == Some(crate::legacy_ai::PROCSET)) {
d.own_legacy = true;
}
if let Some(v) = value("%%HiResBoundingBox:") {
if header || atend {
hires = box_of(v).or(hires);
@@ -185,6 +193,15 @@ pub fn import_with(bytes: &[u8], editing_data: bool) -> Result<Imported, String>
return Err("this is not a PostScript file".into());
}
let dsc = Dsc::read(ps);
// A legacy `.ai` VectorCraft wrote: its prolog prints the art plainly (gradients flat, no
// type), and its program has all of it, which is read as the format (as before the file
// printed).
if editing_data
&& dsc.own_legacy
&& let Some(Ok((document, warnings))) = native::program_alone(ps)
{
return Ok(Imported { document, warnings, preview: false });
}
let [llx, lly, urx, ury] = dsc.bbox.unwrap_or(LETTER);
let frame = Rect::new(0.0, 0.0, urx - llx, ury - lly);
let mut doc = Document::new(frame.width(), frame.height());
+255
View File
@@ -0,0 +1,255 @@
//! The 8-bit encoding of type in legacy `.ai` files: Windows-1252, its glyphs named as the
//! WinAnsiEncoding of the PDF specification names them (ISO 32000-1, Annex D.2). The writer
//! installs it with `TE` (version 7 specification §3.3.1) and encodes strings in it; the reader
//! decodes strings by the `TE` a file has (its glyph names, which may be another platform's).
/// The glyph names of codes 128–255 (`.notdef` where Windows-1252 has no character).
const HIGH: [&str; 128] = [
"Euro",
".notdef",
"quotesinglbase",
"florin",
"quotedblbase",
"ellipsis",
"dagger",
"daggerdbl",
"circumflex",
"perthousand",
"Scaron",
"guilsinglleft",
"OE",
".notdef",
"Zcaron",
".notdef",
".notdef",
"quoteleft",
"quoteright",
"quotedblleft",
"quotedblright",
"bullet",
"endash",
"emdash",
"tilde",
"trademark",
"scaron",
"guilsinglright",
"oe",
".notdef",
"zcaron",
"Ydieresis",
"space",
"exclamdown",
"cent",
"sterling",
"currency",
"yen",
"brokenbar",
"section",
"dieresis",
"copyright",
"ordfeminine",
"guillemotleft",
"logicalnot",
"hyphen",
"registered",
"macron",
"degree",
"plusminus",
"twosuperior",
"threesuperior",
"acute",
"mu",
"paragraph",
"periodcentered",
"cedilla",
"onesuperior",
"ordmasculine",
"guillemotright",
"onequarter",
"onehalf",
"threequarters",
"questiondown",
"Agrave",
"Aacute",
"Acircumflex",
"Atilde",
"Adieresis",
"Aring",
"AE",
"Ccedilla",
"Egrave",
"Eacute",
"Ecircumflex",
"Edieresis",
"Igrave",
"Iacute",
"Icircumflex",
"Idieresis",
"Eth",
"Ntilde",
"Ograve",
"Oacute",
"Ocircumflex",
"Otilde",
"Odieresis",
"multiply",
"Oslash",
"Ugrave",
"Uacute",
"Ucircumflex",
"Udieresis",
"Yacute",
"Thorn",
"germandbls",
"agrave",
"aacute",
"acircumflex",
"atilde",
"adieresis",
"aring",
"ae",
"ccedilla",
"egrave",
"eacute",
"ecircumflex",
"edieresis",
"igrave",
"iacute",
"icircumflex",
"idieresis",
"eth",
"ntilde",
"ograve",
"oacute",
"ocircumflex",
"otilde",
"odieresis",
"divide",
"oslash",
"ugrave",
"uacute",
"ucircumflex",
"udieresis",
"yacute",
"thorn",
"ydieresis",
];
/// Codes 128–159 of Windows-1252 (the others are Latin-1's); its five gaps are C1 controls.
const C1: [char; 32] = [
'€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8D}', 'Ž', '\u{8F}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—', '˜',
'™', 'š', '›', 'œ', '\u{9D}', 'ž', 'Ÿ',
];
/// Glyph names other platforms' encodings use that Windows-1252 has no code for (Mac Roman's).
const OTHER_NAMES: &[(&str, char)] = &[
("notequal", '≠'),
("infinity", '∞'),
("lessequal", '≤'),
("greaterequal", '≥'),
("partialdiff", '∂'),
("summation", '∑'),
("product", '∏'),
("pi", 'π'),
("integral", '∫'),
("Omega", 'Ω'),
("radical", '√'),
("approxequal", '≈'),
("Delta", '∆'),
("lozenge", '◊'),
("fraction", '⁄'),
("fi", 'fi'),
("fl", 'fl'),
("dotlessi", 'ı'),
("breve", '˘'),
("dotaccent", '˙'),
("ring", '˚'),
("hungarumlaut", '˝'),
("ogonek", '˛'),
("caron", 'ˇ'),
("nbspace", '\u{A0}'),
("quotesingle", '\''),
("grave", '`'),
];
/// Character `b` of Windows-1252.
pub(crate) fn decode(b: u8) -> char {
match b {
0x80..=0x9F => C1.get(usize::from(b - 0x80)).copied().unwrap_or('?'),
_ => char::from(b),
}
}
/// The code of `c` in Windows-1252: the printable characters, tab and the line breaks; None for
/// the others (and the C1 controls).
pub(crate) fn encode(c: char) -> Option<u8> {
match c {
'\t' | '\n' | '\r' | ' '..='~' | '\u{A0}'..='\u{FF}' => u8::try_from(u32::from(c)).ok(),
_ => C1.iter().position(|&x| x == c && !x.is_control()).and_then(|i| u8::try_from(0x80 + i).ok()),
}
}
/// The `TE` that installs the encoding: its codes 39 and 96 as in ASCII (PostScript's standard
/// encoding has quotes there) and 128–255 by name.
pub(crate) fn te() -> String {
let mut s = String::from("[39/quotesingle 96/grave 128");
for (i, name) in HIGH.iter().enumerate() {
s.push('/');
s.push_str(name);
if i % 8 == 7 && i + 1 < HIGH.len() {
s.push('\n');
}
}
s.push_str("\nTE\n");
s
}
/// The character glyph `name` stands for at code `code` of an encoding: that code's Windows-1252
/// character when the name is the one this encoding gives it; else by the name.
pub(crate) fn glyph(code: u8, name: &str) -> Option<char> {
if code >= 0x80 && HIGH.get(usize::from(code - 0x80)) == Some(&name) && name != ".notdef" {
return Some(decode(code));
}
if let Some(i) = HIGH.iter().position(|n| *n == name && *n != ".notdef" && *n != "space") {
return u8::try_from(0x80 + i).ok().map(decode);
}
if let Some((_, c)) = OTHER_NAMES.iter().find(|(n, _)| *n == name) {
return Some(*c);
}
match name {
"space" => Some(' '),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_encodable_character_decodes_as_itself() {
for b in 0..=255u8 {
let c = decode(b);
if let Some(e) = encode(c) {
assert_eq!(e, b, "{c:?}");
}
}
assert_eq!(encode('é'), Some(0xE9));
assert_eq!(encode('€'), Some(0x80));
assert_eq!(encode('—'), Some(0x97));
assert_eq!(encode('Ω'), None);
assert_eq!(encode('\u{81}'), None);
}
#[test]
fn glyph_names_decode_by_code_and_by_name() {
assert_eq!(glyph(0xE9, "eacute"), Some('é'));
assert_eq!(glyph(0xA0, "space"), Some('\u{A0}'));
// Mac Roman puts é at 0x8E.
assert_eq!(glyph(0x8E, "eacute"), Some('é'));
assert_eq!(glyph(0xAD, "notequal"), Some('≠'));
assert_eq!(glyph(0x41, "nosuchglyph"), None);
assert!(te().starts_with("[39/quotesingle 96/grave 128/Euro/.notdef/"));
assert!(te().ends_with("/ydieresis\nTE\n"));
}
}
File diff suppressed because it is too large Load Diff
+4
View File
@@ -27,14 +27,18 @@
//! - `tiff`: the TIFF preview.
//! - `import`: the PostScript interpreter.
//! - `print`: print jobs as PostScript files ([`print()`]), their pages drawn by `scene`.
//! - `legacy_ai`: legacy `.ai` files of version 7 ([`export_legacy_ai`]), with their layers and groups.
mod import;
mod latin;
mod legacy_ai;
mod print;
mod ps;
mod scene;
mod tiff;
pub use import::{Imported, ai_alone, family_style, import, import_with, is_loss, layered_ai};
pub use legacy_ai::{HiddenObjects, LegacyAiOptions, LegacyAiOutput, export_legacy_ai};
pub use print::{PrintJob, PrintPage, print};
use vectorcraft_doc::Document;
+24
View File
@@ -73,6 +73,30 @@ pub(crate) fn string(text: &str) -> String {
s
}
/// `bytes` as a PostScript string: `(` `)` `\` escaped, carriage returns, line feeds and tabs as
/// `\r` `\n` `\t`, other bytes outside printable ASCII as octal escapes.
pub(crate) fn bytes(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() + 2);
s.push('(');
for &b in bytes {
match b {
b'(' | b')' | b'\\' => {
s.push('\\');
s.push(char::from(b));
}
b'\r' => s.push_str("\\r"),
b'\n' => s.push_str("\\n"),
b'\t' => s.push_str("\\t"),
0x20..=0x7e => s.push(char::from(b)),
_ => {
let _ = write!(s, "\\{b:03o}");
}
}
}
s.push(')');
s
}
/// A matrix operand `[a b c d e f]`.
pub(crate) fn matrix(a: Affine) -> String {
let mut s = String::from("[");
+590
View File
@@ -0,0 +1,590 @@
//! Legacy `.ai` files (version 7) written, then read back as File › Open reads them.
use std::sync::Arc;
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::{Affine, AnchorKind, FillRule, PathData, Rect, shapes};
use super::*;
/// A node of `doc` filled `fill` (and stroked `stroke` 2 pt wide), path `path`.
fn shape(doc: &mut Document, path: PathData, fill: Paint, stroke: Paint) -> Node {
Node::path(doc.alloc_id(), path, Appearance::basic(fill, stroke, 2.0))
}
fn red() -> Paint {
Paint::solid(Color::rgb(1.0, 0.0, 0.0))
}
/// A 200 × 100 document: a layer `Back` holding a group of a red square (stroked blue) and a
/// locked one, a compound ring, a clipping group and a gradient square; a hidden layer `Front`
/// holding an ellipse and a 2 × 1 image.
fn sample() -> Document {
let mut doc = Document::new(200.0, 100.0);
doc.layers.clear();
let back = doc.add_layer(Some("Back"));
let front = doc.add_layer(Some("Front"));
let a = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), red(), Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
let mut b = shape(&mut doc, shapes::rectangle(Rect::new(50.0, 10.0, 80.0, 40.0)), Paint::solid(Color::gray(0.5)), Paint::None);
b.locked = true;
let group = Node::group(doc.alloc_id(), vec![Arc::new(a), Arc::new(b)]);
doc.insert(Some(back), usize::MAX, group).unwrap();
let outer = shape(&mut doc, shapes::rectangle(Rect::new(100.0, 10.0, 140.0, 50.0)), Paint::None, Paint::None);
let inner = shape(&mut doc, shapes::rectangle(Rect::new(110.0, 20.0, 130.0, 40.0)), Paint::None, Paint::None);
let mut ring = Node::new(doc.alloc_id(), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(inner)], rule: FillRule::EvenOdd });
ring.appearance = Appearance::basic(Paint::solid(Color::cmyk(0.0, 1.0, 1.0, 0.0)), Paint::None, 0.0).into();
doc.insert(Some(back), usize::MAX, ring).unwrap();
let clip = Node::new(
doc.alloc_id(),
NodeKind::Path {
path: shapes::rectangle(Rect::new(160.0, 20.0, 180.0, 40.0)),
rule: FillRule::NonZero,
live: None,
clipping: true,
guide: false,
},
);
let clipped = shape(&mut doc, shapes::rectangle(Rect::new(150.0, 10.0, 190.0, 50.0)), Paint::solid(Color::gray(0.25)), Paint::None);
let window = Node::new(doc.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(clipped)], clip: true });
doc.insert(Some(back), usize::MAX, window).unwrap();
let g = Gradient {
kind: GradientKind::Linear,
stops: vec![GradientStop::new(0.0, Color::rgb(1.0, 1.0, 1.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 0.0))],
};
let shaded = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 60.0, 50.0, 90.0)), Paint::Gradient(Box::new(GradientPaint::new(g))), Paint::None);
doc.insert(Some(back), usize::MAX, shaded).unwrap();
let ellipse = shape(&mut doc, shapes::ellipse(Rect::new(100.0, 60.0, 160.0, 90.0)), red(), Paint::None);
doc.insert(Some(front), usize::MAX, ellipse).unwrap();
let mut png = vec![];
let img = image::RgbaImage::from_raw(2, 1, vec![255, 0, 0, 255, 0, 0, 255, 255]).unwrap();
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let blob = ImageBlob::new("image/png", png);
let key = blob.content_key();
doc.images.insert(key.clone(), blob);
let im =
ImageObject { key, width: 2, height: 1, xf: Affine::new([10.0, 0.0, 0.0, 10.0, 170.0, 70.0]), link: None, placement: Default::default() };
let im = Node::new(doc.alloc_id(), NodeKind::Image(Box::new(im)));
doc.insert(Some(front), usize::MAX, im).unwrap();
if let Some(l) = doc.layers.get_mut(1) {
Arc::make_mut(l).visible = false;
}
doc
}
fn write(doc: &Document) -> LegacyAiOutput {
write_with(doc, LegacyAiOptions::default())
}
/// `doc` written with options `o`. Every file the tests write is also run as a PostScript program
/// ([`prints_as`]) and must draw what `doc` draws.
fn write_with(doc: &Document, o: LegacyAiOptions) -> LegacyAiOutput {
let o = LegacyAiOptions { title: "Sample".into(), created: Some(0), ..o };
let out = export_legacy_ai(doc, &o).unwrap();
prints_as(&out.bytes, doc, &o);
out
}
/// `doc` as the prolog prints the file written from it with options `o`: without its layers that
/// don't print, its type as its glyphs' outlines where the writer writes them (all type with
/// [`LegacyAiOptions::outline_text`], else the type 8-bit text can't hold) and left out where it
/// writes text objects, its
/// hidden objects shown when `o` writes them visible, and its gradients filled with their first
/// stop's colour.
fn as_printed(doc: &Document, o: &LegacyAiOptions) -> Document {
// A run's glyphs, laid out, as one path with the run's fill (the renderer draws no type).
fn outlined(t: &vectorcraft_doc::TextObject) -> Node {
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let runs = t.runs.iter().enumerate().map(|(i, run)| {
let mut bp = kurbo::BezPath::new();
for g in layout.glyphs.iter().filter(|g| g.run == i) {
bp.extend(g.outline.iter());
}
let path = PathData::from_bezpath(&bp).transformed(t.xf);
Arc::new(Node::path(vectorcraft_doc::NodeId(0), path, Appearance::basic(run.style.fill.clone(), Paint::None, 0.0)))
});
Node::group(vectorcraft_doc::NodeId(0), runs.collect())
}
fn plain(nodes: &mut Vec<Arc<Node>>, o: &LegacyAiOptions) {
nodes.retain(|n| match &n.kind {
NodeKind::Text(t) => o.outline_text || !writable(t),
NodeKind::Layer { printable, .. } => *printable,
_ => true,
});
for n in nodes {
if let NodeKind::Text(t) = &n.kind {
*n = Arc::new(outlined(t));
}
let n = Arc::make_mut(n);
if o.hidden == HiddenObjects::Visible && !matches!(n.kind, NodeKind::Layer { .. }) {
n.visible = true;
}
for item in &mut n.appearance.items {
if let AppearanceItem::Fill(f) = item
&& let Paint::Gradient(g) = &f.paint
{
f.paint = g.gradient.stops.first().map_or(Paint::None, |s| Paint::solid(s.color));
}
}
if let Some(c) = n.children_mut() {
plain(c, o);
}
}
}
let mut d = doc.clone();
plain(&mut d.layers, o);
d
}
/// The legacy `.ai` `bytes` run as a PostScript program (VectorCraft's PostScript reader, with the
/// file's prolog) draws as `doc`, written with options `o` (its first artboard the file's), prints
/// ([`as_printed`]): the same art in the same places, in the same colours.
fn prints_as(bytes: &[u8], doc: &Document, o: &LegacyAiOptions) {
let text = String::from_utf8_lossy(bytes);
let nums = |key: &str| -> Vec<f64> {
let line = text.lines().find_map(|l| l.strip_prefix(key)).unwrap_or_else(|| panic!("no {key}"));
line.split_whitespace().map(|v| v.parse().unwrap()).collect()
};
// The art's box (the page the program draws on) and the artboard's top, in art space (y up).
let [llx, lly, urx, ury] = nums("%%HiResBoundingBox:")[..] else { panic!() };
let top = nums("%AI3_TileBox:")[3];
let board = doc.artboards[0].rect;
let there = Rect::new(board.x0 + llx, board.y0 + top - ury, board.x0 + urx, board.y0 + top - lly);
let scale = 300.0 / (urx - llx).max(ury - lly).max(1.0);
let want = vectorcraft_render::Renderer::new().render_region(&as_printed(doc, o), there, scale, true);
let drew = match crate::import_with(bytes, false) {
Ok(page) => {
assert!(!page.preview, "read as its program");
vectorcraft_render::Renderer::new().render_region(&page.document, Rect::new(0.0, 0.0, urx - llx, ury - lly), scale, true)
}
// A page that marks nothing (type alone): blank.
Err(e) if e.contains("draws nothing") => {
vectorcraft_render::Renderer::new().render_region(&Document::new(1.0, 1.0), Rect::new(0.0, 0.0, urx - llx, ury - lly), scale, true)
}
Err(e) => panic!("the file doesn't run as PostScript: {e}"),
};
assert_eq!((drew.width, drew.height), (want.width, want.height));
let differ = drew
.pixels
.as_chunks::<4>()
.0
.iter()
.zip(want.pixels.as_chunks::<4>().0)
.filter(|(a, b)| a.iter().zip(*b).any(|(x, y)| x.abs_diff(*y) > 48))
.count();
let share = differ as f64 / f64::from(want.width * want.height).max(1.0);
assert!(share < 0.002, "the program draws {:.2}% of the page differently", share * 100.0);
}
fn children(n: &Node) -> &[Arc<Node>] {
n.children().map_or(&[][..], Vec::as_slice)
}
#[test]
fn writes_the_documented_operators() {
let out = write(&sample());
let text = String::from_utf8(out.bytes).unwrap();
for needle in [
"%!PS-Adobe-3.0\n",
"%AI5_FileFormat 3\n",
"%%CreationDate: (1/1/1970) (00:00)\n",
"%AI5_ArtSize: 200 100",
"%AI3_TemplateBox: 100 50 100 50",
"%AI5_NumLayers: 2",
"(Back) Ln",
"(Front) Ln",
"%AI5_BeginGradient: (Gradient)",
"\nu\n",
"\n*u\n",
"\nq\n",
"\n1 A\n",
"XI\n",
"%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\nend\n%%EOF\n",
] {
assert!(text.contains(needle), "missing {needle:?} in\n{text}");
}
// The prolog is the writer's own procedure set, named in the header, in the dictionary the
// setup begins; it defines the page trailer's `annotatepage` (§3).
let prolog = text.split("%%BeginProlog\n").nth(1).and_then(|t| t.split("%%EndProlog\n").next()).unwrap();
assert!(prolog.starts_with(&format!("%%BeginResource: procset {PROCSET}\n/VCLegacy ")), "{prolog}");
assert!(prolog.ends_with("end\n%%EndResource\n") && prolog.contains("\n/annotatepage {} bd\n"), "{prolog}");
assert!(text.contains(&format!("%%DocumentSuppliedResources: procset {PROCSET}\n")), "{text}");
// The hidden layer is written hidden: visible 0.
assert!(text.contains("%AI5_BeginLayer\n0 1 1 1 0 0 1 "), "{text}");
// Art space is y up from the artboard's bottom-left: the red square (10..40 down from the top)
// is 60..90 up. Its corners are corner points (upper case, AI7 spec §5.3.2); its fill RGB
// (`r g b Xa`, §5.4.1).
assert!(text.contains("1 0 0 Xa\n"), "{text}");
assert!(text.contains("10 90 m\n40 90 L\n40 60 L\n10 60 L\n10 90 L\nb\n"), "{text}");
// The ellipse's points are smooth (lower case).
assert!(text.contains(" c\n") && !text.contains(" C\n"), "{text}");
// The clipping path: closed by `h`, then `W n` (§5.6.2).
assert!(text.contains(" L\nh\nW\nn\n"), "{text}");
// Gradients: counted (`Bn`), each stop's operands before `%_Bs` (§3.5).
assert!(text.contains("%%BeginSetup\nVCLegacy begin\n1 Bn\n%AI5_BeginGradient: (Gradient)\n(Gradient) 0 2 Bd\n[\n"), "{text}");
assert!(text.contains(" 2 50 100 %_Bs\n") && !text.contains("Np\n"), "{text}");
assert!(text.contains("1 0 0 1 0 0 Bg\nf\n0 BB\n"), "{text}");
// The image: operands and `XI` on a line, its samples on comment lines (§8).
assert!(text.contains("] 0 0 2 1 2 1 8 3 0 0 0 0 XI\n%FF00000000FF\n%AI5_EndRaster\n"), "{text}");
}
#[test]
fn a_gradient_fill_and_its_stroke_are_one_object() {
let mut doc = Document::new(100.0, 100.0);
let g = Gradient {
kind: GradientKind::Radial,
stops: vec![GradientStop::new(0.0, Color::rgb(1.0, 1.0, 1.0)), GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 1.0))],
};
let mut paint = GradientPaint::new(g);
paint.geom = Some(vectorcraft_color::GradientGeom {
start: vectorcraft_geom::Point::new(30.0, 30.0),
end: vectorcraft_geom::Point::new(50.0, 30.0),
aspect: 1.0,
focal: Some(vectorcraft_geom::Point::new(35.0, 30.0)),
});
let n = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 50.0)), Paint::Gradient(Box::new(paint)), red());
let layer = doc.layers[0].id;
doc.insert(Some(layer), usize::MAX, n).unwrap();
let text = String::from_utf8(write(&doc).bytes).unwrap();
// `2 BB` strokes the filled path (§3.5.3); the highlight is a quarter of the radius out (§3.5.5).
assert!(text.contains("Bb\n5 0 0 0.25 Bh\n1 (Gradient) 30 70 0 20 1 0 0 1 0 0 Bg\nf\n2 BB\n"), "{text}");
let doc = crate::import(text.as_bytes()).unwrap().document;
let n = &children(&doc.layers[0])[0];
let Paint::Gradient(g) = n.appearance.fill_paint() else { panic!("{:?}", n.appearance) };
assert_eq!(g.gradient.kind, GradientKind::Radial);
assert_eq!(n.appearance.stroke_paint().color(), Some(Color::rgb(1.0, 0.0, 0.0)));
}
#[test]
fn reads_back_with_layers_groups_and_paths() {
let out = write(&sample());
let imported = crate::import(&out.bytes).unwrap();
let doc = imported.document.clone();
let names: Vec<_> = doc.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect();
assert_eq!(names, ["Back", "Front"], "{:?}", imported.warnings);
assert!(doc.layers[0].visible);
assert!(!doc.layers[1].visible);
let back = children(&doc.layers[0]);
assert_eq!(back.len(), 4, "{back:#?}");
// The group of two squares, the second locked.
let NodeKind::Group { children: pair, clip: false } = &back[0].kind else { panic!("{:?}", back[0].kind) };
assert_eq!(pair.len(), 2);
assert!(!pair[0].locked && pair[1].locked);
let a = &pair[0];
assert_eq!(a.appearance.fill_paint().color(), Some(Color::rgb(1.0, 0.0, 0.0)));
assert_eq!(a.appearance.stroke_paint().color(), Some(Color::rgb(0.0, 0.0, 1.0)));
// Four corners: the start point written again to close it isn't a fifth.
let sq = a.path_data().unwrap();
assert!(sq.subpaths[0].closed && sq.subpaths[0].anchors.len() == 4, "{sq:?}");
assert!(sq.subpaths[0].anchors.iter().all(|p| p.kind == AnchorKind::Corner), "{sq:?}");
let bounds = a.visual_bounds().unwrap();
assert!((bounds.x0 - 9.0).abs() < 0.01 && (bounds.y0 - 9.0).abs() < 0.01, "{bounds:?}");
// The ring: a compound path, even-odd.
assert!(matches!(&back[1].kind, NodeKind::Compound { children, rule: FillRule::EvenOdd } if children.len() == 2), "{:?}", back[1].kind);
// The clipping group: its clipping path first.
let NodeKind::Group { children: window, clip: true } = &back[2].kind else { panic!("{:?}", back[2].kind) };
assert!(matches!(window[0].kind, NodeKind::Path { clipping: true, .. }));
// The gradient square.
let Paint::Gradient(g) = back[3].appearance.fill_paint() else { panic!("{:?}", back[3].appearance) };
assert_eq!(g.gradient.kind, GradientKind::Linear);
assert_eq!(g.gradient.stops.len(), 2);
assert_eq!(g.gradient.stops[0].color, Color::rgb(1.0, 1.0, 1.0));
// The hidden layer: the ellipse with smooth points, and the image.
let front = children(&doc.layers[1]);
assert_eq!(front.len(), 2, "{:?} {:?}", front.iter().map(|n| &n.kind).collect::<Vec<_>>(), imported.warnings);
let path = front[0].path_data().unwrap();
assert_eq!(path.subpaths.len(), 1);
assert!(path.subpaths[0].closed);
assert!(path.subpaths[0].anchors.iter().all(|a| a.kind == AnchorKind::Smooth), "{path:?}");
assert_eq!(path.subpaths[0].anchors.len(), 4, "{path:?}");
let NodeKind::Image(im) = &front[1].kind else { panic!("{:?}", front[1].kind) };
assert_eq!((im.width, im.height), (2, 1));
let b = front[1].visual_bounds().unwrap();
assert!((b.x0 - 170.0).abs() < 0.01 && (b.y0 - 70.0).abs() < 0.01 && (b.x1 - 190.0).abs() < 0.01, "{b:?}");
}
#[test]
fn spot_colours_are_custom_colours() {
let mut doc = Document::new(100.0, 100.0);
doc.swatches.push(vectorcraft_color::Swatch {
name: "Spot Test".into(),
paint: Paint::solid(Color::cmyk(0.0, 0.5, 1.0, 0.0)),
global: true,
spot: true,
});
let fill = Paint::Solid { color: Color::cmyk(0.0, 0.25, 0.5, 0.0), swatch: Some("Spot Test".into()), tint: 0.5 };
let n = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 50.0)), fill, Paint::None);
let layer = doc.layers[0].id;
doc.insert(Some(layer), usize::MAX, n).unwrap();
let out = write(&doc);
let text = String::from_utf8(out.bytes.clone()).unwrap();
assert!(text.contains("%%DocumentCustomColors: (Spot Test)"), "{text}");
assert!(text.contains("0 0.5 1 0 (Spot Test) 0.5 x"), "{text}");
let doc = crate::import(&out.bytes).unwrap().document;
let n = &children(&doc.layers[0])[0];
let Paint::Solid { swatch, tint, .. } = n.appearance.fill_paint() else { panic!() };
assert_eq!(swatch.as_deref(), Some("Spot Test"));
assert!((tint - 0.5).abs() < 1e-6);
}
#[test]
fn losses_are_warned() {
let mut doc = sample();
// A sublayer, a hidden object, a named object, type and half opacity.
let back = doc.layers[0].id;
let sub = Node::layer(doc.alloc_id(), "Sub", vectorcraft_doc::LayerColor::Preset(2));
let sub = doc.insert(Some(back), usize::MAX, sub).unwrap();
let mut hidden = shape(&mut doc, shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), red(), Paint::None);
hidden.visible = false;
doc.insert(Some(sub), usize::MAX, hidden).unwrap();
let mut named = shape(&mut doc, shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), red(), Paint::None);
named.name = Some("Named".into());
named.opacity = 0.5;
doc.insert(Some(sub), usize::MAX, named).unwrap();
let w = write(&doc).warnings;
for expected in [SUBLAYERS, HIDDEN_LAYER, NAMES, TRANSPARENCY] {
assert!(w.iter().any(|x| x == expected), "{expected} not in {w:?}");
}
// The sublayer is a group in its layer.
let doc = crate::import(&write(&doc).bytes).unwrap().document;
let last = children(&doc.layers[0]).last().unwrap();
assert!(matches!(&last.kind, NodeKind::Group { children, .. } if children.len() == 1), "{:?}", last.kind);
}
#[test]
fn several_fills_and_odd_strokes_become_objects() {
let mut doc = Document::new(100.0, 100.0);
let mut n = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 50.0)), red(), Paint::solid(Color::gray(1.0)));
let mut a = (*n.appearance).clone();
if let Some(AppearanceItem::Stroke(s)) = a.items.iter_mut().find(|i| !i.is_fill()) {
s.align = vectorcraft_doc::StrokeAlign::Inside;
}
n.appearance = a.into();
let layer = doc.layers[0].id;
doc.insert(Some(layer), usize::MAX, n).unwrap();
let out = write(&doc);
assert!(out.warnings.iter().any(|w| w.contains("filled outlines")), "{:?}", out.warnings);
let doc = crate::import(&out.bytes).unwrap().document;
let n = &children(&doc.layers[0])[0];
// The fill, then the stroke's outline filled.
assert!(matches!(&n.kind, NodeKind::Group { children, .. } if children.len() == 2), "{:?}", n.kind);
}
#[test]
fn art_stays_put_on_an_artboard_a_fraction_of_a_point_tall() {
let mut doc = Document::new(100.0, 364.252);
let n = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 20.0, 30.0, 40.0)), red(), Paint::None);
let layer = doc.layers[0].id;
doc.insert(Some(layer), usize::MAX, n).unwrap();
let text = String::from_utf8(write(&doc).bytes).unwrap();
// The artboard's top is on a whole point (365); the template box's centre is the artboard's.
assert!(text.contains("%AI3_TemplateBox: 50 182.874 50 182.874\n"), "{text}");
assert!(text.contains("10 345 m\n"), "{text}");
let doc = crate::import(text.as_bytes()).unwrap().document;
let b = children(&doc.layers[0])[0].visual_bounds().unwrap();
assert!((b.x0 - 10.0).abs() < 1e-3 && (b.y0 - 20.0).abs() < 1e-3 && (b.y1 - 40.0).abs() < 1e-3, "{b:?}");
}
/// The file prints as the document with every layer shown: run as a PostScript program
/// ([`write_with`] checks it), its paths, fills, strokes, compound path, clipping group and image
/// draw where and as the document has them (its gradient filled with its first stop's colour, as
/// the prolog fills gradients). Read for its layers, it is read from its program, which has the
/// gradient.
#[test]
fn prints_as_the_document() {
let mut doc = sample();
if let Some(l) = doc.layers.get_mut(1) {
Arc::make_mut(l).visible = true;
}
let out = write(&doc);
let read = crate::import(&out.bytes).unwrap().document;
let mut gradients = 0;
read.walk(|n| {
gradients += n.appearance.items.iter().filter(|i| matches!(i, AppearanceItem::Fill(f) if matches!(f.paint, Paint::Gradient(_)))).count()
});
assert_eq!((read.layers.len(), gradients), (2, 1));
}
#[test]
fn needs_an_artboard() {
let mut doc = Document::new(100.0, 100.0);
doc.artboards.clear();
assert!(export_legacy_ai(&doc, &LegacyAiOptions::default()).is_err());
}
/// Every text object of `doc`'s layers, in paint order.
fn texts(doc: &Document) -> Vec<(Node, vectorcraft_doc::TextObject)> {
let mut out = vec![];
doc.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
out.push((n.clone(), (**t).clone()));
}
});
out
}
fn style(size: f64, fill: Paint) -> vectorcraft_doc::CharStyle {
vectorcraft_doc::CharStyle { size, fill, ..vectorcraft_doc::CharStyle::default() }
}
/// A document of point type (two paragraphs, the second centred, accented letters and a euro
/// sign), area type in a 100 × 50 frame and type on a path starting a quarter of the way along.
fn typed() -> Document {
use vectorcraft_doc::{Justify, ParaStyle, TextKind, TextObject, TextRun};
let mut doc = Document::new(300.0, 200.0);
let layer = doc.layers[0].id;
let mut point = TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), "Héllo €5\nWorld", style(18.0, red()));
point.runs.push(TextRun::new(" again", style(12.0, Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0)))));
point.set_paragraph_styles(vec![ParaStyle::default(), ParaStyle { justify: Justify::Center, ..ParaStyle::default() }]);
let mut area = TextObject::point(vectorcraft_geom::Point::ZERO, "Area type flows in its frame", style(10.0, Paint::solid(Color::BLACK)));
area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)) };
area.xf = Affine::translate((20.0, 80.0));
let mut on_path = TextObject::point(vectorcraft_geom::Point::ZERO, "Along a path", style(12.0, Paint::solid(Color::gray(0.5))));
let mut line = PathData::new(vec![]);
line.subpaths.push(vectorcraft_geom::SubPath::polyline(&[(150.0, 150.0).into(), (250.0, 150.0).into(), (250.0, 190.0).into()], false));
on_path.kind = TextKind::OnPath { path: line, start: 0.25, end: None };
on_path.xf = Affine::IDENTITY;
for t in [point, area, on_path] {
let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t)));
doc.insert(Some(layer), usize::MAX, n).unwrap();
}
doc
}
#[test]
fn type_is_written_as_text_objects() {
let out = write(&typed());
let text = String::from_utf8(out.bytes).unwrap();
for needle in [
"%%DocumentFonts: ",
"%%IncludeFont: ",
"\nTE\n",
"%AI3_BeginEncoding: _",
"0 To\n1 0 0 1 20 160 0 Tp\nTP\n",
"1 To\n",
"2 To\n",
" Tf\n",
"TO\n",
] {
assert!(text.contains(needle), "missing {needle:?} in\n{text}");
}
// Windows-1252: é is 351 (octal), the euro sign 200, the paragraph ends with `\r`.
assert!(text.contains("(H\\351llo \\2005\\r) Tx\n"), "{text}");
// The second paragraph centred.
assert!(text.contains("1 Ta\n"), "{text}");
// The area's frame in art space, painted with nothing.
assert!(text.contains("20 120 m\n"), "{text}");
assert!(!out.warnings.iter().any(|w| w == TYPE), "{:?}", out.warnings);
}
#[test]
fn type_reads_back_as_text() {
use vectorcraft_doc::{Justify, TextKind};
let imported = crate::import(&write(&typed()).bytes).unwrap();
let found = texts(&imported.document);
assert_eq!(found.len(), 3, "{:?}", imported.warnings);
let squeeze = |s: &str| s.replace(' ', "");
// Point type: the characters, paragraphs, sizes, colours and place.
let (_, point) = &found[0];
assert!(matches!(point.kind, TextKind::Point));
assert_eq!(point.plain_text(), "Héllo €5\nWorld again");
assert_eq!(point.runs.len(), 2, "{:?}", point.runs);
let first = &point.runs[0].style;
assert_eq!(squeeze(&first.font_family), "SourceSans3", "{first:?}");
assert!((first.size - 18.0).abs() < 1e-6 && first.leading.is_none(), "{first:?}");
assert_eq!(first.fill.color(), Some(Color::rgb(1.0, 0.0, 0.0)));
assert_eq!(point.runs[1].style.fill.color(), Some(Color::cmyk(1.0, 0.0, 0.0, 0.0)));
assert!((point.runs[1].style.size - 12.0).abs() < 1e-6);
assert_eq!(point.para_at(0).justify, Justify::Left);
assert_eq!(point.para_at(1).justify, Justify::Center);
let origin = point.xf * vectorcraft_geom::Point::ZERO;
assert!((origin.x - 20.0).abs() < 1e-3 && (origin.y - 40.0).abs() < 1e-3, "{:?}", point.xf);
// Area type: its frame where it was.
let (_, area) = &found[1];
let TextKind::Area { frame } = &area.kind else { panic!("{:?}", area.kind) };
let b = frame.transformed(area.xf).to_bezpath().bounding_box();
assert!((b.x0 - 20.0).abs() < 1e-3 && (b.y0 - 80.0).abs() < 1e-3 && (b.x1 - 120.0).abs() < 1e-3 && (b.y1 - 130.0).abs() < 1e-3, "{b:?}");
assert_eq!(area.plain_text(), "Area type flows in its frame");
// Type on a path: its path and where along it the type starts.
let (_, along) = &found[2];
let TextKind::OnPath { path, start, .. } = &along.kind else { panic!("{:?}", along.kind) };
assert!((start - 0.25).abs() < 1e-2, "{start}");
let b = path.transformed(along.xf).to_bezpath().bounding_box();
assert!((b.x0 - 150.0).abs() < 1e-3 && (b.y1 - 190.0).abs() < 1e-3, "{b:?}");
}
#[test]
fn type_8_bit_text_cant_hold_and_type_asked_for_as_outlines_are_outlines() {
use vectorcraft_doc::TextObject;
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let t = TextObject::point(vectorcraft_geom::Point::new(10.0, 50.0), "Ωmega 日本", style(12.0, red()));
let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t)));
doc.insert(Some(layer), usize::MAX, n).unwrap();
let out = write(&doc);
assert!(out.warnings.iter().any(|w| w == TYPE), "{:?}", out.warnings);
assert!(!String::from_utf8_lossy(&out.bytes).contains(" To\n"));
// Type that could be text, asked for as outlines.
let out = write_with(&typed(), LegacyAiOptions { outline_text: true, ..LegacyAiOptions::default() });
let text = String::from_utf8(out.bytes).unwrap();
assert!(!text.contains(" To\n") && !text.contains("%%DocumentFonts"), "{text}");
assert!(!out.warnings.iter().any(|w| w == TYPE), "{:?}", out.warnings);
}
/// A layer `Art` holding a group of a shown square and a hidden one; a hidden layer `Off` holding
/// a hidden square.
fn with_hidden() -> Document {
let mut doc = Document::new(200.0, 100.0);
doc.layers.clear();
let art = doc.add_layer(Some("Art"));
let off = doc.add_layer(Some("Off"));
let shown = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), red(), Paint::None);
let mut hidden = shape(&mut doc, shapes::rectangle(Rect::new(50.0, 10.0, 80.0, 40.0)), red(), Paint::None);
hidden.visible = false;
hidden.locked = true;
let group = Node::group(doc.alloc_id(), vec![Arc::new(shown), Arc::new(hidden)]);
doc.insert(Some(art), usize::MAX, group).unwrap();
let mut also = shape(&mut doc, shapes::rectangle(Rect::new(100.0, 10.0, 140.0, 40.0)), red(), Paint::None);
also.visible = false;
doc.insert(Some(off), usize::MAX, also).unwrap();
if let Some(l) = doc.layers.get_mut(1) {
Arc::make_mut(l).visible = false;
}
doc
}
#[test]
fn hidden_objects_go_to_a_hidden_layer_above_their_own() {
let out = write(&with_hidden());
assert!(out.warnings.iter().any(|w| w == HIDDEN_LAYER), "{:?}", out.warnings);
let doc = crate::import(&out.bytes).unwrap().document;
let names: Vec<_> = doc.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect();
assert_eq!(names, ["Art", "Art (hidden)", "Off"]);
assert!(doc.layers[0].visible && !doc.layers[1].visible && !doc.layers[2].visible);
// The group keeps the shown square; the hidden one, still locked, is in the hidden layer.
let group = &children(&doc.layers[0])[0];
assert_eq!(children(group).len(), 1);
let moved = children(&doc.layers[1]);
assert_eq!(moved.len(), 1);
assert!(moved[0].locked);
let b = moved[0].visual_bounds().unwrap();
assert!((b.x0 - 50.0).abs() < 0.01, "{b:?}");
// In a hidden layer, hidden objects stay where they are.
assert_eq!(children(&doc.layers[2]).len(), 1);
}
#[test]
fn hidden_objects_can_be_left_out_or_shown() {
let out = write_with(&with_hidden(), LegacyAiOptions { hidden: HiddenObjects::Omit, ..LegacyAiOptions::default() });
assert!(out.warnings.iter().any(|w| w == HIDDEN), "{:?}", out.warnings);
let doc = crate::import(&out.bytes).unwrap().document;
assert_eq!(doc.layers.len(), 2);
assert_eq!(children(&children(&doc.layers[0])[0]).len(), 1);
let out = write_with(&with_hidden(), LegacyAiOptions { hidden: HiddenObjects::Visible, ..LegacyAiOptions::default() });
assert!(out.warnings.iter().any(|w| w == HIDDEN_SHOWN), "{:?}", out.warnings);
let doc = crate::import(&out.bytes).unwrap().document;
assert_eq!(doc.layers.len(), 2);
let pair = children(&children(&doc.layers[0])[0]);
assert_eq!(pair.len(), 2);
assert!(pair.iter().all(|n| n.visible));
}
+19
View File
@@ -289,6 +289,25 @@ impl FontFace {
pub fn face_index(&self) -> u32 {
self.index
}
/// The face's PostScript name, as PostScript files name fonts (`SourceSans3-Bold`): name ID 6;
/// for a named instance of a variable font (whose name ID 6 is the default instance's), its
/// prefix and style (`SourceSans3-Semibold`). Without one, its family and style.
pub fn postscript_name(&self) -> String {
let squeeze = |s: &str| -> String { s.chars().filter(|c| c.is_ascii_graphic() && !"[](){}<>/%".contains(*c)).collect() };
let own = self.skrifa().and_then(|f| localized(&f, StringId::POSTSCRIPT_NAME).into_iter().map(|(_, s)| s).next()).map(|s| squeeze(&s));
match own.filter(|s| !s.is_empty()) {
Some(name) if self.variations.is_empty() => name,
own => {
let prefix = own.as_deref().and_then(|n| n.split('-').next()).map_or_else(|| squeeze(&self.family), str::to_string);
format!("{prefix}-{}", squeeze(&self.style))
}
}
}
/// Are the face's outlines TrueType (`glyf`), not PostScript (`CFF `)?
pub fn is_truetype(&self) -> bool {
use skrifa::raw::TableProvider;
self.skrifa().is_some_and(|f| f.glyf().is_ok())
}
}
/// One installed family found by the system font scan: its name and the file of each style.
@@ -596,7 +596,7 @@ fn format_settings(ui: &mut egui::Ui, d: &mut Dialog, id: &str) {
}
/// A small cached rendering of artboard `i` (keyed by document revision).
fn artboard_thumb(app: &mut VectorcraftApp, ctx: &egui::Context, i: usize) -> Option<egui::TextureHandle> {
pub(super) fn artboard_thumb(app: &mut VectorcraftApp, ctx: &egui::Context, i: usize) -> Option<egui::TextureHandle> {
let st = app.session.active()?;
let key = egui::Id::new(("ab-thumb", i, st.revision));
if let Some(t) = ctx.data(|d| d.get_temp::<egui::TextureHandle>(key)) {
+16 -2
View File
@@ -149,6 +149,8 @@ pub(crate) struct DialogSpec {
/// The OK button's label when it depends on the app (Export for Screens' "Download" on the
/// web); `ok` when none. Only with an `ok`.
pub ok_label: Option<fn(&VectorcraftApp) -> &'static str>,
/// Can OK (or Enter) apply the dialog as it is? None: always. The body says why not.
pub ok_enabled: Option<fn(&VectorcraftApp, &Dialog) -> bool>,
}
impl DialogSpec {
@@ -170,6 +172,7 @@ impl DialogSpec {
max_width: None,
preview: false,
ok_label: None,
ok_enabled: None,
};
/// A dialog that draws its own window and confirms through its module.
@@ -380,6 +383,10 @@ pub(crate) fn settle(app: &mut VectorcraftApp) {
missing_fonts::open_next(app);
}
/// The field of a dialog that a save panel opened (its format's options): Enter doesn't confirm it
/// until Enter has been let go.
pub(crate) const AFTER_PANEL: &str = "__afterPanel";
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
about::show(app, ctx);
settle(app);
@@ -418,8 +425,14 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
return window(app, ctx);
}
let mut ok = false;
let mut ok_enabled = true;
let mut cancel = false;
let mut discard = false;
// Opened as a save panel closed: the Enter that closed it isn't this dialog's, until it is let go.
let after_panel = d.bool(AFTER_PANEL);
if after_panel && !ctx.input(|i| i.key_down(egui::Key::Enter) || i.key_pressed(egui::Key::Enter)) {
d.fields.remove(AFTER_PANEL);
}
let heading = (spec.heading)(&d);
let revolves = revolve_gizmo::active(&d);
if revolves {
@@ -450,9 +463,10 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
// right-to-left layout would otherwise take all the room left in the window, so the
// window could never shrink to its content.
let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y);
ok_enabled = spec.ok_enabled.is_none_or(|f| f(app, &d));
ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| {
if let Some(label) = spec.ok.map(|ok| spec.ok_label.map_or(ok, |f| f(app)))
&& widgets::primary_button(ui, label).clicked()
&& widgets::primary_button_enabled(ui, label, ok_enabled).clicked()
{
ok = true;
}
@@ -466,7 +480,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
});
});
if spec.ok.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Enter)) {
if spec.ok.is_some() && ok_enabled && !after_panel && ctx.input(|i| i.key_pressed(egui::Key::Enter)) {
ok = true;
}
if discard {
+343 -5
View File
@@ -7,7 +7,12 @@
//! editable), `path`, `format`, and one field per option (agents set them with `ui.dialog.set`).
//! Native and `.ai` files show their save options (VectorCraft Options after Save As): each
//! artboard to a separate file, with `all` (true: every artboard) or `range`, Include Linked Files,
//! Embed ICC Profiles, Create PDF-Compatible File and Use Compression.
//! Embed ICC Profiles, Create PDF-Compatible File and Use Compression. A `.ai` file's Version comes
//! first: PDF-compatible, or legacy version 7 (`aiVersion: "7"`, a file per artboard), which takes
//! only Transparent Images (`transparentImages`), Hidden Objects (`hiddenObjects`: a hidden layer,
//! omitted or made visible), Text (`outlineText`: editable or outlines) and, with several
//! artboards, a picker of the artboards to save (thumbnails to check, field `boards`, saved as
//! `range`).
use std::sync::OnceLock;
@@ -22,7 +27,12 @@ use crate::{VectorcraftApp, io, widgets};
/// Dialog kind.
pub const KIND: &str = "saveOptions";
pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 380.0, ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 380.0, ok_enabled: Some(can_save), ..DialogSpec::FORM };
/// OK saves a legacy `.ai` file only with an artboard checked.
fn can_save(app: &VectorcraftApp, d: &Dialog) -> bool {
!legacy_ai(d) || picked_boards(d, app.session.active().map_or(0, |st| st.doc.artboards.len())).iter().any(|b| *b)
}
fn heading(d: &Dialog) -> String {
if d.bool("__pick") {
@@ -33,6 +43,9 @@ fn heading(d: &Dialog) -> String {
}
.into();
}
if legacy_ai(d) {
return tl!("Legacy .ai Options").into();
}
crate::i18n::fmt(tl!("{format} Options"), &[("format", fileio::format(&d.str("format")).map_or("", |f| f.label))])
}
@@ -133,21 +146,221 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.end_row();
}
}
for o in shown(f) {
// Version first; the legacy version takes only its own options.
let legacy = legacy_ai(d);
let mut rows: Vec<&FormatOption> = shown(f).filter(|o| LEGACY_ONLY.contains(&o.name) == legacy || o.name == "aiVersion").collect();
rows.sort_by_key(|o| o.name != "aiVersion");
for o in rows {
option_row(app, ui, d, o);
}
});
if legacy_ai(d) && app.session.active().is_some_and(|st| st.doc.artboards.len() > 1) {
artboard_picker(app, ui, d);
}
if let Some(id) = switch.filter(|id| *id != f.id) {
switch_format(app, d, id);
}
false
}
/// One option: a checkbox, an artboard menu, a number or a text field.
/// The `.ai` options only the legacy version takes (and the only ones it takes, besides the
/// version).
const LEGACY_ONLY: &[&str] = &["transparentImages", "hiddenObjects", "outlineText"];
/// Is a `.ai` file to be saved in the legacy version 7 (`aiVersion: "7"`)?
fn legacy_ai(d: &Dialog) -> bool {
d.str("format") == "ai" && d.str("aiVersion").trim() == "7"
}
/// The artboards a legacy `.ai` save writes (field `boards`: one bool per artboard), as last
/// saved (`range`), else all of them.
fn picked_boards(d: &Dialog, n: usize) -> Vec<bool> {
if let Some(a) = d.fields.get("boards").and_then(Value::as_array) {
let mut b: Vec<bool> = a.iter().map(|b| b.as_bool().unwrap_or(false)).collect();
b.resize(n, false);
return b;
}
let range = d.str("range");
match (ArtboardPick { range: Some(range.clone()), ..Default::default() }).resolve(n) {
Ok(Some(chosen)) if !range.trim().is_empty() => (0..n).map(|i| chosen.contains(&i)).collect(),
_ => vec![true; n],
}
}
/// The file each checked artboard is saved to (`None` for the others): the file itself when one
/// is checked, else `{stem}-{artboard}.ai` beside it, as the save names them.
fn board_files(app: &VectorcraftApp, d: &Dialog, boards: &[bool]) -> Vec<Option<String>> {
let checked: Vec<usize> = boards.iter().enumerate().filter(|(_, on)| **on).map(|(i, _)| i).collect();
let path = d.str("path");
let file = std::path::Path::new(path.trim());
let name = file.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
let stem = file.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
let mut names = match (app.session.active(), checked.len()) {
(_, 1) => vec![name],
(Some(st), _) => fileio::artboard_file_names(&st.doc, &checked).into_iter().map(|b| format!("{stem}-{b}.ai")).collect(),
(None, _) => vec![],
}
.into_iter();
boards.iter().map(|on| if *on { names.next() } else { None }).collect()
}
/// `text` as it fits in `width` with `font`: whole, else its start cut short before its last
/// `tail` characters ("desktop-i…-Artboard-1.ai"), else as much of its end as fits.
fn elide_before_tail(ui: &egui::Ui, text: &str, tail: usize, font: &egui::FontId, width: f32) -> String {
let fits = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), egui::Color32::WHITE).size().x <= width;
if fits(text) {
return text.to_string();
}
let chars: Vec<char> = text.chars().collect();
let split = chars.len().saturating_sub(tail);
let (head, end) = chars.split_at(split);
let end: String = end.iter().collect();
for p in (1..head.len()).rev() {
let s = format!("{}…{end}", head.iter().take(p).collect::<String>());
if fits(&s) {
return s;
}
}
for k in (1..=chars.len()).rev() {
let s = format!("…{}", chars.iter().skip(chars.len() - k).collect::<String>());
if fits(&s) {
return s;
}
}
"…".into()
}
/// Legacy `.ai`: the artboards to save, as thumbnails to check; each is saved as a file of its own.
fn artboard_picker(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
const TILE: egui::Vec2 = egui::vec2(116.0, 84.0);
let t = Tokens::get(ui.ctx());
let names: Vec<String> = app.session.active().map(|st| st.doc.artboards.iter().map(|a| a.name.clone()).collect()).unwrap_or_default();
let n = names.len();
let mut boards = picked_boards(d, n);
let before = boards.clone();
ui.add_space(10.0);
ui.add(
egui::Label::new(
egui::RichText::new(tl!("A legacy .ai file holds one artboard: each artboard you check is saved as a separate file.")).color(t.text_dim),
)
.wrap(),
);
ui.add_space(4.0);
ui.horizontal(|ui| {
if ui.small_button(tl!("Select All")).clicked() {
boards.iter_mut().for_each(|b| *b = true);
}
if ui.small_button(tl!("Clear")).clicked() {
boards.iter_mut().for_each(|b| *b = false);
}
let checked = boards.iter().filter(|b| **b).count();
let (count, total) = (checked.to_string(), n.to_string());
let text = crate::i18n::fmt(tl!("{count} of {total} artboards"), &[("count", &count), ("total", &total)]);
ui.label(egui::RichText::new(text).color(if checked == 0 { t.warning } else { t.text_dim }));
if checked == 0 {
ui.label(egui::RichText::new(tl!("Check an artboard to save.")).color(t.warning));
}
});
ui.add_space(4.0);
let files = board_files(app, d, &boards);
let stem = std::path::Path::new(d.str("path").trim()).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
egui::ScrollArea::vertical().max_height(330.0).show(ui, |ui| {
ui.with_layout(egui::Layout::left_to_right(egui::Align::Min).with_main_wrap(true), |ui| {
ui.spacing_mut().item_spacing = egui::vec2(10.0, 10.0);
for (i, name) in names.iter().enumerate() {
let on = boards.get(i).copied().unwrap_or(false);
ui.allocate_ui(egui::vec2(TILE.x, TILE.y + 44.0), |ui| {
ui.vertical(|ui| {
ui.set_width(TILE.x);
let (r, resp) = ui.allocate_exact_size(TILE, egui::Sense::click());
ui.painter().rect_filled(r, 3.0, t.pasteboard);
let inner = r.shrink(6.0);
match super::export_for_screens::artboard_thumb(app, ui.ctx(), i) {
Some(tex) => {
let sz = tex.size_vec2();
let s = (inner.width() / sz.x).min(inner.height() / sz.y).min(1.0);
let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0));
ui.painter().image(tex.id(), egui::Rect::from_center_size(inner.center(), sz * s), uv, egui::Color32::WHITE);
}
None => {
ui.painter().rect_filled(inner, 0.0, egui::Color32::WHITE);
}
}
let stroke = if on { egui::Stroke::new(2.0, t.accent) } else { egui::Stroke::new(1.0, t.border) };
ui.painter().rect_stroke(r, 3.0, stroke, egui::StrokeKind::Inside);
let mut flip = resp.on_hover_text(name.as_str()).clicked();
flip |= widgets::check_name(ui, name, on);
let file = files.get(i).cloned().flatten().unwrap_or_default();
// The end tells the files apart: the start gives way first.
let font = egui::TextStyle::Small.resolve(ui.style());
let tail = file.strip_prefix(stem.as_str()).map_or(0, |t| t.chars().count());
let shown = elide_before_tail(ui, &file, tail, &font, TILE.x);
let label = ui.add(egui::Label::new(egui::RichText::new(&shown).small().color(t.text_dim)));
if shown != file {
label.on_hover_text(file);
}
if flip && let Some(b) = boards.get_mut(i) {
*b ^= true;
}
});
});
}
});
});
if boards != before || !d.fields.contains_key("boards") {
d.fields.insert("boards".into(), json!(boards));
}
}
/// One option: a checkbox, an artboard menu, a version menu, a number or a text field.
fn option_row(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, o: &FormatOption) {
let t = Tokens::get(ui.ctx());
let label = label(o);
let value = d.fields.get(o.name).cloned().unwrap_or(Value::Null);
if o.name == "aiVersion" {
ui.label(egui::RichText::new(tl!("Version:")).color(t.text_dim)).on_hover_text(o.description);
let versions = [tl!("PDF-compatible"), tl!("Legacy (version 7)")];
let at = usize::from(legacy_ai(d));
if let Some(i) = widgets::dropdown(ui, ("save-option", o.name), versions.get(at).copied().unwrap_or_default(), &versions, 254.0) {
d.fields.insert(o.name.into(), json!(if i == 1 { "7" } else { "pdf" }));
}
ui.end_row();
return;
}
if o.name == "transparentImages" {
ui.label(egui::RichText::new(tl!("Transparent Images:")).color(t.text_dim)).on_hover_text(o.description);
let choices = [tl!("Flatten"), tl!("Omit")];
let at = usize::from(d.str("transparentImages").trim().eq_ignore_ascii_case("omit"));
if let Some(i) = widgets::dropdown(ui, ("save-option", o.name), choices.get(at).copied().unwrap_or_default(), &choices, 254.0) {
d.fields.insert(o.name.into(), json!(if i == 1 { "omit" } else { "flatten" }));
}
ui.end_row();
return;
}
if o.name == "hiddenObjects" {
ui.label(egui::RichText::new(tl!("Hidden Objects:")).color(t.text_dim)).on_hover_text(o.description);
let choices = [tl!("Hidden Layer"), tl!("Omit"), tl!("Make Visible")];
let at = match d.str("hiddenObjects").trim() {
"omit" => 1,
"visible" => 2,
_ => 0,
};
if let Some(i) = widgets::dropdown(ui, ("save-option", o.name), choices.get(at).copied().unwrap_or_default(), &choices, 254.0) {
d.fields.insert(o.name.into(), json!(["layer", "omit", "visible"].get(i).copied().unwrap_or("layer")));
}
ui.end_row();
return;
}
if o.name == "outlineText" && legacy_ai(d) {
ui.label(egui::RichText::new(tl!("Text:")).color(t.text_dim)).on_hover_text(o.description);
let choices = [tl!("Editable"), tl!("Outlines")];
let at = usize::from(value.as_bool().unwrap_or(false));
if let Some(i) = widgets::dropdown(ui, ("save-option", o.name), choices.get(at).copied().unwrap_or_default(), &choices, 254.0) {
d.fields.insert(o.name.into(), json!(i == 1));
}
ui.end_row();
return;
}
if o.ty == "boolean" {
ui.label("");
let on = value.as_bool().unwrap_or(false);
@@ -216,7 +429,18 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult {
.iter()
.filter_map(|o| d.fields.get(o.name).filter(|v| !v.is_null() && v.as_str() != Some("")).map(|v| (o.name.to_string(), v.clone())))
.collect();
if separates(f) {
if legacy_ai(d) {
// The artboards checked in the picker (all of them: no range).
options.remove("range");
let n = app.session.active().map_or(0, |s| s.doc.artboards.len());
let boards = picked_boards(d, n);
if !boards.iter().any(|b| *b) {
return Err(tl!("Check an artboard to save.").into());
}
if !boards.iter().all(|b| *b) {
options.insert("range".into(), json!(super::export_for_screens::range_text(&boards)));
}
} else if separates(f) {
if !d.bool("separateArtboards") || all_artboards(d) {
options.remove("range");
} else {
@@ -335,6 +559,120 @@ mod tests {
assert!(written.borrow()[1].1.starts_with(b"<?xml"));
}
#[test]
fn save_as_ai_version_7_writes_a_file_per_artboard() {
let (mut app, written, _) = desktop("art.ai");
app.run("artboard.new", json!({"x": 200, "y": 0, "width": 100, "height": 80, "name": "Back"})).unwrap();
assert_eq!(app.run("file.saveAs", json!({})).unwrap()["pending"], KIND);
assert_eq!(dialog(&app).str("aiVersion"), "pdf", "PDF-compatible unless asked");
assert_eq!(dialog(&app).str("transparentImages"), "flatten");
assert_eq!(dialog(&app).str("hiddenObjects"), "layer");
assert!(!dialog(&app).bool("outlineText"));
frame(&mut app, false);
app.ui.dialog.as_mut().unwrap().fields.insert("aiVersion".into(), json!("7"));
app.ui.dialog.as_mut().unwrap().fields.insert("transparentImages".into(), json!("omit"));
app.ui.dialog.as_mut().unwrap().fields.insert("hiddenObjects".into(), json!("visible"));
app.ui.dialog.as_mut().unwrap().fields.insert("outlineText".into(), json!(true));
frame(&mut app, false);
frame(&mut app, true);
assert!(app.ui.dialog.is_none());
let names: Vec<String> = written.borrow().iter().map(|(p, _)| p.clone()).collect();
assert_eq!(names, ["art-Artboard-1.ai", "art-Back.ai"]);
assert!(written.borrow().iter().all(|(_, b)| b.starts_with(b"%!PS")));
let st = app.session.active().unwrap();
assert_eq!((st.format, st.save_options["aiVersion"].clone()), ("ai", json!("7")), "Save remembers the version");
assert_eq!(st.save_options["transparentImages"], json!("omit"));
assert_eq!((st.save_options["hiddenObjects"].clone(), st.save_options["outlineText"].clone()), (json!("visible"), json!(true)));
}
/// An Illustrator file opened, then saved as `.ai`: the options dialog stays open through the
/// Enter that closed the save panel, and only an Enter after it is let go confirms it.
#[test]
fn legacy_ai_saves_the_artboards_checked_in_its_picker() {
let (mut app, written, _) = desktop("art.ai");
for (i, name) in ["Back", "Side", "Top"].iter().enumerate() {
let x = 200.0 * (i + 1) as f64;
app.run("artboard.new", json!({"x": x, "y": 0, "width": 100, "height": 80 + 20 * i, "name": name})).unwrap();
app.run("shape.ellipse", json!({"x": x + 10.0, "y": 10, "width": 40, "height": 40})).unwrap();
}
app.run("file.saveAs", json!({})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("aiVersion".into(), json!("7"));
frame(&mut app, false);
assert_eq!(dialog(&app).fields["boards"], json!([true, true, true, true]), "every artboard to start with");
if let Some(dir) = std::env::var_os("CRAFT_UI_SAVE_OPTIONS_SHOT") {
let mut ready = false;
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 760.0)).wgpu().build_ui_state(
move |ui, app: &mut VectorcraftApp| {
if !ready {
theme::install_fonts(ui.ctx());
theme::apply(ui.ctx(), theme::Brightness::MediumDark);
ready = true;
return;
}
super::super::show(app, ui.ctx());
},
app,
);
h.run_steps(6);
h.render().unwrap().save(std::path::Path::new(&dir).join("legacy-ai-picker.png")).unwrap();
let fields = &mut h.state_mut().ui.dialog.as_mut().unwrap().fields;
fields.insert("path".into(), json!("desktop-ideas1-vc5.ai"));
fields.insert("boards".into(), json!([false, false, false, false]));
h.run_steps(2);
h.state_mut().ui.dialog.as_mut().unwrap().fields.insert("boards".into(), json!([true, true, false, true]));
h.run_steps(2);
h.render().unwrap().save(std::path::Path::new(&dir).join("legacy-ai-picker-long.png")).unwrap();
h.state_mut().ui.dialog.as_mut().unwrap().fields.insert("boards".into(), json!([false, false, false, false]));
h.run_steps(2);
h.render().unwrap().save(std::path::Path::new(&dir).join("legacy-ai-picker-none.png")).unwrap();
app = h.into_state();
app.ui.dialog.as_mut().unwrap().fields.insert("path".into(), json!("art.ai"));
}
// Clearing them all keeps the dialog open.
app.ui.dialog.as_mut().unwrap().fields.insert("boards".into(), json!([false, false, false, false]));
let d = dialog(&app).clone();
assert!(!can_save(&app, &d), "OK is greyed out");
frame(&mut app, true);
assert!(app.ui.dialog.is_some(), "Enter does nothing");
assert!(confirm(&mut app, &d).is_err());
assert!(written.borrow().is_empty());
app.ui.dialog.as_mut().unwrap().fields.insert("boards".into(), json!([true, false, true, false]));
frame(&mut app, true);
assert!(app.ui.dialog.is_none());
let names: Vec<String> = written.borrow().iter().map(|(p, _)| p.clone()).collect();
assert_eq!(names, ["art-Artboard-1.ai", "art-Side.ai"]);
assert_eq!(board_files_of(&app, [true, false, true, false]), [Some("art-Artboard-1.ai".into()), None, Some("art-Side.ai".into()), None]);
assert_eq!(board_files_of(&app, [false, true, false, false]), [None, Some("art.ai".into()), None, None], "one: the file itself");
// Save As again starts from the artboards saved.
assert_eq!(app.session.active().unwrap().save_options["range"], json!("1, 3"));
app.run("file.saveAs", json!({})).unwrap();
frame(&mut app, false);
assert_eq!(dialog(&app).fields["boards"], json!([true, false, true, false]));
}
fn board_files_of(app: &VectorcraftApp, boards: [bool; 4]) -> Vec<Option<String>> {
let d = Dialog { kind: KIND.into(), fields: [("path".to_string(), json!("art.ai"))].into_iter().collect() };
board_files(app, &d, &boards)
}
#[test]
fn the_enter_that_closed_the_save_panel_does_not_confirm_the_options() {
let (mut app, written, _) = desktop("again.ai");
let dir = vectorcraft_testkit::temp_dir("ai-options-enter");
let orig = dir.join("orig.ai");
std::fs::write(&orig, vectorcraft_testkit::ai::ai(&vectorcraft_testkit::ai::sample_data(), vectorcraft_testkit::ai::page_pdf())).unwrap();
app.run("document.open", json!({"path": orig.to_string_lossy()})).unwrap();
assert_eq!(app.run("file.saveAs", json!({})).unwrap()["pending"], KIND);
frame(&mut app, true);
assert!(app.ui.dialog.is_some(), "the panel's Enter doesn't confirm it");
assert!(written.borrow().is_empty());
// Let go, then pressed again: OK.
frame(&mut app, false);
frame(&mut app, true);
assert!(app.ui.dialog.is_none());
assert_eq!(written.borrow()[0].0, "again.ai");
}
#[test]
fn save_a_copy_and_template_suggest_their_names() {
let (mut app, written, picks) = desktop("copy.vectorcraft");
+14
View File
@@ -3178,6 +3178,20 @@ theme Appearance Motiv
Large Velké
Large Tabs Velké karty
Legacy vector clipboard Starší vektorová schránka
Legacy (version 7) Starší (verze 7)
Transparent Images: Průhledné obrazy:
Flatten Sloučit
Omit Vynechat
Legacy .ai Options Starší soubor .ai – volby
A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Soubor .ai ve starší verzi obsahuje jedinou kreslicí plochu: každá zaškrtnutá kreslicí plocha se uloží jako samostatný soubor.
{count} of {total} artboards {count} z {total} kreslicích ploch
Check an artboard to save. Zaškrtněte kreslicí plochu, kterou chcete uložit.
Hidden Objects: Skryté objekty:
Hidden Layer Skrytá vrstva
Make Visible Zviditelnit
Text: Text:
Editable Upravitelný
PDF-compatible Kompatibilní s PDF
Legacy vector clipboard (no transparency) Starší vektorová schránka (bez průhlednosti)
Line Color Barva čar
Lines Čáry
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -1461,6 +1461,20 @@
Left: Links:
Legacy Macintosh RGB (Gamma 1.8) Älteres Macintosh-RGB (Gamma 1,8)
Legacy vector clipboard Ältere Vektorzwischenablage
Legacy (version 7) Älter (Version 7)
Transparent Images: Transparente Bilder:
Flatten Reduzieren
Omit Weglassen
Legacy .ai Options Optionen für ältere .ai-Datei
A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Eine ältere .ai-Datei enthält eine Zeichenfläche: Jede aktivierte Zeichenfläche wird als eigene Datei gespeichert.
{count} of {total} artboards {count} von {total} Zeichenflächen
Check an artboard to save. Aktivieren Sie eine Zeichenfläche zum Speichern.
Hidden Objects: Ausgeblendete Objekte:
Hidden Layer Ausgeblendete Ebene
Make Visible Einblenden
Text: Text:
Editable Bearbeitbar
PDF-compatible PDF-kompatibel
Legacy vector clipboard (no transparency) Ältere Vektorzwischenablage (ohne Transparenz)
Less Weniger
Letter Letter
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -1477,6 +1477,20 @@
Left: Izquierda:
Legacy Macintosh RGB (Gamma 1.8) RGB de Macintosh heredado (Gamma 1.8)
Legacy vector clipboard Portapapeles vectorial heredado
Legacy (version 7) Heredado (versión 7)
Transparent Images: Imágenes transparentes:
Flatten Acoplar
Omit Omitir
Legacy .ai Options Opciones de .ai heredado
A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Un archivo .ai heredado contiene una mesa de trabajo: cada mesa de trabajo marcada se guarda como un archivo independiente.
{count} of {total} artboards {count} de {total} mesas de trabajo
Check an artboard to save. Marque una mesa de trabajo para guardarla.
Hidden Objects: Objetos ocultos:
Hidden Layer Capa oculta
Make Visible Hacer visibles
Text: Texto:
Editable Editable
PDF-compatible Compatible con PDF
Legacy vector clipboard (no transparency) Portapapeles vectorial heredado (sin transparencia)
Less Menos
Letter Carta
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -1476,6 +1476,20 @@
Left: Gauche :
Legacy Macintosh RGB (Gamma 1.8) RVB Macintosh hérité (gamma 1,8)
Legacy vector clipboard Presse-papiers vectoriel hérité
Legacy (version 7) Hérité (version 7)
Transparent Images: Images transparentes :
Flatten Aplatir
Omit Omettre
Legacy .ai Options Options .ai hérité
A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Un fichier .ai hérité contient un seul plan de travail : chaque plan de travail coché est enregistré dans un fichier distinct.
{count} of {total} artboards {count} plans de travail sur {total}
Check an artboard to save. Cochez un plan de travail à enregistrer.
Hidden Objects: Objets masqués :
Hidden Layer Calque masqué
Make Visible Rendre visibles
Text: Texte :
Editable Modifiable
PDF-compatible Compatible PDF
Legacy vector clipboard (no transparency) Presse-papiers vectoriel hérité (sans transparence)
Less Moins
Letter Lettre US
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -1880,6 +1880,20 @@
Left: Bal:
Legacy Macintosh RGB (Gamma 1.8) Régi Macintosh RGB (gamma 1,8)
Legacy vector clipboard Régi vektoros vágólap
Legacy (version 7) Régi (7-as verzió)
Transparent Images: Átlátszó képek:
Flatten Egyesítés
Omit Kihagyás
Legacy .ai Options Régi .ai beállításai
A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. A régi .ai fájl egy rajztáblát tartalmaz: minden bejelölt rajztábla külön fájlba kerül.
{count} of {total} artboards {count} / {total} rajztábla
Check an artboard to save. Jelöljön be egy mentendő rajztáblát.
Hidden Objects: Rejtett objektumok:
Hidden Layer Rejtett réteg
Make Visible Láthatóvá tétel
Text: Szöveg:
Editable Szerkeszthető
PDF-compatible PDF-kompatibilis
Legacy vector clipboard (no transparency) Régi vektoros vágólap (átlátszóság nélkül)
Legal Legal
Length: Hossz:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -1479,6 +1479,20 @@
Left: Sinistra:
Legacy Macintosh RGB (Gamma 1.8) RGB Macintosh tradizionale (gamma 1.8)
Legacy vector clipboard Appunti vettoriali tradizionali
Legacy (version 7) Tradizionale (versione 7)
Transparent Images: Immagini trasparenti:
Flatten Unisci
Omit Ometti
Legacy .ai Options Opzioni .ai tradizionale
A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Un file .ai tradizionale contiene una tavola da disegno: ogni tavola selezionata viene salvata come file separato.
{count} of {total} artboards {count} di {total} tavole da disegno
Check an artboard to save. Seleziona una tavola da disegno da salvare.
Hidden Objects: Oggetti nascosti:
Hidden Layer Livello nascosto
Make Visible Rendi visibili
Text: Testo:
Editable Modificabile
PDF-compatible Compatibile PDF
Legacy vector clipboard (no transparency) Appunti vettoriali tradizionali (senza trasparenza)
Less Meno
Letter Lettera
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -1666,6 +1666,20 @@
Left Plane 左面
Left: 左:
Legacy vector clipboard 従来のベクタークリップボード
Legacy (version 7) 従来 (バージョン 7)
Transparent Images: 透明な画像:
Flatten 統合
Omit 省略
Legacy .ai Options 従来の .ai オプション
A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. 従来の .ai ファイルにはアートボードが 1 つしか入りません。チェックした各アートボードは別々のファイルとして保存されます。
{count} of {total} artboards {total} 個中 {count} 個のアートボード
Check an artboard to save. 保存するアートボードをチェックしてください。
Hidden Objects: 非表示のオブジェクト:
Hidden Layer 非表示レイヤー
Make Visible 表示する
Text: テキスト:
Editable 編集可能
PDF-compatible PDF 互換
Legacy vector clipboard (no transparency) 従来のベクタークリップボード (透明なし)
Less 少なく
Ligatures are off while the text is tracked. テキストにトラッキングが設定されている間、合字は無効です。
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -1661,6 +1661,20 @@
Left Plane 왼쪽 평면
Left: 왼쪽:
Legacy vector clipboard 레거시 벡터 클립보드
Legacy (version 7) 레거시 (버전 7)
Transparent Images: 투명 이미지:
Flatten 병합
Omit 생략
Legacy .ai Options 레거시 .ai 옵션
A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. 레거시 .ai 파일에는 대지가 하나만 들어갑니다. 선택한 각 대지는 별도의 파일로 저장됩니다.
{count} of {total} artboards 대지 {total}개 중 {count}개
Check an artboard to save. 저장할 대지를 선택하십시오.
Hidden Objects: 숨겨진 개체:
Hidden Layer 숨겨진 레이어
Make Visible 표시
Text: 텍스트:
Editable 편집 가능
PDF-compatible PDF 호환
Legacy vector clipboard (no transparency) 레거시 벡터 클립보드 (투명도 없음)
Less 간단히
Ligatures are off while the text is tracked. 자간이 적용된 텍스트에서는 합자가 꺼집니다.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -4651,6 +4651,20 @@ mark-style Japanese Japońskie
Softness Miękkość
Ambient Light Światło otoczenia
Light behind the object Światło za obiektem
Legacy (version 7) Starsza (wersja 7)
Transparent Images: Przezroczyste obrazy:
Flatten Spłaszcz
Omit Pomiń
Legacy .ai Options Opcje starszego pliku .ai
A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Starszy plik .ai zawiera jeden obszar kompozycji: każdy zaznaczony obszar kompozycji jest zapisywany jako osobny plik.
{count} of {total} artboards {count} z {total} obszarów kompozycji
Check an artboard to save. Zaznacz obszar kompozycji, aby zapisać.
Hidden Objects: Ukryte obiekty:
Hidden Layer Ukryta warstwa
Make Visible Uwidocznij
Text: Tekst:
Editable Edytowalny
PDF-compatible Zgodny z PDF
# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
Dimension Wymiar
Drawing : real size — 1:10 labels 1 unit on the page as 10. Rysunek : rozmiar rzeczywisty — 1:10 opisuje 1 jednostkę na stronie jako 10.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -1225,6 +1225,20 @@
Left: Esquerda:
Legacy Macintosh RGB (Gamma 1.8) Macintosh RGB Legado (Gama 1.8)
Legacy vector clipboard Área de transferência vetorial legada
Legacy (version 7) Legado (versão 7)
Transparent Images: Imagens transparentes:
Flatten Achatar
Omit Omitir
Legacy .ai Options Opções de .ai legado
A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Um arquivo .ai legado contém uma prancheta: cada prancheta marcada é salva como um arquivo separado.
{count} of {total} artboards {count} de {total} pranchetas
Check an artboard to save. Marque uma prancheta para salvar.
Hidden Objects: Objetos ocultos:
Hidden Layer Camada oculta
Make Visible Tornar visíveis
Text: Texto:
Editable Editável
PDF-compatible Compatível com PDF
Legacy vector clipboard (no transparency) Área de transferência vetorial legada (sem transparência)
Less Menos
Levels… Níveis…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+14
View File
@@ -1477,6 +1477,20 @@
Left: Слева:
Legacy Macintosh RGB (Gamma 1.8) Устаревший Macintosh RGB (гамма 1.8)
Legacy vector clipboard Устаревший векторный буфер обмена
Legacy (version 7) Устаревший (версия 7)
Transparent Images: Прозрачные изображения:
Flatten Объединить
Omit Пропустить
Legacy .ai Options Параметры устаревшего .ai
A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Устаревший файл .ai содержит одну монтажную область: каждая отмеченная монтажная область сохраняется в отдельный файл.
{count} of {total} artboards {count} из {total} монтажных областей
Check an artboard to save. Отметьте монтажную область для сохранения.
Hidden Objects: Скрытые объекты:
Hidden Layer Скрытый слой
Make Visible Сделать видимыми
Text: Текст:
Editable Редактируемый
PDF-compatible Совместимый с PDF
Legacy vector clipboard (no transparency) Устаревший векторный буфер обмена (без прозрачности)
Less Меньше
Letter Letter
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -1473,6 +1473,20 @@
Left: Зліва:
Legacy Macintosh RGB (Gamma 1.8) Застарілий Macintosh RGB (гама 1.8)
Legacy vector clipboard Застарілий векторний буфер обміну
Legacy (version 7) Застарілий (версія 7)
Transparent Images: Прозорі зображення:
Flatten Об’єднати
Omit Пропустити
Legacy .ai Options Параметри застарілого .ai
A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Застарілий файл .ai містить одну монтажну область: кожна позначена монтажна область зберігається в окремий файл.
{count} of {total} artboards {count} з {total} монтажних областей
Check an artboard to save. Позначте монтажну область для збереження.
Hidden Objects: Приховані об’єкти:
Hidden Layer Прихований шар
Make Visible Зробити видимими
Text: Текст:
Editable Редагований
PDF-compatible Сумісний із PDF
Legacy vector clipboard (no transparency) Застарілий векторний буфер обміну (без прозорості)
Less Менше
Letter Letter
Can't render this file because it contains an unexpected character in line 713 and column 40.
+14
View File
@@ -1694,6 +1694,20 @@
Left-Justified Tab Tab căn trái
Left-to-Right Paragraph Direction Đoạn văn từ trái sang phải
Left: Trái:
Legacy (version 7) Cũ (phiên bản 7)
Transparent Images: Ảnh trong suốt:
Flatten Làm phẳng
Omit Bỏ qua
Legacy .ai Options Tùy chọn .ai cũ
A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Một tệp .ai cũ chỉ chứa một bảng vẽ: mỗi bảng vẽ bạn chọn được lưu thành một tệp riêng.
{count} of {total} artboards {count} trên {total} bảng vẽ
Check an artboard to save. Hãy chọn một bảng vẽ để lưu.
Hidden Objects: Đối tượng ẩn:
Hidden Layer Lớp ẩn
Make Visible Hiện ra
Text: Văn bản:
Editable Có thể chỉnh sửa
PDF-compatible Tương thích PDF
Legacy Macintosh RGB (Gamma 1.8) RGB Macintosh cũ (Gamma 1.8)
Legacy vector clipboard Bảng tạm vector cũ
Legacy vector clipboard (no transparency) Bảng tạm vector cũ (không có trong suốt)
1 # Vietnamese (vi) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
+14
View File
@@ -1381,6 +1381,20 @@
Left: 左:
Legacy Macintosh RGB (Gamma 1.8) 旧版 Macintosh RGB (Gamma 1.8)
Legacy vector clipboard 旧版矢量剪贴板
Legacy (version 7) 旧版 (版本 7)
Transparent Images: 透明图像:
Flatten 拼合
Omit 省略
Legacy .ai Options 旧版 .ai 选项
A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. 旧版 .ai 文件只包含一个画板:勾选的每个画板都会存储为单独的文件。
{count} of {total} artboards {total} 个画板中的 {count} 个
Check an artboard to save. 请勾选要存储的画板。
Hidden Objects: 隐藏的对象:
Hidden Layer 隐藏图层
Make Visible 设为可见
Text: 文本:
Editable 可编辑
PDF-compatible 与 PDF 兼容
Legacy vector clipboard (no transparency) 旧版矢量剪贴板 (无透明度)
Less 较少
Letter 信纸
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -1455,6 +1455,20 @@
Left: 左:
Legacy Macintosh RGB (Gamma 1.8) 舊版 Macintosh RGB (Gamma 1.8)
Legacy vector clipboard 舊版向量剪貼簿
Legacy (version 7) 舊版 (版本 7)
Transparent Images: 透明影像:
Flatten 平面化
Omit 略過
Legacy .ai Options 舊版 .ai 選項
A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. 舊版 .ai 檔案只包含一個工作區域:勾選的每個工作區域都會儲存為獨立的檔案。
{count} of {total} artboards {total} 個工作區域中的 {count} 個
Check an artboard to save. 請勾選要儲存的工作區域。
Hidden Objects: 隱藏的物件:
Hidden Layer 隱藏圖層
Make Visible 設為可見
Text: 文字:
Editable 可編輯
PDF-compatible 與 PDF 相容
Legacy vector clipboard (no transparency) 舊版向量剪貼簿 (無透明度)
Less 較少
Letter Letter
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5 -1
View File
@@ -412,7 +412,11 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo
}
if ask && asks_options(mode, chosen.format) {
let path = chosen.path.clone().unwrap_or_default();
return ask_format_options(app, mode, chosen.format, &path);
let r = ask_format_options(app, mode, chosen.format, &path);
if let Some(d) = app.ui.dialog.as_mut() {
d.fields.insert(crate::dialogs::AFTER_PANEL.into(), json!(true));
}
return r;
}
write_plan(app, chosen)
}
+17 -4
View File
@@ -89,13 +89,26 @@ fn flat_fill(ui: &Ui, rect: Rect, resp: &Response) {
/// Blue call-to-action pill (dialog OK/Create).
pub fn primary_button(ui: &mut Ui, text: &str) -> Response {
primary_button_enabled(ui, text, true)
}
/// [`primary_button`], greyed out and not clickable unless `enabled`.
pub fn primary_button_enabled(ui: &mut Ui, text: &str, enabled: bool) -> Response {
let t = Tokens::get(ui.ctx());
let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), theme::semibold(12.5), Color32::WHITE);
let fg = if enabled { Color32::WHITE } else { t.text_disabled };
let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), theme::semibold(12.5), fg);
let w = galley.size().x + 32.0;
let (rect, resp) = ui.allocate_exact_size(vec2(w.max(72.0), 28.0), Sense::click());
let bg = if resp.hovered() { t.accent } else { t.accent_strong };
let (rect, resp) = ui.allocate_exact_size(vec2(w.max(72.0), 28.0), if enabled { Sense::click() } else { Sense::hover() });
let bg = match (enabled, resp.hovered()) {
(false, _) => t.input,
(true, true) => t.accent,
(true, false) => t.accent_strong,
};
ui.painter().rect_filled(rect, CornerRadius::same(14), bg);
ui.painter().galley(rect.center() - galley.size() / 2.0, galley, Color32::WHITE);
if !enabled {
ui.painter().rect_stroke(rect, CornerRadius::same(14), egui::Stroke::new(1.0, t.button_border), egui::StrokeKind::Inside);
}
ui.painter().galley(rect.center() - galley.size() / 2.0, galley, fg);
resp
}
+5 -1
View File
@@ -621,7 +621,11 @@ Native save options: File → Save As to a native or `.ai` file opens the `saveO
(heading "VectorCraft Options" or "PDF-compatible .ai Options"). Fields: `separateArtboards`, `all` (true: every
artboard; false: `range`, such as "1-3, 5"), `includeLinked`, `embedProfiles`, `pdfCompatible`, `compress`, and for
native files `version` and `preview`. A bad range keeps the dialog open; `ui.dialog.confirm` writes the file and the
artboards' files.
artboards' files. A `.ai` file's `aiVersion` comes first: `"pdf"` (the default) or `"7"` (legacy, heading "Legacy .ai
Options"), which takes only `transparentImages` (`"flatten"` or `"omit"`), `hiddenObjects` (`"layer"`, `"omit"` or
`"visible"`, the Hidden Objects menu), `outlineText` (the Text menu: false Editable, true Outlines) and, with several artboards, the artboard
picker: `boards`, one bool per artboard (default: those last saved, else all), saved as `range`, one file per checked
artboard. OK is disabled with none checked.
EMF and WMF: Export As… lists both formats (`file.exportAs {format: "emf"}` or `"wmf"`); they have no options
dialog, so `ui.dialog.confirm` asks for the path and writes the file (one per artboard with Use Artboards). Open and
File diff suppressed because one or more lines are too long
+11 -5
View File
File diff suppressed because one or more lines are too long
+8 -1
View File
@@ -594,7 +594,14 @@ imported instead and the first warning says why. Choosing a standard turns it of
path (`document.save {path: "art.ai"}` or `{format: "ai"}`) writes a PDF-compatible file that always carries the
document, takes the PDF options and keeps its path when reopened, so Save writes `.ai` again; a `.ait` opens untitled.
`document.export` / `serialize` to `.ai` (and the CLI's `run --export x.ai` and `convert in.svg out.ai`) write the same file
without giving the document a path.
without giving the document a path. `options: {aiVersion: "7"}` writes legacy `.ai` files of version 7 instead (the
PostScript form, which keeps layers, groups, compound paths and clipping groups in apps that read it), one per artboard
of `range` (default all; `{name}-{artboard}.ai` when several, listed in the result's `files`), with
`transparentImages: "flatten"` (default) or `"omit"` for images with see-through pixels, opacity or a blending mode,
`hiddenObjects: "layer"` (default: a hidden layer of each layer's hidden objects), `"omit"` or `"visible"`, and
`outlineText: true` to write type as outlines instead of text objects. From the CLI: `vectorcraft-cli convert in.svg
out.ai --ai-version 7 [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] [--outline-text]
[--range R]`.
Drive a tool like a mouse:
+3 -2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long