mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 13:17:19 -06:00
Merge pull request #1794 from danngreen/feat/legacy-ai-export
Add Legacy .ai export (keeps groups and layers)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ mod encode;
|
||||
pub mod eps;
|
||||
mod export;
|
||||
pub(crate) mod imagemap;
|
||||
mod legacy_ai;
|
||||
mod load;
|
||||
pub(crate) use load::{MAX_RASTER_ALLOC, too_large_note};
|
||||
mod metafile;
|
||||
|
||||
@@ -99,6 +99,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)]
|
||||
|
||||
@@ -335,7 +335,8 @@ impl<W: std::io::Write> std::io::Write for Counted<W> {
|
||||
/// The artboards a native or `.ai` save also writes to files of their own: with
|
||||
/// `separateArtboards`, those `range` names (default all).
|
||||
fn separate_boards(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<usize>> {
|
||||
if !is_native(f) || !bool_or(p, "separateArtboards", false) {
|
||||
// A legacy .ai file is a file per artboard already.
|
||||
if !is_native(f) || !bool_or(p, "separateArtboards", false) || super::legacy_ai::wanted(cmd, p)? {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let range = str_param(p, "range").filter(|r| !r.trim().is_empty()).map(str::to_string);
|
||||
@@ -346,7 +347,7 @@ fn separate_boards(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<V
|
||||
|
||||
/// `doc` reduced to artboard `i` and the art on it (Save each artboard to a separate file):
|
||||
/// objects whose bounds touch the artboard, in their layers; resources are kept.
|
||||
fn artboard_doc(doc: &Document, i: usize) -> Document {
|
||||
pub(super) fn artboard_doc(doc: &Document, i: usize) -> Document {
|
||||
let mut d = doc.clone();
|
||||
let Some(a) = doc.artboards.get(i) else { return d };
|
||||
let r = a.rect;
|
||||
@@ -387,6 +388,9 @@ const NOT_PDF_COMPATIBLE: &str = "saved without PDF content: VectorCraft opens i
|
||||
|
||||
/// The shared Save and Export encoder for PDF-compatible `.ai` files.
|
||||
pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Encoded> {
|
||||
if super::legacy_ai::wanted(cmd, p)? {
|
||||
return super::legacy_ai::encode(cmd, f, doc, p);
|
||||
}
|
||||
let mut params = if p.is_object() { p.clone() } else { json!({}) };
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.insert("preserveEditing".into(), json!(true));
|
||||
@@ -463,9 +467,14 @@ fn job_for_stamped(s: &Session, st: &DocState, plan: SavePlan, stamped: Option<i
|
||||
}
|
||||
let boards = separate_boards(cmd, plan.format, &doc, ¶ms)?;
|
||||
let mut params = super::pdf::expand_preset(s, cmd, ¶ms)?.into_owned();
|
||||
let legacy = super::legacy_ai::wanted(cmd, ¶ms)?;
|
||||
if let (Some(o), "ai") = (params.as_object_mut(), plan.format.id) {
|
||||
// Save keeps every artboard; exports may choose a subset instead.
|
||||
o.insert("range".into(), json!("all"));
|
||||
// Save keeps every artboard; exports may choose a subset instead. A legacy .ai file per
|
||||
// artboard keeps a range given: the artboards to save.
|
||||
let chosen = legacy && o.get("range").and_then(Value::as_str).is_some_and(|r| !r.trim().is_empty());
|
||||
if !chosen {
|
||||
o.insert("range".into(), json!("all"));
|
||||
}
|
||||
}
|
||||
Ok(SaveJob { plan, doc, params, snapshot, source: st.doc.clone(), stamped, boards })
|
||||
}
|
||||
@@ -507,7 +516,9 @@ impl SaveJob {
|
||||
fn encode_one(&self, doc: &Document) -> Result<Encoded> {
|
||||
let (cmd, f) = (self.plan.mode.command(), self.plan.format);
|
||||
let enc = if f.id == "ai" { encode_ai(cmd, f, doc, &self.params)? } else { encode_all(doc, f.id, &self.params)? };
|
||||
if enc.files.len() != 1 {
|
||||
// A legacy .ai file per artboard.
|
||||
let per_artboard = f.id == "ai" && super::legacy_ai::wanted(cmd, &self.params)?;
|
||||
if enc.files.len() != 1 && !per_artboard {
|
||||
return Err(bad(cmd, "Save writes one artboard: name one, or export several with document.export"));
|
||||
}
|
||||
Ok(enc)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,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());
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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("[");
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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)
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
+8
-1
@@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user