Save As .ai: carry editing data that other apps open as layers and paths

A PDF-compatible .ai now also carries, on its first page's PieceInfo, an
editing copy of the art in the form VectorCraft's own .ai reader reads:
AIPrivateData streams of 64 KiB holding the program compressed with
Zstandard after %AI24_ZStandard_Data, an AIMetaData stream of its header
comments, and the ContainerVersion, CreatorVersion, NumBlock,
RoundtripStreamType and RoundtripVersion entries.

The program holds the header comments, the color swatches and color
groups, every artboard, layers and sublayers (name, order, visibility,
lock, color, printing, dimming), groups, paths, fills and strokes (width,
caps, joins, miter limit, dashes, overprint, fill rule), process, spot and
global colors, guides, hidden and locked objects and object names. Type,
gradients, patterns, images, symbols, live objects, brushes, clipping
groups and compound paths are written as plain filled and stroked paths,
each with a warning; transparency and effects other than geometry effects
are left out.

The .ai option editingData (on by default, Include Editing Data for Other
Apps in Save As) turns it off. The legacy version 7 writer is unchanged;
the path, guide and line style operators both writers write move to
eps/src/ai_ops.rs. LoadOptions::native off opens a file from its editing
copy or pages instead of the VectorCraft document it carries.
This commit is contained in:
dotCooCoo
2026-10-28 16:55:41 -07:00
parent 56804f6d7e
commit f33617142c
44 changed files with 1984 additions and 94 deletions
Generated
+1
View File
@@ -6240,6 +6240,7 @@ dependencies = [
"vectorcraft-effects",
"vectorcraft-geom",
"vectorcraft-metafile",
"vectorcraft-pathops",
"vectorcraft-pdf",
"vectorcraft-render",
"vectorcraft-testkit",
+1 -3
View File
@@ -263,9 +263,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
}
"pdf" | "ai" | "ait" => {
// Saved with Preserve Editing: the document it carries, unless some pages are picked.
let editing = opts
.pages
.is_none()
let editing = (opts.pages.is_none() && opts.native)
.then(|| vectorcraft_pdf::editing_with(bytes, opts.password.as_deref()))
.flatten()
.map(|e| (e.intact, move || Some(e.data)));
+6
View File
@@ -99,6 +99,12 @@ 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: "editingData",
ty: "boolean",
default: "true",
description: "also carry an editing copy of the artwork in the form other apps that open .ai files read: layers and sublayers, groups, paths, fills and strokes, colors, swatches and artboards, with type, gradients, patterns, images, symbols, clipping and live objects as plain filled and stroked paths, without transparency (written only with every artboard, as Preserve Editing is)",
},
FormatOption {
name: "aiVersion",
ty: "string",
+14 -3
View File
@@ -263,7 +263,7 @@ pub const EDITING_NEEDS_EVERY_ARTBOARD: &str =
pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec<u8>, Vec<String>)> {
// Keep the editable object tree independent of preview fallbacks used by the drawn pages.
let (full, mut warnings) = crate::cmd::place::document::full_documents(doc);
let (bytes, drawn_warnings) = encode_carrying(cmd, &full, p, || Ok(vectorcraft_format::save(doc, false)))?;
let (bytes, drawn_warnings) = encode_carrying(cmd, &full, p, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?;
warnings.extend(drawn_warnings);
warnings.extend(overflow_warning(cmd, &full, p)?);
Ok((bytes, warnings))
@@ -281,8 +281,15 @@ fn overflow_warning(cmd: &str, doc: &Document, p: &Value) -> Result<Option<Strin
}
/// [`encode`] the pages of `doc`, carrying `native()` as the editing data (a `.ai` file: the
/// native document with its save options, whose pages may be left blank).
pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl FnOnce() -> Result<Vec<u8>>) -> Result<(Vec<u8>, Vec<String>)> {
/// native document with its save options, whose pages may be left blank), and `private()`, the
/// editing copy of the art for other apps with what it couldn't hold, on the first page.
pub(super) fn encode_carrying(
cmd: &str,
doc: &Document,
p: &Value,
native: impl FnOnce() -> Result<Vec<u8>>,
private: impl FnOnce() -> Result<Option<(vectorcraft_pdf::PrivateData, Vec<String>)>>,
) -> Result<(Vec<u8>, Vec<String>)> {
let mut opts = options(cmd, doc, p)?;
if opts.settings.thumbnails {
opts.thumbnails = thumbnails(doc, &opts).map_err(|e| pdf_error(cmd, e))?;
@@ -291,6 +298,10 @@ pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl
if opts.settings.preserve_editing {
if opts.artboards.as_ref().is_none_or(|v| v.iter().copied().eq(0..doc.artboards.len())) {
opts.native = Some(native()?);
if let Some((data, lost)) = private()? {
opts.private_data = Some(data);
warnings.extend(lost);
}
} else {
opts.settings.preserve_editing = false;
warnings.push(EDITING_NEEDS_EVERY_ARTBOARD.to_string());
+5 -1
View File
@@ -39,6 +39,9 @@ pub struct LoadOptions {
/// A `.ai` opens from its editing copy even where that draws differently from its PDF part
/// (which can be older than it), with a warning, instead of from the PDF part.
pub trust_editing_data: bool,
/// A PDF or `.ai` saved with Preserve Editing opens from the VectorCraft document it carries;
/// off, it opens as another app would open it, from the editing copy of its art or its pages.
pub native: bool,
}
impl Default for LoadOptions {
@@ -54,6 +57,7 @@ impl Default for LoadOptions {
dxf: Default::default(),
editing_data: true,
trust_editing_data: false,
native: true,
}
}
}
@@ -91,7 +95,7 @@ impl LoadOptions {
Some(v) => (v.as_bool().ok_or_else(|| bad(cmd, "editingData must be true, false or \"always\""))?, false),
};
let dxf = super::dxfimport::options(cmd, p.get("dxf"))?;
Ok(Self { pages, crop, password, color_mode, grays, text_as, layers, dxf, editing_data, trust_editing_data })
Ok(Self { pages, crop, password, color_mode, grays, text_as, layers, dxf, editing_data, trust_editing_data, native: true })
}
/// Does the document read only part of the file, or read it differently (a page range, another
+34 -4
View File
@@ -584,6 +584,14 @@ fn named_for_save(cmd: &str, e: crate::EngineError) -> crate::EngineError {
}
}
/// The `.ai` option `editingData`: whether the file also carries the editing copy of the art that
/// other apps read (default on).
#[derive(Default, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct AiEditingData {
editing_data: Option<bool>,
}
/// The shared Save and Export encoder for PDF-compatible `.ai` files (and `.ait` templates, the
/// same file).
pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Encoded> {
@@ -613,13 +621,35 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res
return Err(bad(cmd, "pdfCompatible: false needs every artboard to keep the editable artwork"));
}
}
let pages = doc.without_edit_modes();
let edited = doc.without_edit_modes();
// Resolve placed files only for the drawn pages. Missing files become preview images there;
// the native attachment keeps the original placed objects and their relinkable metadata.
let (pages, placed_warnings) =
if pdf_compatible { crate::cmd::place::document::full_documents(&pages) } else { (std::borrow::Cow::Owned(blank_pages(&pages)), Vec::new()) };
let (pages, placed_warnings) = if pdf_compatible {
crate::cmd::place::document::full_documents(&edited)
} else {
(std::borrow::Cow::Owned(blank_pages(&edited)), Vec::new())
};
let native = || super::native::ai_native(cmd, doc, &native_options);
let (bytes, mut warnings) = super::pdf::encode_carrying(cmd, &pages, &params, native)?;
let other_apps: AiEditingData = super::encode::options(f, &params)?;
let private = || {
if !other_apps.editing_data.unwrap_or(true) {
return Ok(None);
}
// The art with placed files resolved as the pages draw them, also when the pages are blank.
let resolved;
let art: &Document = if pdf_compatible {
&pages
} else {
resolved = crate::cmd::place::document::full_documents(&edited).0;
&resolved
};
let o = vectorcraft_eps::AiEditingOptions {
title: doc.title.clone(),
created: doc.metadata.created.or_else(vectorcraft_doc::metadata::output_time),
};
vectorcraft_eps::ai_private_data(art, &o).map(Some).map_err(|e| bad(cmd, e))
};
let (bytes, mut warnings) = super::pdf::encode_carrying(cmd, &pages, &params, native, private)?;
warnings.extend(placed_warnings);
if !pdf_compatible {
warnings.push(NOT_PDF_COMPATIBLE.to_string());
@@ -403,3 +403,51 @@ fn ai_export_without_artboards_keeps_art_outside_the_original_pages() {
assert_eq!(comparable(&s.doc().unwrap().doc), before);
assert!(s.execute("document.export", &json!({"format": "ai", "useArtboards": "yes"})).is_err());
}
/// The layers of `doc` as their names and the kinds and bounds of what they hold, rounded.
fn outline(doc: &vectorcraft_doc::Document) -> Vec<String> {
fn walk(nodes: &[std::sync::Arc<vectorcraft_doc::Node>], depth: usize, out: &mut Vec<String>) {
for n in nodes {
let b = n.visual_bounds().map(|b| [b.x0, b.y0, b.x1, b.y1].map(|v| v.round() as i64));
let kind = match &n.kind {
vectorcraft_doc::NodeKind::Layer { .. } => "layer",
vectorcraft_doc::NodeKind::Group { .. } => "group",
vectorcraft_doc::NodeKind::Path { .. } => "path",
_ => "other",
};
out.push(format!("{depth} {kind} {:?} {} {b:?}", n.name, n.visible));
walk(n.children().map(Vec::as_slice).unwrap_or_default(), depth + 1, out);
}
}
let mut out = vec![];
walk(&doc.layers, 0, &mut out);
out
}
/// A `.ai` also carries the editing copy of its art that other apps read: opened without the
/// VectorCraft document it carries, it gives back the same layers, groups and paths, its page
/// matching them. `editingData: false` leaves the copy out.
#[test]
fn ai_files_carry_an_editing_copy_for_other_apps() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
s.execute("shape.ellipse", &json!({"x": 60, "y": 10, "width": 50, "height": 30})).unwrap();
s.execute("select.all", &json!({})).unwrap();
s.execute("object.group", &json!({})).unwrap();
s.execute("layer.new", &json!({"name": "Top"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 120, "y": 50, "width": 60, "height": 40})).unwrap();
let before = outline(&s.doc().unwrap().doc);
let bytes = b64(&s.execute("document.export", &json!({"format": "ai"})).unwrap());
let private = vectorcraft_pdf::illustrator_data(&bytes, None).expect("the editing copy for other apps");
assert!(private.starts_with(b"%AI24_ZStandard_Data"));
let opts = LoadOptions { native: false, ..Default::default() };
let l = load::load_with("art.ai", &bytes, &opts).unwrap();
assert!(l.editing_data && !l.restored);
assert!(!l.warnings.iter().any(|w| w.contains("editing data")), "{:?}", l.warnings);
assert_eq!(outline(&l.doc), before);
let r = s.execute("document.export", &json!({"format": "ai", "editingData": false})).unwrap();
assert_eq!(vectorcraft_pdf::illustrator_data(&b64(&r), None), None);
assert!(s.execute("document.export", &json!({"format": "ai", "editingData": "no"})).is_err());
}
+2
View File
@@ -13,6 +13,8 @@ vectorcraft-geom = { workspace = true }
vectorcraft-color = { workspace = true }
vectorcraft-doc = { workspace = true }
vectorcraft-effects = { workspace = true }
# The editing data for other apps writes clipped art cut to its clipping path.
vectorcraft-pathops = { workspace = true }
vectorcraft-brush = { workspace = true }
vectorcraft-text = { workspace = true }
# Windows metafile previews, opened when a file's PostScript can't be read.
+238
View File
@@ -0,0 +1,238 @@
//! The editing copy of the art that a PDF-compatible `.ai` file carries in its first page's
//! `PieceInfo` ([`vectorcraft_pdf::PrivateData`]): the document's layers, sublayers, groups, paths,
//! colors, swatches and artboards in the form [`crate::import`]'s `.ai` reader reads, so that apps
//! that open `.ai` files from that copy get them as editable objects.
//!
//! The form is the one the published specification documents (Adobe Illustrator File Format
//! Specification, version 7.0, see [`crate::legacy_ai`]) for paths (§5.3), colors (§5.4),
//! groups and compound paths (§5.3.5), guides (§6) and layers (§9), with the newer forms the reader
//! reads from files users sent:
//!
//! - Header comments: `%%BoundingBox`, `%%HiResBoundingBox`, `%AI5_FileFormat 14.0`, the first
//! artboard as `%AI3_Cropmarks` and `%AI5_ArtSize`, `%AI5_RulerUnits`, `%AI9_ColorModel` (1 RGB, 2
//! CMYK), `%AI5_NumLayers`, and the version comment `%%AI8_CreatorVersion` ([`CREATOR_VERSION`]).
//! - Setup: the color swatches (`%AI5_BeginPalette`, `0 0 Pb`, each swatch's color operator and
//! `(name) Pc`, a color group as `0 (name) count Pg` before its swatches, `PB`), then the
//! artboards as the `ArtboardArray` of the `/Document` dictionary, on `%_` lines.
//! - Layers: `%AI5_BeginLayer`, `visible preview unlocked printing dimmed 0 1 colorIndex r g b 0
//! dimPercent 0 Lb` (14 operands; a color that isn't one of the preset layer colors has the
//! index -1), `(name) Ln`, its art and sublayers, `LB`, `%AI5_EndLayer--`.
//! - Colors: `g` gray, `k` CMYK, `c m y k r g b Xa` RGB (Lab is written as RGB); a spot color
//! `c m y k (name) tint x` (CMYK) or `c m y k r g b|L a b (name) tint 1|2 Xx` (RGB or Lab); a
//! global process color `… (name) tint model Xk`; Registration `1 1 1 1 ([Registration]) tint
//! Xs`. Upper case paints the stroke.
//! - State before each object: locked `A`, hidden `Xw` (`1` or `0`). An object's name follows it as
//! an `/ArtDictionary` whose `AI10_ArtUID` is the name as an XML id. Guides are `(N) *`, the
//! path, `(N) *` (`(n) *` when closed).
//!
//! Strings are written in UTF-8. Art space has y up, its origin at the first artboard's bottom-left
//! corner. What the form above can't hold is written as plain filled and stroked paths, each such
//! loss a warning: type as its outlines, gradients as their average color, pattern fills as their
//! tiles cut to the shape, clipping groups and clipping layers as their art cut to the clipping
//! path (the clipping path's fill cut too, its stroke not), images as their outlines filled with
//! their average color, symbols and live objects as their art, strokes a plain line can't draw as
//! their filled outlines, several fills or strokes as a group of a path for each, gradient and
//! pattern swatches, transparency and live effects other than geometry effects left out.
//!
//! The data streams hold the program compressed with Zstandard after `%AI24_ZStandard_Data`, with
//! the version numbers of the newest files users sent ([`private_data`]).
use std::fmt::Write;
use std::sync::Arc;
use vectorcraft_doc::{Document, Node, Unit};
use vectorcraft_geom::{Affine, Rect};
use vectorcraft_pdf::PrivateData;
use crate::ps;
mod writer;
use writer::Writer;
/// The version comment's value: the creator version of the newest files users sent.
pub const CREATOR_VERSION: &str = "30.7.0";
/// The `PieceInfo` numbers that go with Zstandard-compressed data: container 12, creator 30,
/// round trip 24, stream type 2.
const CONTAINER_VERSION: u32 = 12;
const CREATOR: u32 = 30;
const ROUNDTRIP_VERSION: u32 = 24;
const ROUNDTRIP_STREAM_TYPE: u32 = 2;
/// What starts the data streams of Zstandard-compressed data.
const ZSTD_MARKER: &[u8] = b"%AI24_ZStandard_Data";
/// The first line of a PostScript file (the DSC version comment).
const PS_HEADER: &str = "%!PS-Adobe-3.0"; // brand-ok: the PostScript header comment
/// What to write in the header.
#[derive(Clone, Debug, Default)]
pub struct AiEditingOptions {
/// `%%Title`.
pub title: String,
/// `%%CreationDate`, Unix seconds.
pub created: Option<i64>,
}
/// The editing data and what it couldn't hold.
#[derive(Clone, Debug, Default)]
pub struct AiEditingOutput {
/// The program: header comments, setup, layers, trailer.
pub program: Vec<u8>,
/// Its header comments, up to `%%EndComments`.
pub header: Vec<u8>,
pub warnings: Vec<String>,
}
/// Write `doc` as the editing data of a `.ai` file: every artboard and layer.
pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result<AiEditingOutput, String> {
let board = doc.artboards.first().ok_or("the document has no artboard")?.rect;
if !(board.is_finite() && board.width() > 0.0 && board.height() > 0.0) {
return Err("the document has no artboard".into());
}
// Live geometry effects as their result, as the PDF part draws them.
let baked = baked(doc);
let doc = baked.as_ref().unwrap_or(doc);
// The document is y down; art space y up from the first artboard's bottom-left corner.
let to_art = Affine::new([1.0, 0.0, 0.0, -1.0, -board.x0, board.y1]);
let mut w = Writer::new(doc, to_art);
for layer in &doc.layers {
w.layer(layer);
}
let layers = doc.layers.len();
let (width, height) = (board.width(), board.height());
let art = doc
.layers
.iter()
.filter(|l| l.visible)
.filter_map(|l| l.visual_bounds())
.map(|b| to_art.transform_rect_bbox(b))
.filter(|b| b.is_finite())
.reduce(|a, b| a.union(b))
.unwrap_or(Rect::new(0.0, 0.0, width, height));
let header = header(doc, o, &w, art, (width, height), layers);
let mut s = header.clone();
s.push_str("%%BeginProlog\n%%EndProlog\n%%BeginSetup\n");
w.palette(&mut s);
artboards(&mut s, doc, to_art);
s.push_str("%%EndSetup\n");
s.push_str(&w.body);
s.push_str("%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n");
Ok(AiEditingOutput { program: s.into_bytes(), header: header.into_bytes(), warnings: w.warnings })
}
/// `doc` with its geometry effects baked ([`vectorcraft_effects::bake_document`]), in its symbols'
/// art too (which instances are written as); `None` when nothing changes.
fn baked(doc: &Document) -> Option<Document> {
let baked = vectorcraft_effects::bake_document(doc);
let d = baked.as_ref().unwrap_or(doc);
if !d.symbols.iter().any(|s| vectorcraft_effects::needs_bake(&s.art)) {
return baked;
}
// The symbols' art baked as the layers of a copy of the document.
let mut art = d.clone();
art.layers = d.symbols.iter().map(|s| Arc::new(Node::group(s.art.id, vec![s.art.clone()]))).collect();
// The copy keeps what baking adds to the document (the images color adjustments make).
let Some(mut out) = vectorcraft_effects::bake_document(&art) else { return baked };
let arts: Vec<Option<Arc<Node>>> = out.layers.iter().map(|l| l.children().and_then(|c| c.first()).cloned()).collect();
out.layers = d.layers.clone();
for (s, a) in out.symbols.iter_mut().zip(arts) {
if let Some(a) = a {
s.art = a;
}
}
Some(out)
}
/// The editing data of `doc` as a `.ai` file's first page carries it: the program compressed with
/// Zstandard after `%AI24_ZStandard_Data`, its header as the `AIMetaData`, and the version numbers
/// that go with that form; and what it couldn't hold.
pub fn private_data(doc: &Document, o: &AiEditingOptions) -> Result<(PrivateData, Vec<String>), String> {
let out = export_ai_editing(doc, o)?;
let mut data = ZSTD_MARKER.to_vec();
data.extend(ruzstd::encoding::compress_to_vec(out.program.as_slice(), ruzstd::encoding::CompressionLevel::Fastest));
let p = PrivateData {
data,
metadata: out.header,
container_version: CONTAINER_VERSION,
creator_version: CREATOR,
roundtrip_version: ROUNDTRIP_VERSION,
roundtrip_stream_type: ROUNDTRIP_STREAM_TYPE,
};
Ok((p, out.warnings))
}
/// The header comments, through `%%EndComments`. `art`: the visible art's bounds, `size` the first
/// artboard's, in art space.
fn header(doc: &Document, o: &AiEditingOptions, w: &Writer, art: Rect, (width, height): (f64, f64), layers: usize) -> String {
let mut s = String::new();
s.push_str(PS_HEADER);
s.push('\n');
let _ = writeln!(s, "%%Creator: VectorCraft {}", env!("CARGO_PKG_VERSION"));
let _ = writeln!(s, "%%AI8_CreatorVersion: {CREATOR_VERSION}");
let _ = writeln!(s, "%%Title: {}", ps::bytes(o.title.as_bytes()));
if let Some(t) = o.created {
let [y, mo, d, h, mi, _] = vectorcraft_doc::metadata::civil(t);
let _ = writeln!(s, "%%CreationDate: ({mo}/{d}/{y:04}) ({h:02}:{mi:02})");
}
let _ = writeln!(s, "%%BoundingBox: {} {} {} {}", art.x0.floor() as i64, art.y0.floor() as i64, art.x1.ceil() as i64, art.y1.ceil() as i64);
s.push_str("%%HiResBoundingBox: ");
ps::push_nums(&mut s, &[art.x0, art.y0, art.x1, art.y1]);
s.pop();
s.push('\n');
s.push_str("%%DocumentProcessColors: Cyan Magenta Yellow Black\n");
s.push_str("%AI5_FileFormat 14.0\n%AI3_ColorUsage: Color\n");
let spots = w.spot_swatches();
for (i, (name, _)) in spots.iter().enumerate() {
let _ = writeln!(s, "{} {}", if i == 0 { "%%DocumentCustomColors:" } else { "%%+" }, ps::bytes(name.as_bytes()));
}
for (i, (name, cmyk)) in spots.iter().enumerate() {
s.push_str(if i == 0 { "%%CMYKCustomColor: " } else { "%%+ " });
ps::push_nums(&mut s, &cmyk.map(f64::from));
let _ = writeln!(s, "{}", ps::bytes(name.as_bytes()));
}
let (w2, h2) = (ps::num(width), ps::num(height));
let _ = writeln!(s, "%AI3_Cropmarks: 0 0 {w2} {h2}");
let (cx, cy) = (ps::num(width / 2.0), ps::num(height / 2.0));
let _ = writeln!(s, "%AI3_TemplateBox: {cx} {cy} {cx} {cy}");
let _ = writeln!(s, "%AI3_TileBox: 0 0 {w2} {h2}");
s.push_str("%AI3_DocumentPreview: None\n");
let _ = writeln!(s, "%AI5_ArtSize: {w2} {h2}");
let _ = writeln!(s, "%AI5_RulerUnits: {}", ruler_units(doc.units));
s.push_str("%AI24_LargeCanvasScale: 1\n");
let model = if doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { 2 } else { 1 };
let _ = writeln!(s, "%AI9_ColorModel: {model}");
let _ = writeln!(s, "%AI5_NumLayers: {layers}");
s.push_str("%%EndComments\n");
s
}
/// `%AI5_RulerUnits` of `u` (as the reader reads it; units it has no number for are points).
fn ruler_units(u: Unit) -> u8 {
match u {
Unit::Inches => 0,
Unit::Millimeters => 1,
Unit::Picas => 3,
Unit::Centimeters => 4,
Unit::Pixels => 6,
Unit::Points | Unit::FeetInches | Unit::Meters | Unit::Yards | Unit::Feet => 2,
}
}
/// The artboards as the `ArtboardArray` of the `/Document` dictionary: each one's top-left and
/// bottom-right corners in art space (`PositionPoint1`, `PositionPoint2`) and its name.
fn artboards(s: &mut String, doc: &Document, to_art: Affine) {
s.push_str("%AI9_BeginDocumentData\n%_/Document :\n%_/Dictionary :\n%_/Array :\n");
for ab in &doc.artboards {
let r = to_art.transform_rect_bbox(ab.rect);
s.push_str("%_/Dictionary :\n%_");
ps::push_nums(s, &[r.x0, r.y1]);
s.push_str("/RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_");
ps::push_nums(s, &[r.x1, r.y0]);
s.push_str("/RealPointRelToROrigin\n%_ (PositionPoint2) ,\n");
let _ = writeln!(s, "%_{} /UnicodeString (Name) ,\n%_; ,", ps::bytes(ab.name.as_bytes()));
}
s.push_str("%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n%AI9_EndDocumentData\n");
}
#[cfg(test)]
mod tests;
+645
View File
@@ -0,0 +1,645 @@
//! The editing data written, then read back by VectorCraft's own reader ([`crate::ai_alone`]).
use std::sync::Arc;
use vectorcraft_color::swatch::{Swatch, SwatchGroup};
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::{
Appearance, AppearanceItem, Artboard, Dash, Document, FillLayer, ImageBlob, ImageObject, LayerColor, LineCap, LineJoin, Node, NodeKind,
StrokeLayer, Symbol,
};
use vectorcraft_geom::{Affine, FillRule, PathData, Rect, shapes};
use super::*;
fn opts() -> AiEditingOptions {
AiEditingOptions { title: "Sample".into(), created: Some(0) }
}
/// The program written from `doc`, as text.
fn program(doc: &Document) -> String {
String::from_utf8(export_ai_editing(doc, &opts()).unwrap().program).unwrap()
}
/// `doc` written, then read back from the private data alone.
fn read_back(doc: &Document) -> Document {
let (p, _) = private_data(doc, &opts()).unwrap();
crate::ai_alone(&p.data).unwrap().0
}
fn solid(c: Color) -> Paint {
Paint::solid(c)
}
/// A swatch's color at `tint`, linked to it.
fn linked(name: &str, full: Color, tint: f32) -> Paint {
Paint::Solid { color: full.tinted(tint), swatch: Some(name.into()), tint }
}
const BRAND: Color = Color::Cmyk { c: 0.1, m: 0.7, y: 0.2, k: 0.05 };
const GOLD: Color = Color::Cmyk { c: 0.0, m: 0.25, y: 0.9, k: 0.1 };
const SKY: Color = Color::Rgb { r: 0.25, g: 0.5, b: 1.0 };
const MINT: Color = Color::Lab { l: 80.0, a: -40.0, b: 10.0 };
/// A 200 × 100 document with a second artboard: swatches (process, global process, CMYK, RGB and
/// Lab spot colors, a color group); a layer `Back` (preset color 2) holding a named group `Pair`
/// of a named rectangle `Red` (stroked 2 pt, round caps, bevel joins, miter limit 4, dashed, over
/// a gray fill that overprints) and a hidden, locked one, an even-odd ellipse in a global color at
/// half tint, an open path stroked in a spot color, a guide, and a hidden locked sublayer `Sub`
/// (a custom color, not printing, dimmed) holding a path filled with an RGB and stroked with a Lab
/// spot color; a second layer `Front`, locked, with a CMYK square named like the rectangle.
fn sample() -> Document {
let mut doc = Document::new(200.0, 100.0);
let mut second = doc.artboards[0].clone();
second.id = 2;
second.name = "Back side".into();
second.rect = Rect::new(250.0, 0.0, 450.0, 100.0);
doc.artboards.push(second);
let swatch = |name: &str, c: Color, global: bool, spot: bool| Swatch { name: name.into(), paint: solid(c), global, spot };
doc.swatches = vec![
Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false },
swatch("Teal", Color::rgb(0.0, 0.5, 0.5), false, false),
swatch("Brand", BRAND, true, false),
swatch("Gold", GOLD, true, true),
swatch("Sky", SKY, true, true),
swatch("Mint", MINT, true, true),
]
.into();
doc.swatch_groups = vec![SwatchGroup { name: "Grays".into(), swatches: vec![swatch("Mid", Color::gray(0.5), false, false)] }].into();
doc.layers.clear();
let back = doc.add_layer(Some("Back"));
let front = doc.add_layer(Some("Front"));
let mut stroke = StrokeLayer::new(solid(Color::rgb(0.0, 0.0, 1.0)), 2.0);
stroke.cap = LineCap::Round;
stroke.join = LineJoin::Bevel;
stroke.miter_limit = 4.0;
stroke.dash = Some(Dash { pattern: vec![3.0, 2.0], offset: 1.0, align_corners: false });
let fill = FillLayer { overprint: true, ..FillLayer::new(solid(Color::gray(0.25))) };
let mut red = Node::path(
doc.alloc_id(),
shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)),
Appearance { items: vec![AppearanceItem::Fill(fill), AppearanceItem::Stroke(stroke)], ..Appearance::default() },
);
red.name = Some("Red".into());
let mut hidden = Node::path(
doc.alloc_id(),
shapes::rectangle(Rect::new(50.0, 10.0, 80.0, 40.0)),
Appearance::basic(solid(Color::cmyk(0.0, 1.0, 1.0, 0.0)), Paint::None, 0.0),
);
hidden.visible = false;
hidden.locked = true;
let mut pair = Node::group(doc.alloc_id(), vec![Arc::new(red), Arc::new(hidden)]);
pair.name = Some("Pair".into());
doc.insert(Some(back), usize::MAX, pair).unwrap();
let ellipse = Node::new(
doc.alloc_id(),
NodeKind::Path {
path: shapes::ellipse(Rect::new(100.0, 10.0, 160.0, 50.0)),
rule: FillRule::EvenOdd,
live: None,
clipping: false,
guide: false,
},
);
let ellipse = Node { appearance: Appearance::basic(linked("Brand", BRAND, 0.5), Paint::None, 0.0).into(), ..ellipse };
doc.insert(Some(back), usize::MAX, ellipse).unwrap();
let mut open = PathData::from_bezpath(&kurbo::BezPath::from_vec(vec![
kurbo::PathEl::MoveTo((10.0, 60.0).into()),
kurbo::PathEl::LineTo((60.0, 90.0).into()),
]));
open.subpaths.iter_mut().for_each(|sp| sp.closed = false);
let line = Node::path(doc.alloc_id(), open, Appearance::basic(Paint::None, linked("Gold", GOLD, 1.0), 3.0));
doc.insert(Some(back), usize::MAX, line).unwrap();
let guide = Node::new(
doc.alloc_id(),
NodeKind::Path {
path: shapes::rectangle(Rect::new(0.0, 50.0, 200.0, 50.0)),
rule: FillRule::NonZero,
live: None,
clipping: false,
guide: true,
},
);
doc.insert(Some(back), usize::MAX, guide).unwrap();
let mut sub = Node::layer(doc.alloc_id(), "Sub", LayerColor::Custom([10, 200, 30]));
if let NodeKind::Layer { printable, dim_images, .. } = &mut sub.kind {
*printable = false;
*dim_images = Some(70);
}
sub.visible = false;
sub.locked = true;
let sub = doc.insert(Some(back), usize::MAX, sub).unwrap();
let skyfill = Node::path(
doc.alloc_id(),
shapes::rectangle(Rect::new(120.0, 60.0, 190.0, 95.0)),
Appearance::basic(linked("Sky", SKY, 0.8), linked("Mint", MINT, 1.0), 1.0),
);
doc.insert(Some(sub), usize::MAX, skyfill).unwrap();
let mut square = Node::path(
doc.alloc_id(),
shapes::rectangle(Rect::new(300.0, 20.0, 340.0, 60.0)),
Appearance::basic(solid(Color::cmyk(0.5, 0.0, 1.0, 0.0)), Paint::None, 0.0),
);
square.name = Some("Red".into());
doc.insert(Some(front), usize::MAX, square).unwrap();
if let Some(l) = doc.layers.get_mut(1) {
let l = Arc::make_mut(l);
l.locked = true;
if let NodeKind::Layer { color, .. } = &mut l.kind {
*color = LayerColor::Preset(2);
}
}
doc
}
/// `v` to three decimals.
fn r3(v: f64) -> String {
format!("{:.3}", (v * 1000.0).round() / 1000.0 + 0.0)
}
fn color(c: &Color) -> String {
let f = |v: f32| r3(f64::from(v));
match *c {
Color::Gray { k } => format!("gray {}", f(k)),
Color::Cmyk { c, m, y, k } => format!("cmyk {} {} {} {}", f(c), f(m), f(y), f(k)),
Color::Rgb { r, g, b } => format!("rgb {} {} {}", f(r), f(g), f(b)),
Color::Lab { l, a, b } => format!("lab {} {} {}", f(l), f(a), f(b)),
}
}
fn paint(p: &Paint) -> String {
match p {
Paint::Solid { color: c, swatch, tint } => format!("{} {swatch:?} {}", color(c), r3(f64::from(*tint))),
Paint::None => "none".into(),
other => format!("{other:?}"),
}
}
/// What the reader's view of the slice's constructs is made of, one line per object.
fn view(nodes: &[Arc<Node>], depth: usize, out: &mut Vec<String>) {
for n in nodes {
let head = format!("{depth} {:?} shown={} locked={}", n.name, n.visible, n.locked);
match &n.kind {
NodeKind::Layer { color, printable, preview, dim_images, clip, children, .. } => {
out.push(format!("{head} layer {color:?} printable={printable} preview={preview} dim={dim_images:?} clip={clip}"));
view(children, depth + 1, out);
}
NodeKind::Group { children, clip } => {
out.push(format!("{head} group clip={clip}"));
view(children, depth + 1, out);
}
NodeKind::Path { path, rule, guide, .. } => {
let mut s = format!("{head} path {rule:?} guide={guide}");
for sp in &path.subpaths {
s += &format!(" [closed={}", sp.closed);
// Points are written to four decimals.
let r2 = |v: f64| format!("{:.2}", v + 0.0);
for a in &sp.anchors {
s += &format!(" {},{} {},{} {},{}", r2(a.h_in.x), r2(a.h_in.y), r2(a.p.x), r2(a.p.y), r2(a.h_out.x), r2(a.h_out.y));
}
s += "]";
}
if !guide {
// Items that paint nothing aren't written.
for it in n.appearance.items.iter().filter(|i| !i.paint().is_none()) {
s += &match it {
AppearanceItem::Fill(f) => format!(" fill({} op={})", paint(&f.paint), f.overprint),
AppearanceItem::Stroke(st) => format!(
" stroke({} w={} {:?} {:?} m={} {:?} op={})",
paint(&st.paint),
r3(st.width),
st.cap,
st.join,
r3(st.miter_limit),
st.dash.as_ref().map(|d| (d.pattern.clone(), d.offset)),
st.overprint
),
};
}
}
out.push(s);
}
other => out.push(format!("{head} {other:?}")),
}
}
}
fn lines(doc: &Document) -> Vec<String> {
let mut out = vec![];
view(&doc.layers, 0, &mut out);
out
}
fn swatches(doc: &Document) -> Vec<String> {
let one = |s: &Swatch| format!("{} global={} spot={} {}", s.name, s.global, s.spot, paint(&s.paint));
let mut out: Vec<String> = doc.swatches.iter().filter(|s| !matches!(s.paint, Paint::None)).map(one).collect();
for g in doc.swatch_groups.iter() {
out.push(format!("group {}", g.name));
out.extend(g.swatches.iter().map(one));
}
out
}
#[test]
fn the_reader_reads_back_every_construct_of_the_slice() {
let doc = sample();
let back = read_back(&doc);
assert_eq!(lines(&back), lines(&doc));
assert_eq!(swatches(&back), swatches(&doc));
let boards: Vec<(String, Rect)> = back.artboards.iter().map(|a| (a.name.clone(), a.rect)).collect();
assert_eq!(
boards,
vec![("Artboard 1".to_string(), Rect::new(0.0, 0.0, 200.0, 100.0)), ("Back side".to_string(), Rect::new(250.0, 0.0, 450.0, 100.0))]
);
assert_eq!(back.color_mode, doc.color_mode);
assert_eq!(back.units, doc.units);
}
#[test]
fn writing_again_gives_the_same_bytes() {
let doc = sample();
let first = export_ai_editing(&doc, &opts()).unwrap();
assert_eq!(first.program, export_ai_editing(&doc, &opts()).unwrap().program);
assert_eq!(private_data(&doc, &opts()).unwrap().0, private_data(&doc, &opts()).unwrap().0);
// What the reader reads back is written as the same program.
assert_eq!(program(&read_back(&doc)), String::from_utf8(first.program).unwrap());
assert!(first.warnings.is_empty(), "{:?}", first.warnings);
}
#[test]
fn the_data_is_zstandard_compressed_with_the_header_as_metadata() {
let doc = sample();
let out = export_ai_editing(&doc, &opts()).unwrap();
let (p, _) = private_data(&doc, &opts()).unwrap();
assert!(p.data.starts_with(ZSTD_MARKER));
let mut body = p.data.get(ZSTD_MARKER.len()..).unwrap();
let mut decoded = vec![];
std::io::Read::read_to_end(&mut ruzstd::decoding::StreamingDecoder::new(&mut body).unwrap(), &mut decoded).unwrap();
assert_eq!(decoded, out.program);
assert!(out.program.starts_with(&out.header));
assert_eq!(p.metadata, out.header);
let header = String::from_utf8(out.header).unwrap();
assert!(header.ends_with("%%EndComments\n"));
for line in [
"%%AI8_CreatorVersion: 30.7.0\n",
"%AI5_FileFormat 14.0\n",
"%AI3_Cropmarks: 0 0 200 100\n",
"%AI5_ArtSize: 200 100\n",
"%AI9_ColorModel: 1\n",
"%AI5_NumLayers: 2\n",
"%%DocumentCustomColors: (Gold)\n%%+ (Sky)\n%%+ (Mint)\n",
] {
assert!(header.contains(line), "{line}");
}
assert_eq!((p.container_version, p.creator_version, p.roundtrip_version, p.roundtrip_stream_type), (12, 30, 24, 2));
}
#[test]
fn a_pdf_carries_the_data_on_its_first_page() {
let doc = sample();
let (p, _) = private_data(&doc, &opts()).unwrap();
let pdf =
vectorcraft_pdf::export(&doc, &vectorcraft_pdf::PdfOptions { private_data: Some(p.clone()), ..vectorcraft_pdf::PdfOptions::uncompressed() })
.unwrap();
assert_eq!(vectorcraft_pdf::illustrator_data(&pdf, None).as_deref(), Some(&p.data[..]));
let (read, _) = crate::ai_alone(&vectorcraft_pdf::illustrator_data(&pdf, None).unwrap()).unwrap();
assert_eq!(lines(&read), lines(&doc));
}
#[test]
fn names_are_xml_ids_numbered_when_taken() {
let text = program(&sample());
assert!(text.contains("%_/XMLUID : (Red) ; (AI10_ArtUID) ,"));
assert!(text.contains("%_/XMLUID : (Red_00000000000000000001_) ; (AI10_ArtUID) ,"));
assert!(text.contains("%_/XMLUID : (Pair) ; (AI10_ArtUID) ,"));
}
#[test]
fn many_objects_of_one_name_are_numbered_in_turn() {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
for _ in 0..20_000 {
let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default());
n.name = Some("Dot".into());
doc.insert(Some(layer), usize::MAX, n).unwrap();
}
let text = program(&doc);
let ids: std::collections::HashSet<&str> = text.lines().filter(|l| l.starts_with("%_/XMLUID")).collect();
assert_eq!(ids.len(), 20_000);
assert!(text.contains("(Dot_00000000000000019999_)"));
}
#[test]
fn layers_have_fourteen_operands() {
let text = program(&sample());
assert!(text.contains("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Back) Ln\n"), "{text}");
assert!(text.contains("%AI5_BeginLayer\n0 1 0 0 1 0 1 -1 10 200 30 0 70 0 Lb\n(Sub) Ln\n"), "{text}");
}
/// A 200 × 100 document of what slice 1 writes as plain paths: a gradient fill, a clipping group,
/// a compound path, an image, type, a symbol instance and a half-transparent square.
fn unsupported() -> Document {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let fill = |p: Paint| Appearance::basic(p, Paint::None, 0.0);
let g = Gradient::new(
GradientKind::Linear,
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 =
Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), fill(Paint::Gradient(Box::new(GradientPaint::new(g)))));
doc.insert(Some(layer), usize::MAX, shaded).unwrap();
let clip = Node::new(
doc.alloc_id(),
NodeKind::Path {
path: shapes::rectangle(Rect::new(40.0, 10.0, 50.0, 30.0)),
rule: FillRule::NonZero,
live: None,
clipping: true,
guide: false,
},
);
let clipped = Node::path(
doc.alloc_id(),
shapes::rectangle(Rect::new(35.0, 15.0, 60.0, 25.0)),
Appearance::basic(solid(Color::gray(0.5)), solid(Color::BLACK), 1.0),
);
let mut window = Node::new(doc.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(clipped)], clip: true });
window.name = Some("Window".into());
doc.insert(Some(layer), usize::MAX, window).unwrap();
let outer = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(70.0, 10.0, 90.0, 30.0)), Appearance::default());
let inner = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(75.0, 15.0, 85.0, 25.0)), Appearance::default());
let mut ring = Node::new(doc.alloc_id(), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(inner)], rule: FillRule::EvenOdd });
ring.appearance = fill(solid(Color::cmyk(0.0, 1.0, 1.0, 0.0))).into();
doc.insert(Some(layer), usize::MAX, ring).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, 100.0, 10.0]),
link: None,
placement: Default::default(),
pixels: None,
};
let im = Node::new(doc.alloc_id(), NodeKind::Image(Box::new(im)));
doc.insert(Some(layer), usize::MAX, im).unwrap();
let t = vectorcraft_doc::TextObject::point(
vectorcraft_geom::Point::new(10.0, 70.0),
"Type",
vectorcraft_doc::CharStyle { size: 18.0, fill: solid(Color::BLACK), ..vectorcraft_doc::CharStyle::default() },
);
let t = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t)));
doc.insert(Some(layer), usize::MAX, t).unwrap();
let art = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), fill(solid(Color::rgb(0.0, 1.0, 0.0))));
doc.symbols.push(Symbol { name: "Dot".into(), art: Arc::new(art) });
let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((150.0, 10.0)) });
doc.insert(Some(layer), usize::MAX, instance).unwrap();
let mut faded = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(170.0, 10.0, 190.0, 30.0)), fill(solid(Color::BLACK)));
faded.opacity = 0.5;
doc.insert(Some(layer), usize::MAX, faded).unwrap();
doc
}
/// Every path in `nodes` and their descendants.
fn paths(nodes: &[Arc<Node>], out: &mut Vec<Node>) {
for n in nodes {
match n.children() {
Some(c) => paths(c, out),
None => out.push((**n).clone()),
}
}
}
#[test]
fn art_the_slice_does_not_cover_is_written_as_plain_paths() {
let doc = unsupported();
let out = export_ai_editing(&doc, &opts()).unwrap();
for w in [writer::GRADIENTS, writer::CLIPPING, writer::IMAGES, writer::TYPE, writer::LIVE, writer::TRANSPARENCY] {
assert!(out.warnings.iter().any(|x| x == w), "{w}: {:?}", out.warnings);
}
let back = read_back(&doc);
let layer = back.layers[0].children().unwrap();
let kinds: Vec<&str> = layer
.iter()
.map(|n| match n.kind {
NodeKind::Path { .. } => "path",
NodeKind::Group { .. } => "group",
NodeKind::Compound { .. } => "compound",
_ => "other",
})
.collect();
assert_eq!(kinds, ["path", "group", "compound", "path", "group", "group", "path"]);
let mut all = vec![];
paths(layer, &mut all);
assert!(all.iter().all(|n| matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })));
// The gradient as its average color.
assert_eq!(paint(back.layers[0].children().unwrap()[0].appearance.items[0].paint()), paint(&solid(Color::rgb(0.5, 0.5, 0.5))));
// The clipping group: the gray fill and the black stroke's outline, cut to the 10 × 20 clip.
let window = &layer[1];
assert_eq!(window.name.as_deref(), Some("Window"));
let b = window.visual_bounds().unwrap();
assert!((b.x0 - 40.0).abs() < 0.01 && (b.x1 - 50.0).abs() < 0.01, "{b:?}");
// The image's outline filled with its pixels' average.
assert_eq!(paint(layer[3].appearance.items[0].paint()), paint(&solid(Color::rgb(0.5, 0.0, 0.5))));
assert_eq!(layer[3].visual_bounds().map(|b| (r3(b.x0), r3(b.y0), r3(b.x1), r3(b.y1))), Some((r3(100.0), r3(10.0), r3(120.0), r3(20.0))));
// The half-transparent square, opaque.
assert_eq!(layer[6].opacity, 1.0);
}
#[test]
fn brushed_strokes_are_written_as_their_brush_art() {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let mut line = Node::path(
doc.alloc_id(),
shapes::line((20.0, 50.0).into(), (180.0, 50.0).into()),
Appearance::basic(Paint::None, solid(Color::rgb(1.0, 0.0, 0.0)), 4.0),
);
line.appearance.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
doc.insert(Some(layer), usize::MAX, line).unwrap();
let pieces = vectorcraft_brush::stroke_pieces(
&doc,
&vectorcraft_brush::find(&doc, "3 pt. Round").unwrap(),
&shapes::line((20.0, 50.0).into(), (180.0, 50.0).into()).to_bezpath(),
doc.layers[0].children().unwrap()[0].appearance.stroke().unwrap(),
);
let back = read_back(&doc);
let NodeKind::Group { children, .. } = &back.layers[0].children().unwrap()[0].kind else { panic!("a group of the brush's art") };
assert_eq!(children.len(), pieces.len());
let bounds = |n: &Node| n.visual_bounds().map(|b| [b.x0, b.y0, b.x1, b.y1].map(|v| (v * 10.0).round()));
assert_eq!(children.iter().map(|c| bounds(c)).collect::<Vec<_>>(), pieces.iter().map(bounds).collect::<Vec<_>>());
}
#[test]
fn type_is_written_as_create_outlines_makes_it() {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let style = vectorcraft_doc::CharStyle { size: 18.0, fill: solid(Color::BLACK), underline: true, ..vectorcraft_doc::CharStyle::default() };
let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 70.0), "Under", style);
let mut t = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t)));
t.name = Some("Title".into());
doc.insert(Some(layer), usize::MAX, t.clone()).unwrap();
let outlined = vectorcraft_effects::outline_text(&t).unwrap();
let back = read_back(&doc);
let group = &back.layers[0].children().unwrap()[0];
assert_eq!(group.name.as_deref(), Some("Title"));
let rounded = |n: &Node| n.visual_bounds().map(|b| [b.x0, b.y0, b.x1, b.y1].map(|v| (v * 100.0).round()));
// The underline reaches below the glyphs: the bounds match only with it.
assert_eq!(rounded(group), rounded(&outlined));
}
#[test]
fn a_stained_symbol_instance_is_written_in_its_stained_colors() {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let art = Node::path(
doc.alloc_id(),
shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)),
Appearance::basic(solid(Color::rgb(0.0, 1.0, 0.0)), Paint::None, 0.0),
);
doc.symbols.push(Symbol { name: "Dot".into(), art: Arc::new(art) });
let mut instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((50.0, 10.0)) });
instance.appearance = Appearance::basic(solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into();
let stained = vectorcraft_brush::instance_art(&doc.symbols[0].art, &instance);
doc.insert(Some(layer), usize::MAX, instance).unwrap();
let back = read_back(&doc);
let NodeKind::Group { children, .. } = &back.layers[0].children().unwrap()[0].kind else { panic!("the instance's group") };
assert_eq!(paint(children[0].appearance.items[0].paint()), paint(stained.appearance.items[0].paint()));
assert_ne!(paint(stained.appearance.items[0].paint()), paint(&solid(Color::rgb(0.0, 1.0, 0.0))), "the instance stains its art");
}
#[test]
fn a_layer_with_crop_marks_keeps_its_art_and_gets_the_marks() {
for clipping in [false, true] {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let at = Rect::new(50.0, 30.0, 90.0, 60.0);
if clipping {
let clip = Node::new(
doc.alloc_id(),
NodeKind::Path { path: shapes::rectangle(at), rule: FillRule::NonZero, live: None, clipping: true, guide: false },
);
doc.insert(Some(layer), usize::MAX, clip).unwrap();
}
let square = Node::path(doc.alloc_id(), shapes::rectangle(at), Appearance::basic(solid(Color::BLACK), Paint::None, 0.0));
doc.insert(Some(layer), usize::MAX, square).unwrap();
if let Some(l) = doc.layers.get_mut(0) {
let fx = vectorcraft_doc::Effect { id: vectorcraft_effects::CROP_MARKS.into(), params: serde_json::json!({}), visible: true };
let l = Arc::make_mut(l);
let mut ap = (*l.appearance).clone();
ap.effects.push(fx);
l.appearance = ap.into();
if let NodeKind::Layer { clip, .. } = &mut l.kind {
*clip = clipping;
}
}
let name = doc.layers[0].name.clone();
let back = read_back(&doc);
assert_eq!(back.layers.len(), 1);
assert_eq!(back.layers[0].name, name);
let members = back.layers[0].children().unwrap();
assert_eq!(members.len(), 2, "clipping {clipping}: the square and the marks");
assert!(matches!(members[0].kind, NodeKind::Path { .. }));
assert!(matches!(members[1].kind, NodeKind::Group { .. }));
// The marks lie around the square, outside a clipping path.
let marks = members[1].visual_bounds().unwrap();
assert!(marks.x0 < at.x0 - 1.0 && marks.x1 > at.x1 + 1.0, "clipping {clipping}: {marks:?}");
}
}
#[test]
fn geometry_effects_in_a_symbol_are_written_as_their_result() {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let mut ap = Appearance::basic(solid(Color::BLACK), Paint::None, 0.0);
ap.effects.push(vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: serde_json::json!({"offset": 5}), visible: true });
let art = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), ap);
doc.symbols.push(Symbol { name: "Dot".into(), art: Arc::new(art) });
let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((50.0, 20.0)) });
doc.insert(Some(layer), usize::MAX, instance).unwrap();
let back = read_back(&doc);
let b = back.layers[0].children().unwrap()[0].visual_bounds().unwrap();
// The 10 pt square offset by 5 pt, at (50, 20).
assert!((b.x0 - 45.0).abs() < 0.1 && (b.x1 - 65.0).abs() < 0.1 && (b.y0 - 15.0).abs() < 0.1 && (b.y1 - 35.0).abs() < 0.1, "{b:?}");
}
#[test]
fn an_adjusted_image_in_a_symbol_is_written_in_its_adjusted_color() {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let mut png = vec![];
image::RgbaImage::from_pixel(2, 2, image::Rgba([0, 0, 0, 255])).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: 2, xf: Affine::scale(5.0), link: None, placement: Default::default(), pixels: None };
let mut im = Node::new(doc.alloc_id(), NodeKind::Image(Box::new(im)));
let shift = serde_json::json!({"amount": 100, "preserveLightness": false});
let mut ap = (*im.appearance).clone();
ap.effects.push(vectorcraft_doc::Effect { id: "adjust.shiftToColor".into(), params: shift, visible: true });
im.appearance = ap.into();
doc.symbols.push(Symbol { name: "Picture".into(), art: Arc::new(im) });
let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Picture".into(), xf: Affine::translate((50.0, 20.0)) });
doc.insert(Some(layer), usize::MAX, instance).unwrap();
let back = read_back(&doc);
let mut all = vec![];
paths(back.layers[0].children().unwrap(), &mut all);
let fill = paint(all[0].appearance.items[0].paint());
assert_ne!(fill, paint(&solid(Color::gray(0.5))), "the adjusted image is found");
assert_ne!(fill, paint(&solid(Color::rgb(0.0, 0.0, 0.0))), "the image is adjusted");
}
#[test]
fn effects_on_a_fill_are_reported_left_out() {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let mut fill = FillLayer::new(solid(Color::BLACK));
fill.effects.push(vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({}), visible: true });
let ap = Appearance { items: vec![AppearanceItem::Fill(fill)], ..Appearance::default() };
let square = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), ap);
doc.insert(Some(layer), usize::MAX, square).unwrap();
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings);
}
#[test]
fn images_too_large_to_average_are_mid_gray() {
let mut png = vec![];
image::GrayImage::new(5000, 4000).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
assert_eq!(image_average_of(&png), paint(&solid(Color::gray(0.5))));
let mut small = vec![];
image::GrayImage::new(50, 40).write_to(&mut std::io::Cursor::new(&mut small), image::ImageFormat::Png).unwrap();
assert_eq!(image_average_of(&small), paint(&solid(Color::rgb(0.0, 0.0, 0.0))));
}
/// The fill an image of `png` is written with, as the reader reads it.
fn image_average_of(png: &[u8]) -> String {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let blob = ImageBlob::new("image/png", png.to_vec());
let key = blob.content_key();
doc.images.insert(key.clone(), blob);
let im = ImageObject { key, width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
let im = Node::new(doc.alloc_id(), NodeKind::Image(Box::new(im)));
doc.insert(Some(layer), usize::MAX, im).unwrap();
paint(read_back(&doc).layers[0].children().unwrap()[0].appearance.items[0].paint())
}
#[test]
fn a_document_without_an_artboard_is_an_error() {
let mut doc = Document::new(200.0, 100.0);
doc.artboards.clear();
assert!(export_ai_editing(&doc, &opts()).is_err());
doc.artboards.push(Artboard { rect: Rect::new(0.0, 0.0, 0.0, 10.0), ..Document::new(1.0, 1.0).artboards[0].clone() });
assert!(export_ai_editing(&doc, &opts()).is_err());
}
+710
View File
@@ -0,0 +1,710 @@
//! The program's art and swatches (see [`super`] for the form).
use std::collections::{HashMap, HashSet};
use std::fmt::Write;
use std::sync::Arc;
use vectorcraft_color::cms::Cms;
use vectorcraft_color::swatch::{REGISTRATION, Swatch};
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, LAYER_COLORS, LayerColor, Node, NodeKind, StrokeLayer};
use vectorcraft_effects::stroke;
use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Shape, SubPath};
use vectorcraft_pathops::BoolOp;
use crate::ai_ops::{self, average, unit};
use crate::ps;
/// How deep symbols, live objects and pattern tiles may nest in one another (deeper art is left
/// out). Groups and layers need no limit of their own: a document is never deeper than
/// [`vectorcraft_doc::MAX_DEPTH`].
const MAX_NEST: u32 = 8;
/// The side of the image whose pixels are averaged (larger ones are scaled down to it first).
const AVERAGE_SIDE: u32 = 64;
/// Most pixels an image may have to be decoded and averaged (more: mid gray).
const MAX_AVERAGED_PIXELS: u64 = 1 << 24;
pub(super) const TYPE: &str = "type is written as its outlines in the editing data for other apps";
pub(super) const GRADIENTS: &str = "gradients are written as their average color in the editing data for other apps";
const PATTERNS: &str = "pattern fills are written as their tiles cut to the shape in the editing data for other apps";
pub(super) const CLIPPING: &str =
"clipping groups and clipping layers are written as their art cut to the clipping path in the editing data for other apps";
pub(super) const IMAGES: &str = "images are written as their outlines filled with their average color in the editing data for other apps";
pub(super) const LIVE: &str =
"symbols, blends, envelopes, meshes, repeats, placed documents and intertwines are written as their art in the editing data for other apps";
const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, gradients, patterns, arrowheads) are written as filled outlines, and brushed strokes as their brush's art, in the editing data for other apps";
pub(super) const TRANSPARENCY: &str = "transparency (opacity, blending modes, opacity masks) is left out of the editing data for other apps";
pub(super) const EFFECTS: &str = "live effects other than geometry effects are left out of the editing data for other apps";
const SWATCHES: &str = "gradient and pattern swatches are left out of the editing data for other apps";
const TEMPLATES: &str = "template layers are written as dimmed layers that don't print in the editing data for other apps";
pub(super) struct Writer<'a> {
doc: &'a Document,
/// The document → art space.
to_art: Affine,
cms: Arc<Cms>,
pub(super) body: String,
pub(super) warnings: Vec<String>,
/// The lock and hidden state (`A`, `Xw`) the last operators set; `None` at a layer's start.
state: Option<(bool, bool)>,
/// The region the art being written is cut to (a clipping path's, in document space).
clip: Option<PathData>,
/// Symbols, live objects and pattern tiles being written inside one another.
nest: u32,
/// The XML ids objects are named with so far.
ids: HashSet<String>,
/// The number the next object named after each XML id tries first.
next: HashMap<String, u64>,
/// The document's brushes, read the first time a brushed stroke is written.
brushes: Option<Vec<vectorcraft_brush::Brush>>,
}
impl<'a> Writer<'a> {
pub(super) fn new(doc: &'a Document, to_art: Affine) -> Self {
Self {
doc,
to_art,
cms: vectorcraft_color::cms::active(),
body: String::new(),
warnings: vec![],
state: None,
clip: None,
nest: 0,
ids: HashSet::new(),
next: HashMap::new(),
brushes: None,
}
}
fn warn(&mut self, w: &str) {
if !self.warnings.iter().any(|x| x == w) {
self.warnings.push(w.to_string());
}
}
fn line(&mut self, s: &str) {
self.body.push_str(s);
self.body.push('\n');
}
// ---------- layers and objects ----------
/// A top-level layer. A layer that baking its effects made a group of the layer and the art an
/// effect adds (Crop Marks) is written as that layer holding the added art too, and any other
/// object at the top level in a layer of its own.
pub(super) fn layer(&mut self, n: &Node) {
if n.is_layer() {
return self.write_layer(n, &[]);
}
let members = n.children().map(Vec::as_slice).unwrap_or_default();
self.transparency(n);
match members.iter().find(|c| c.is_layer()) {
Some(l) => {
// The group took the layer's name. The added art is outside the layer's clipping.
let layer = Node { name: l.name.clone().or_else(|| n.name.clone()), ..(**l).clone() };
let added: Vec<Arc<Node>> = members.iter().filter(|c| !c.is_layer()).cloned().collect();
self.write_layer(&layer, &added);
}
None => {
let mut layer = Node::layer(n.id, n.name.as_deref().unwrap_or("Layer"), LayerColor::Preset(0));
(layer.visible, layer.locked) = (n.visible, n.locked);
self.write_layer(&layer, &[Arc::new(n.clone())]);
}
}
}
/// A layer or sublayer: `%AI5_BeginLayer`, its options `Lb`, its name `Ln`, its art, then
/// `after` (outside its clipping path when it clips), `LB`, `%AI5_EndLayer--`.
fn write_layer(&mut self, n: &Node, after: &[Arc<Node>]) {
let NodeKind::Layer { color, template, printable, children, clip, preview, dim_images } = &n.kind else { return };
let [r, g, b] = match color {
LayerColor::Preset(i) => LAYER_COLORS.get(usize::from(*i)).map_or([79, 128, 255], |c| c.1),
LayerColor::Custom(rgb) => *rgb,
};
let index = match color {
LayerColor::Preset(i) if usize::from(*i) < LAYER_COLORS.len() => i64::from(*i),
_ => -1,
};
if *template {
self.warn(TEMPLATES);
}
self.transparency(n);
let flag = |b: bool| u8::from(b);
let dim = dim_images.unwrap_or(50);
let _ = writeln!(
self.body,
"%AI5_BeginLayer\n{} {} {} {} {} 0 1 {index} {r} {g} {b} 0 {dim} 0 Lb\n{} Ln",
flag(n.visible),
flag(*preview),
flag(!n.locked),
flag(*printable && !*template),
flag(dim_images.is_some() || *template),
ps::bytes(n.name.as_deref().unwrap_or("Layer").as_bytes()),
);
self.state = None;
if *clip {
self.warn(CLIPPING);
self.clip_contents(children);
} else {
for c in children {
self.node(c);
}
}
for c in after {
self.node(c);
}
self.line("LB\n%AI5_EndLayer--");
// The reader's state carries over into the art that follows.
self.state = None;
}
/// Set the lock and hidden state of the object that follows.
fn state(&mut self, locked: bool, hidden: bool) {
let (was_locked, was_hidden) = match self.state {
Some((l, h)) => (Some(l), Some(h)),
None => (None, None),
};
if was_locked != Some(locked) {
self.line(if locked { "1 A" } else { "0 A" });
}
if was_hidden != Some(hidden) {
self.line(if hidden { "1 Xw" } else { "0 Xw" });
}
self.state = Some((locked, hidden));
}
/// Warn when `n` has transparency or effects the data leaves out.
fn transparency(&mut self, n: &Node) {
if n.opacity < 1.0 || n.blend != BlendMode::Normal || n.extras().mask.as_ref().is_some_and(|m| !m.disabled) {
self.warn(TRANSPARENCY);
}
let items = n.appearance.items.iter().filter(|i| i.visible()).flat_map(|i| i.effects());
if n.appearance.effects.iter().chain(items).any(|e| e.visible) {
self.warn(EFFECTS);
}
}
/// One object, its state before it and its name after it.
fn node(&mut self, n: &Node) {
if let NodeKind::Layer { .. } = n.kind {
return self.write_layer(n, &[]);
}
self.transparency(n);
self.state(n.locked, !n.visible);
let start = self.body.len();
match &n.kind {
NodeKind::Layer { .. } => {}
NodeKind::Group { children, clip } => {
self.line("u");
if *clip {
self.warn(CLIPPING);
self.clip_contents(children);
} else {
for c in children {
self.node(c);
}
}
self.line("U");
}
NodeKind::Path { path, rule, guide, .. } => {
if *guide {
ai_ops::guide(&mut self.body, self.to_art, path, true);
} else {
self.shape(n, path, *rule);
}
}
NodeKind::Compound { children, rule } => {
let mut subpaths = vec![];
for p in children.iter().filter_map(|c| c.path_data()) {
subpaths.extend(p.subpaths.iter().cloned());
}
self.shape(n, &PathData::new(subpaths), *rule);
}
NodeKind::Text(_) => self.text(n),
NodeKind::Image(im) => {
let bounds = PathData::from_bezpath(&(im.xf * Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)).to_path(0.1)));
let color = image_average(self.doc.image_blob(im).map(|b| b.bytes.as_slice()));
self.warn(IMAGES);
self.fill_object(&bounds, FillRule::NonZero, &FillLayer::new(Paint::solid(color)));
}
NodeKind::SymbolInstance { symbol, xf } => {
self.warn(LIVE);
self.line("u");
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
&& self.nest < MAX_NEST
{
// Stained by the instance's fill, as the canvas draws it.
let mut art = vectorcraft_brush::instance_art(&sym.art, n);
art.transform(*xf, false);
art.visible = true;
art.locked = false;
art.name = None;
self.nest += 1;
self.node(&art);
self.nest -= 1;
}
self.line("U");
}
NodeKind::Blend { .. }
| NodeKind::Envelope { .. }
| NodeKind::Mesh(_)
| NodeKind::Repeat(_)
| NodeKind::PlacedDocument(_)
| NodeKind::Intertwine { .. } => {
self.warn(LIVE);
self.line("u");
if self.nest < MAX_NEST {
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
self.nest += 1;
for c in g.children().map(Vec::as_slice).unwrap_or_default() {
self.node(c);
}
self.nest -= 1;
}
self.line("U");
}
}
if self.body.len() > start {
self.name(n);
}
}
/// `n`'s name, after it: an `/ArtDictionary` whose `AI10_ArtUID` is the name as an XML id,
/// numbered (`_` and 20 digits, `_`) when another object has that id.
fn name(&mut self, n: &Node) {
let Some(name) = n.name.as_deref().filter(|s| !s.is_empty()) else { return };
let base = vectorcraft_doc::xml_id::from_object_name(name);
let mut id = base.clone();
let next = self.next.entry(base.clone()).or_insert(0);
while self.ids.contains(&id) {
*next += 1;
id = format!("{base}_{next:020}_");
}
self.ids.insert(id.clone());
let _ = writeln!(self.body, "%_/ArtDictionary :\n%_/XMLUID : {} ; (AI10_ArtUID) ,\n%_;\n%_", ps::bytes(id.as_bytes()));
}
/// A container's members, the first one's outline cutting the others: its fills painted
/// behind them (cut to the region outside too), its strokes over them, not cut by it.
fn clip_contents(&mut self, children: &[Arc<Node>]) {
let Some((first, rest)) = children.split_first() else { return };
// Nothing to clip by hides the clipped art.
let Some((outline, rule)) = vectorcraft_effects::clip_outline(first) else { return };
let paint = first.clip_paint();
let outer = self.clip.clone();
let region = self.cut(&PathData::from_bezpath(&outline), rule);
self.clip = Some(region);
if let Some(fill) = &paint.fill {
self.node(fill);
}
for c in rest {
self.node(c);
}
self.clip = outer;
if let Some(stroke) = &paint.stroke {
self.node(stroke);
}
}
/// `path` (filled by `rule`) cut to the region the art is cut to, as a non-zero path.
fn cut(&self, path: &PathData, rule: FillRule) -> PathData {
match &self.clip {
Some(clip) => vectorcraft_pathops::boolean(path, rule, clip, FillRule::NonZero, BoolOp::Intersect),
None => vectorcraft_pathops::boolean(path, rule, path, rule, BoolOp::Intersect),
}
}
// ---------- paths ----------
/// A path's fills and strokes: one object with one fill and a plain stroke over it where it
/// can be, else a group of an object for each (in paint order).
fn shape(&mut self, n: &Node, path: &PathData, rule: FillRule) {
if path.is_empty() {
return;
}
let items: Vec<&AppearanceItem> = n
.appearance
.items
.iter()
.filter(|i| match i {
AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(),
AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0 && s.width.is_finite(),
})
.collect();
if items.iter().any(|i| i.opacity() < 1.0 || i.blend() != BlendMode::Normal) {
self.warn(TRANSPARENCY);
}
let solid = |p: &Paint| matches!(p, Paint::Solid { .. });
match items.as_slice() {
[] => self.path_object(path, rule, None, None),
[AppearanceItem::Fill(f)] if solid(&f.paint) => self.path_object(path, rule, Some(f), None),
[AppearanceItem::Stroke(s)] if direct_stroke(s) => self.path_object(path, rule, None, Some(s)),
[AppearanceItem::Fill(f), AppearanceItem::Stroke(s)] if solid(&f.paint) && direct_stroke(s) => {
self.path_object(path, rule, Some(f), Some(s))
}
[one] => self.item(path, rule, one),
items => {
self.line("u");
for i in items {
self.item(path, rule, i);
}
self.line("U");
}
}
}
/// One fill or stroke of a path as an object of its own.
fn item(&mut self, path: &PathData, rule: FillRule, item: &AppearanceItem) {
match item {
AppearanceItem::Fill(f) => self.fill_object(path, rule, f),
AppearanceItem::Stroke(s) if direct_stroke(s) => self.path_object(path, rule, None, Some(s)),
AppearanceItem::Stroke(s) => {
self.warn(STROKES);
if self.brush_art(path, s) {
return;
}
let outline = stroke::outline_region(path, rule, s);
let fill = FillLayer { overprint: s.overprint, ..FillLayer::new(s.paint.clone()) };
self.fill_object(&outline, FillRule::NonZero, &fill);
}
}
}
/// A brushed stroke `st` of `path` as a group of its brush's art. False when it has no brush
/// the document knows (or brushes nest too deep).
fn brush_art(&mut self, path: &PathData, st: &StrokeLayer) -> bool {
if self.nest >= MAX_NEST {
return false;
}
let doc = self.doc;
let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc));
let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) else { return false };
let art = vectorcraft_brush::stroke_pieces(doc, b, &path.to_bezpath(), st);
let state = self.state;
self.nest += 1;
self.line("u");
for piece in &art {
self.node(piece);
}
if let Some((locked, hidden)) = state {
self.state(locked, hidden);
}
self.line("U");
self.nest -= 1;
true
}
/// A fill as an object of its own: a gradient as its average color, a pattern as its tiles
/// cut to the path.
fn fill_object(&mut self, path: &PathData, rule: FillRule, f: &FillLayer) {
match &f.paint {
Paint::Solid { .. } => self.path_object(path, rule, Some(f), None),
Paint::Gradient(g) => {
self.warn(GRADIENTS);
let flat = FillLayer { paint: Paint::solid(average(&g.gradient)), ..f.clone() };
self.path_object(path, rule, Some(&flat), None);
}
Paint::Pattern { pattern, xf } => {
self.warn(PATTERNS);
let doc = self.doc;
let Some(def) = doc.pattern(pattern).filter(|_| self.nest < MAX_NEST) else {
let flat = FillLayer { paint: Paint::solid(Color::gray(0.5)), ..f.clone() };
return self.path_object(path, rule, Some(&flat), None);
};
let tiles = def.instances_in(*xf, path.to_bezpath().bounding_box());
let outer = self.clip.clone();
let region = self.cut(path, rule);
self.clip = Some(region);
self.nest += 1;
let state = self.state;
self.line("u");
for t in &tiles {
self.node(t);
}
if let Some((locked, hidden)) = state {
self.state(locked, hidden);
}
self.line("U");
self.nest -= 1;
self.clip = outer;
}
Paint::None => {}
}
}
/// A path object painted with `fill` (a color) and `stroke` (a plain line of a color) over
/// it; several subpaths make a compound path. Where the art is cut to a clipping path, the
/// fill is the path's region cut to it and the stroke its outline cut to it, filled.
fn path_object(&mut self, path: &PathData, rule: FillRule, fill: Option<&FillLayer>, stroke: Option<&StrokeLayer>) {
if self.clip.is_none() {
return self.plain_object(path, rule, fill, stroke);
}
let both = fill.is_some() && stroke.is_some();
if both {
self.line("u");
}
if fill.is_some() || stroke.is_none() {
let region = self.cut(path, rule);
self.plain_object(&region, FillRule::NonZero, fill, None);
}
if let Some(s) = stroke {
let outline = stroke::outline_region(path, rule, s);
let region = self.cut(&outline, FillRule::NonZero);
let fill = FillLayer { overprint: s.overprint, ..FillLayer::new(s.paint.clone()) };
self.plain_object(&region, FillRule::NonZero, Some(&fill), None);
}
if both {
self.line("U");
}
}
/// [`Self::path_object`] without cutting: the paint (`O`/`R` overprint, line style), `XR`,
/// each subpath's points and painting operator (`*u` … `*U` around several).
fn plain_object(&mut self, path: &PathData, rule: FillRule, fill: Option<&FillLayer>, stroke: Option<&StrokeLayer>) {
let subpaths: Vec<&SubPath> = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).collect();
if subpaths.is_empty() {
return;
}
if let Some(f) = fill {
self.color(&f.paint, false);
self.line(if f.overprint { "1 O" } else { "0 O" });
}
if let Some(s) = stroke {
self.color(&s.paint, true);
self.line(if s.overprint { "1 R" } else { "0 R" });
ai_ops::line_style(&mut self.body, s);
}
self.line(if rule == FillRule::EvenOdd { "1 XR" } else { "0 XR" });
let op = match (fill.is_some(), stroke.is_some()) {
(true, true) => 'b',
(true, false) => 'f',
(false, true) => 's',
(false, false) => 'n',
};
let several = subpaths.len() > 1;
if several {
self.line("*u");
}
for sp in subpaths {
ai_ops::subpath(&mut self.body, self.to_art, sp);
let op = if sp.closed { op } else { op.to_ascii_uppercase() };
self.body.push(op);
self.body.push('\n');
}
if several {
self.line("*U");
}
}
// ---------- type ----------
/// Type `n` as a group of what Type › Create Outlines makes of it
/// ([`vectorcraft_effects::outline_text`]): each run's glyphs with their underline and
/// strikethrough, its color glyphs, and the object's own fills and strokes.
fn text(&mut self, n: &Node) {
self.warn(TYPE);
self.line("u");
if let Some(outlined) = vectorcraft_effects::outline_text(n) {
let state = self.state;
for c in outlined.children().map(Vec::as_slice).unwrap_or_default() {
self.node(c);
}
if let Some((locked, hidden)) = state {
self.state(locked, hidden);
}
}
self.line("U");
}
// ---------- color ----------
/// Set the fill (or with `stroke`, the stroke) color to solid paint `p`: a spot or global
/// swatch by its name, Registration, else the color itself.
fn color(&mut self, p: &Paint, stroke: bool) {
let Paint::Solid { color, swatch, tint } = p else {
return self.plain_color(Color::gray(1.0), stroke);
};
if swatch.as_deref() == Some(REGISTRATION) {
let mut s = String::from("1 1 1 1 ");
s.push_str(&ps::bytes(REGISTRATION.as_bytes()));
s.push(' ');
ps::push_num(&mut s, f64::from(1.0 - unit(*tint)));
s.push_str(if stroke { " XS" } else { " Xs" });
return self.line(&s);
}
let doc = self.doc;
if let Some(name) = swatch
&& let Some(sw) = doc.swatch(name).filter(|s| s.global || s.spot)
&& let Paint::Solid { color: full, swatch: None, .. } = sw.paint
{
let mut s = String::new();
self.named(&mut s, name, full, *tint, sw.spot, stroke);
return self.body.push_str(&s);
}
self.plain_color(*color, stroke);
}
/// A color that names no swatch: `g` (gray), `k` (CMYK), else `c m y k r g b Xa` (RGB, and
/// Lab as RGB), with its CMYK equivalent.
fn plain_color(&mut self, color: Color, stroke: bool) {
let s = &mut self.body;
let op = |fill: &'static str, line: &'static str| if stroke { line } else { fill };
match color {
// Gray is ink coverage; the format's gray is lightness.
Color::Gray { k } => {
ps::push_num(s, f64::from(1.0 - unit(k)));
let _ = writeln!(s, " {}", op("g", "G"));
}
Color::Cmyk { c, m, y, k } => {
ps::push_nums(s, &[c, m, y, k].map(|v| f64::from(unit(v))));
let _ = writeln!(s, "{}", op("k", "K"));
}
Color::Rgb { .. } | Color::Lab { .. } => {
let cmyk = self.cms.to_cmyk(&color, self.cms.settings().intent).map(unit);
ps::push_nums(s, &cmyk.map(f64::from));
ps::push_nums(s, &color.to_rgb().map(|v| f64::from(unit(v))));
let _ = writeln!(s, "{}", op("Xa", "XA"));
}
}
}
/// A spot (`spot`) or global process color `name` of full color `full` at `tint`, written to
/// `s`: its CMYK equivalent, its RGB or Lab values, the name, how much lighter than the full
/// color, and for any but a CMYK spot color (`x`) the model (0 CMYK, 1 RGB, 2 Lab) before
/// `Xx` (spot) or `Xk` (global process).
fn named(&self, s: &mut String, name: &str, full: Color, tint: f32, spot: bool, stroke: bool) {
let cmyk = self.cms.to_cmyk(&self.doc.linked_color(full, spot), self.cms.settings().intent).map(unit);
ps::push_nums(s, &cmyk.map(f64::from));
let model = match full {
Color::Rgb { r, g, b } => {
ps::push_nums(s, &[r, g, b].map(|v| f64::from(unit(v))));
Some(1)
}
Color::Lab { l, a, b } => {
ps::push_nums(s, &[f64::from(l), f64::from(a), f64::from(b)]);
Some(2)
}
_ if spot => None,
_ => Some(0),
};
s.push_str(&ps::bytes(name.as_bytes()));
s.push(' ');
ps::push_num(s, f64::from(1.0 - unit(tint)));
let op = match (model, spot) {
(None, _) => "x",
(Some(_), true) => "Xx",
(Some(_), false) => "Xk",
};
if let Some(m) = model {
let _ = write!(s, " {m}");
}
let op = if stroke { op.to_ascii_uppercase() } else { op.to_string() };
let _ = writeln!(s, " {op}");
}
// ---------- swatches ----------
/// The swatches: `%AI5_BeginPalette`, `0 0 Pb`, each color swatch's color and `(name) Pc`
/// (the ungrouped ones first, then each color group's after `0 (name) count Pg`), `PB`,
/// `%AI5_EndPalette`.
pub(super) fn palette(&mut self, s: &mut String) {
s.push_str("%AI5_BeginPalette\n0 0 Pb\n");
let doc = self.doc;
for sw in doc.swatches.iter() {
self.swatch(s, sw);
}
for g in doc.swatch_groups.iter() {
let colors: Vec<&Swatch> = g.swatches.iter().filter(|sw| writable(sw)).collect();
let _ = writeln!(s, "0 {} {} Pg", ps::bytes(g.name.as_bytes()), colors.len());
for sw in &g.swatches {
self.swatch(s, sw);
}
}
s.push_str("PB\n%AI5_EndPalette\n");
}
/// One swatch of the palette: a color swatch's color and `(name) Pc`; gradient and pattern
/// swatches are left out.
fn swatch(&mut self, s: &mut String, sw: &Swatch) {
if sw.name == REGISTRATION || matches!(sw.paint, Paint::None) {
return;
}
if !writable(sw) {
return self.warn(SWATCHES);
}
let Paint::Solid { color, swatch, .. } = &sw.paint else { return };
if swatch.is_none() && (sw.global || sw.spot) {
self.named(s, &sw.name, *color, 1.0, sw.spot, false);
} else {
let body = std::mem::take(&mut self.body);
self.plain_color(*color, false);
s.push_str(&std::mem::replace(&mut self.body, body));
}
let _ = writeln!(s, "{} Pc", ps::bytes(sw.name.as_bytes()));
}
/// The spot color swatches, with their full colors' CMYK equivalents, for the header.
pub(super) fn spot_swatches(&self) -> Vec<(String, [f32; 4])> {
let intent = self.cms.settings().intent;
self.doc
.swatches_iter()
.filter(|sw| sw.spot)
.filter_map(|sw| match sw.paint {
Paint::Solid { color, swatch: None, .. } => {
Some((sw.name.clone(), self.cms.to_cmyk(&self.doc.linked_color(color, true), intent).map(unit)))
}
_ => None,
})
.collect()
}
}
/// Is `sw` a color swatch the palette writes?
fn writable(sw: &Swatch) -> bool {
matches!(sw.paint, Paint::Solid { .. }) && sw.name != REGISTRATION
}
/// Does a plain line of one color draw `s` (the line style operators can write it)?
fn direct_stroke(s: &StrokeLayer) -> bool {
matches!(s.paint, Paint::Solid { .. }) && stroke::is_plain(s)
}
/// The image `bytes` decoded, when its header gives at most [`MAX_AVERAGED_PIXELS`] pixels; the
/// decoder may allocate at most 8 bytes a pixel.
fn decode_bounded(bytes: &[u8]) -> Option<image::DynamicImage> {
let reader = || -> image::ImageResult<image::ImageReader<std::io::Cursor<&[u8]>>> {
let mut r = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format()?;
let mut limits = image::Limits::default();
limits.max_alloc = Some(MAX_AVERAGED_PIXELS * 8);
r.limits(limits);
Ok(r)
};
let (w, h) = reader().and_then(|r| r.into_dimensions()).ok()?;
if u64::from(w) * u64::from(h) > MAX_AVERAGED_PIXELS {
return None;
}
reader().and_then(|r| r.decode()).ok()
}
/// The average color of the image `bytes` (scaled down first), transparent pixels over white;
/// mid gray when it can't be decoded or has more than [`MAX_AVERAGED_PIXELS`] pixels.
fn image_average(bytes: Option<&[u8]>) -> Color {
let Some(img) = bytes.and_then(decode_bounded) else { return Color::gray(0.5) };
let img = if img.width() > AVERAGE_SIDE || img.height() > AVERAGE_SIDE { img.thumbnail(AVERAGE_SIDE, AVERAGE_SIDE) } else { img };
let rgba = img.to_rgba8();
let mut sum = [0u64; 3];
let mut n = 0u64;
for px in rgba.pixels() {
let [r, g, b, a] = px.0;
let over = |c: u8| (u64::from(c) * u64::from(a) + 255 * (255 - u64::from(a))) / 255;
sum[0] += over(r);
sum[1] += over(g);
sum[2] += over(b);
n += 1;
}
if n == 0 {
return Color::gray(0.5);
}
let c = |v: u64| (v as f32 / n as f32 / 255.0).clamp(0.0, 1.0);
Color::rgb(c(sum[0]), c(sum[1]), c(sum[2]))
}
+97
View File
@@ -0,0 +1,97 @@
//! Operators the legacy `.ai` writer ([`crate::legacy_ai`]) and the editing data writer
//! ([`crate::ai_editing`]) both write: path points, guides and line style (Adobe Illustrator File
//! Format Specification, version 7.0, §5.3, §5.4 and §6), and the colors they write in place of
//! the paint they can't hold.
use std::fmt::Write;
use vectorcraft_color::{Color, Gradient};
use vectorcraft_doc::{LineCap, LineJoin, StrokeLayer};
use vectorcraft_geom::{Affine, Anchor, AnchorKind, PathData, Point, SubPath};
use crate::ps;
/// One subpath's points, taken into art space by `xf`: `m`, then a line or curve to each point
/// (lower case: the point is smooth, upper case: a corner, §5.3.2), and for a closed one the
/// segment back to the start, which says whether the start is smooth or a corner (as the
/// specification's examples close paths: the start point again, then the painting operator).
pub(crate) fn subpath(s: &mut String, xf: Affine, sp: &SubPath) {
let Some(first) = sp.anchors.first() else { return };
let pt = |s: &mut String, p: Point| {
let p = xf * p;
ps::push_nums(s, &[p.x, p.y]);
};
pt(s, first.p);
s.push_str("m\n");
let segment = |s: &mut String, a: &Anchor, b: &Anchor| {
let straight = a.h_out == a.p && b.h_in == b.p;
let smooth = b.kind == AnchorKind::Smooth;
if straight {
pt(s, b.p);
s.push_str(if smooth { "l\n" } else { "L\n" });
} else {
pt(s, a.h_out);
pt(s, b.h_in);
pt(s, b.p);
s.push_str(if smooth { "c\n" } else { "C\n" });
}
};
for pair in sp.anchors.windows(2) {
if let [a, b] = pair {
segment(s, a, b);
}
}
if sp.closed
&& sp.anchors.len() > 1
&& let Some(last) = sp.anchors.last()
{
segment(s, last, first);
}
}
/// A guide (§6): for each subpath, `(N) *`, its points, `(N) *`; with `closing`, a closed
/// subpath ends `(n) *` (the painting operator that closes it first).
pub(crate) fn guide(s: &mut String, xf: Affine, path: &PathData, closing: bool) {
for sp in path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()) {
s.push_str("(N) *\n");
subpath(s, xf, sp);
s.push_str(if closing && sp.closed { "(n) *\n" } else { "(N) *\n" });
}
}
pub(crate) fn unit(v: f32) -> f32 {
if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }
}
/// The average colour of a gradient's stops (in RGB).
pub(crate) fn average(g: &Gradient) -> Color {
let n = g.stops.len().max(1) as f32;
let sum = g.stops.iter().map(|s| s.color.to_rgb()).fold([0.0f32; 3], |a, c| [a[0] + c[0], a[1] + c[1], a[2] + c[2]]);
Color::rgb(unit(sum[0] / n), unit(sum[1] / n), unit(sum[2] / n))
}
/// The stroke's width, cap, join, miter limit and dashes: `w J j M d`.
pub(crate) fn line_style(s: &mut String, st: &StrokeLayer) {
let cap = match st.cap {
LineCap::Butt => 0,
LineCap::Round => 1,
LineCap::Square => 2,
};
let join = match st.join {
LineJoin::Miter => 0,
LineJoin::Round => 1,
LineJoin::Bevel => 2,
};
ps::push_num(s, st.width);
let _ = write!(s, " w {cap} J {join} j ");
ps::push_num(s, if st.miter_limit.is_finite() { st.miter_limit.max(1.0) } else { 10.0 });
s.push_str(" M [");
let dash = st.dash.as_ref().filter(|d| d.is_dashed());
if let Some(d) = dash {
ps::push_nums(s, &d.pattern);
s.pop();
}
s.push_str("] ");
ps::push_num(s, dash.map_or(0.0, |d| d.offset));
s.push_str(" d\n");
}
+4 -4
View File
@@ -64,13 +64,13 @@ use std::sync::Arc;
use vectorcraft_color::cms::Cms;
use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{
AppearanceItem, AreaOptions, CharPosition, CharStyle, Document, FillLayer, ImageBlob, Justify, LAYER_COLORS, LayerColor, LineCap, LineJoin, Node,
NodeKind, ParaDirection, ParaStyle, PathEffect, StrokeLayer, TextKind, TextObject,
AppearanceItem, AreaOptions, CharPosition, CharStyle, Document, FillLayer, ImageBlob, Justify, LAYER_COLORS, LayerColor, Node, NodeKind,
ParaDirection, ParaStyle, PathEffect, StrokeLayer, TextKind, TextObject,
};
use vectorcraft_effects::stroke;
use vectorcraft_geom::{Affine, AnchorKind, BezPath, FillRule, PathData, Point, Rect, Shape, SubPath, Vec2};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, SubPath, Vec2};
use crate::{latin, ps};
use crate::{ai_ops, latin, ps};
mod encoding;
mod prolog;
+1 -10
View File
@@ -60,9 +60,7 @@ pub(super) enum Ink {
},
}
pub(super) fn unit(v: f32) -> f32 {
if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }
}
pub(super) use crate::ai_ops::{average, unit};
/// Can `a` be drawn through (a finite, non-flat map)?
pub(super) fn invertible(a: Affine) -> bool {
@@ -172,10 +170,3 @@ pub(super) fn direct_fill(p: &Paint) -> bool {
pub(super) fn direct_stroke(s: &StrokeLayer) -> bool {
matches!(s.paint, Paint::Solid { .. }) && stroke::is_plain(s)
}
/// The average colour of a gradient's stops (in RGB).
pub(super) fn average(g: &Gradient) -> Color {
let n = g.stops.len().max(1) as f32;
let sum = g.stops.iter().map(|s| s.color.to_rgb()).fold([0.0f32; 3], |a, c| [a[0] + c[0], a[1] + c[1], a[2] + c[2]]);
Color::rgb(unit(sum[0] / n), unit(sum[1] / n), unit(sum[2] / n))
}
+6 -67
View File
@@ -357,13 +357,9 @@ impl<'a> Writer<'a> {
self.line(if sp.closed { "h\nW\nn" } else { "H\nW\nN" });
}
/// A guide (§6): `(N) *`, its path, `(N) *`.
/// A guide ([`ai_ops::guide`]).
fn guide(&mut self, path: &PathData) {
for sp in path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()) {
self.line("(N) *");
self.subpath(sp);
self.line("(N) *");
}
ai_ops::guide(&mut self.body, self.to_art, path, false);
}
/// A path's fills and strokes: one object with one fill and a plain stroke over it where it
@@ -522,71 +518,14 @@ impl<'a> Writer<'a> {
self.line(if rule == FillRule::EvenOdd { "1 XR" } else { "0 XR" });
}
/// One subpath's points, in art space: `m`, then a line or curve to each point (lower case: the
/// point is smooth, upper case: a corner, §5.3.2), and for a closed one the segment back to the
/// start, which says whether the start is smooth or a corner (as the specification's examples
/// close paths: the start point again, then the painting operator).
/// One subpath's points, in art space ([`ai_ops::subpath`]).
fn subpath(&mut self, sp: &SubPath) {
let Some(first) = sp.anchors.first() else { return };
let xf = self.to_art;
let s = &mut self.body;
let pt = |s: &mut String, p: Point| {
let p = xf * p;
ps::push_nums(s, &[p.x, p.y]);
};
pt(s, first.p);
s.push_str("m\n");
let segment = |s: &mut String, a: &vectorcraft_geom::Anchor, b: &vectorcraft_geom::Anchor| {
let straight = a.h_out == a.p && b.h_in == b.p;
let smooth = b.kind == AnchorKind::Smooth;
if straight {
pt(s, b.p);
s.push_str(if smooth { "l\n" } else { "L\n" });
} else {
pt(s, a.h_out);
pt(s, b.h_in);
pt(s, b.p);
s.push_str(if smooth { "c\n" } else { "C\n" });
}
};
for pair in sp.anchors.windows(2) {
if let [a, b] = pair {
segment(s, a, b);
}
}
if sp.closed
&& sp.anchors.len() > 1
&& let Some(last) = sp.anchors.last()
{
segment(s, last, first);
}
ai_ops::subpath(&mut self.body, self.to_art, sp);
}
/// The stroke's width, cap, join, miter limit and dashes: `w J j M d`.
/// The stroke's width, cap, join, miter limit and dashes ([`ai_ops::line_style`]).
fn line_style(&mut self, st: &StrokeLayer) {
let cap = match st.cap {
LineCap::Butt => 0,
LineCap::Round => 1,
LineCap::Square => 2,
};
let join = match st.join {
LineJoin::Miter => 0,
LineJoin::Round => 1,
LineJoin::Bevel => 2,
};
let s = &mut self.body;
ps::push_num(s, st.width);
let _ = write!(s, " w {cap} J {join} j ");
ps::push_num(s, if st.miter_limit.is_finite() { st.miter_limit.max(1.0) } else { 10.0 });
s.push_str(" M [");
let dash = st.dash.as_ref().filter(|d| d.is_dashed());
if let Some(d) = dash {
ps::push_nums(s, &d.pattern);
s.pop();
}
s.push_str("] ");
ps::push_num(s, dash.map_or(0.0, |d| d.offset));
s.push_str(" d\n");
ai_ops::line_style(&mut self.body, st);
}
// ---------- colour ----------
+6
View File
@@ -28,7 +28,12 @@
//! - `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.
//! - `ai_editing`: the editing copy of the art a PDF-compatible `.ai` file carries
//! ([`export_ai_editing`], [`ai_private_data`]).
//! - `ai_ops`: the path and line style operators those two write.
mod ai_editing;
mod ai_ops;
mod import;
mod latin;
mod legacy_ai;
@@ -37,6 +42,7 @@ mod ps;
mod scene;
mod tiff;
pub use ai_editing::{AiEditingOptions, AiEditingOutput, CREATOR_VERSION, export_ai_editing, private_data as ai_private_data};
pub use import::{Imported, POSTSCRIPT_ERRORS, ai_alone, family_style, import, import_trusting, import_with, is_loss, layered_ai, page};
pub use legacy_ai::{HiddenObjects, LegacyAiOptions, LegacyAiOutput, export_legacy_ai};
pub use print::{PrintJob, PrintPage, print};
+6 -1
View File
@@ -17,7 +17,8 @@
//! printer's marks around it (the media box), drawn in Registration. [`PdfSettings`] is the Save PDF
//! dialog's model (standard, compatibility, General, Compression, Marks and Bleeds, Output,
//! Advanced, Security); options the writer doesn't apply yet come back as warnings. With Preserve
//! Editing the native document rides along as an embedded file ([`editing()`]). Each page can
//! Editing the native document rides along as an embedded file ([`editing()`]), and the first
//! page can carry a `.ai` file's editing copy of the art ([`PrivateData`]). Each page can
//! carry a thumbnail ([`Thumbnail`], drawn by the caller), and Fast Web View writes a linearised
//! file (still linearised when encrypted). A PDF 1.3 file ([`PdfSettings::pdf13`]) must be flat:
//! the app flattens the document first, and the writer refuses transparency that is left.
@@ -63,6 +64,7 @@ mod output;
mod pages;
mod patch;
mod pdfx;
mod piece_info;
mod post;
mod presets;
mod print;
@@ -79,6 +81,7 @@ pub use import::{
import, import_with_report, is_glyph_outlines, is_outlines_note,
};
pub use pages::{PageInfo, PdfInfo, has_illustrator_data, illustrator_data, info, is_postscript};
pub use piece_info::{PRIVATE_CHUNK, PrivateData};
pub use post::{THUMBNAIL_SIZE, Thumbnail};
pub use presets::*;
pub use print::*;
@@ -102,6 +105,8 @@ pub struct PdfOptions {
pub modified: Option<i64>,
/// The native document (`.vectorcraft` bytes) Preserve Editing embeds.
pub native: Option<Vec<u8>>,
/// The editing copy of the art for the first page's `PieceInfo`, as a `.ai` file carries it.
pub private_data: Option<PrivateData>,
/// The pages drawn small, in page order: what Embed Page Thumbnails embeds (the writer
/// doesn't draw; [`page_areas`] says what each page shows).
pub thumbnails: Vec<Thumbnail>,
+126
View File
@@ -0,0 +1,126 @@
//! An editor's private data on the first page: a page-piece dictionary (ISO 32000-1 §14.5,
//! `PieceInfo`) holding the editing copy of the art that `.ai` files carry, laid out as in the
//! `.ai` files users sent, the form [`crate::illustrator_data`] reads.
//!
//! - The page dictionary gets `/PieceInfo << /Illustrator n 0 R >>` and its own `/LastModified`
//! (which §14.5 requires with `PieceInfo`).
//! - Object `n` is the data dictionary: `<< /LastModified (D:…) /Private m 0 R >>`.
//! - Object `m` holds `AIMetaData` (a stream of the data's header comments), `AIPrivateData1` …
//! `AIPrivateDataN` (the data in streams of [`PRIVATE_CHUNK`] bytes, the last one shorter),
//! `ContainerVersion`, `CreatorVersion`, `NumBlock` (N), `RoundtripStreamType` and
//! `RoundtripVersion`. Every stream has a `/Length` and no filter: the data comes compressed.
use std::fmt::Write as _;
use crate::PdfError;
use crate::linearize::{self, Parsed};
use crate::patch::Patch;
/// Bytes in each private data stream but the last.
pub const PRIVATE_CHUNK: usize = 1 << 16;
/// The `PieceInfo` key the data dictionary is under.
const OWNER: &str = "Illustrator"; // brand-ok: the key the files use
/// The private data a page-piece dictionary carries (see the module documentation).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PrivateData {
/// The data, as its streams hold it joined.
pub data: Vec<u8>,
/// What the `AIMetaData` stream holds.
pub metadata: Vec<u8>,
pub container_version: u32,
pub creator_version: u32,
pub roundtrip_version: u32,
pub roundtrip_stream_type: u32,
}
/// `pdf` with `p` in its first page's `PieceInfo`, dated `modified` (Unix seconds, UTC).
pub(crate) fn add(pdf: Vec<u8>, p: &PrivateData, modified: i64) -> Result<Vec<u8>, PdfError> {
let failed = |why: &str| PdfError::Write(format!("can't add the editing data for other apps: {why}"));
let Parsed { xref, pages, .. } = linearize::parse(&pdf).map_err(failed)?;
let first = *pages.first().ok_or_else(|| failed("a page can't be read"))?;
let (dict, _) = xref.dict(&pdf, first).ok_or_else(|| failed("a page can't be read"))?;
let mut patch = Patch::new(&xref);
let metadata = patch.add_object(stream(&p.metadata));
let blocks: Vec<u32> = p.data.chunks(PRIVATE_CHUNK).map(|c| patch.add_object(stream(c))).collect();
let mut private = format!("<< /AIMetaData {metadata} 0 R");
for (i, n) in blocks.iter().enumerate() {
let _ = write!(private, " /AIPrivateData{} {n} 0 R", i + 1);
}
let _ = write!(
private,
" /ContainerVersion {} /CreatorVersion {} /NumBlock {} /RoundtripStreamType {} /RoundtripVersion {} >>",
p.container_version,
p.creator_version,
blocks.len(),
p.roundtrip_stream_type,
p.roundtrip_version
);
let private = patch.add_object(private.into_bytes());
let date = pdf_date(modified);
let owner = patch.add_object(format!("<< /LastModified {date} /Private {private} 0 R >>").into_bytes());
patch.replace(dict, dict, format!("/PieceInfo << /{OWNER} {owner} 0 R >> /LastModified {date} ").into_bytes());
patch.apply(&pdf, &xref)
}
/// A stream object of `bytes`, not filtered.
fn stream(bytes: &[u8]) -> Vec<u8> {
let mut s = format!("<< /Length {} >>\nstream\n", bytes.len()).into_bytes();
s.extend_from_slice(bytes);
s.extend_from_slice(b"\nendstream");
s
}
/// `at` (Unix seconds) as a PDF date string in UTC (ISO 32000-1 §7.9.4).
fn pdf_date(at: i64) -> String {
let [y, mo, d, h, mi, s] = vectorcraft_doc::metadata::civil(at);
format!("(D:{y:04}{mo:02}{d:02}{h:02}{mi:02}{s:02}Z)")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{PdfOptions, export};
use vectorcraft_doc::Document;
fn data(len: usize) -> PrivateData {
PrivateData {
data: (0..len).map(|i| (i % 251) as u8).collect(),
metadata: b"%!PS-Adobe-3.0\n%%EndComments\n".to_vec(),
container_version: 12,
creator_version: 30,
roundtrip_version: 24,
roundtrip_stream_type: 2,
}
}
#[test]
fn the_first_page_carries_the_data_in_streams_of_64_kib() {
let doc = Document::new(200.0, 100.0);
let p = data(PRIVATE_CHUNK * 2 + 10);
let opts = PdfOptions { private_data: Some(p.clone()), ..PdfOptions::uncompressed() };
let pdf = export(&doc, &opts).unwrap();
assert_eq!(crate::illustrator_data(&pdf, None).as_deref(), Some(&p.data[..]));
let text = String::from_utf8_lossy(&pdf);
for key in ["/AIPrivateData1 ", "/AIPrivateData2 ", "/AIPrivateData3 ", "/NumBlock 3 ", "/ContainerVersion 12 ", "/CreatorVersion 30 "] {
assert!(text.contains(key), "{key}");
}
assert!(!text.contains("/AIPrivateData4"));
assert_eq!(text.matches("/LastModified (D:").count(), 2, "the page's and the data dictionary's");
assert!(text.contains(&format!("<< /Length {PRIVATE_CHUNK} >>")) && text.contains("<< /Length 10 >>"));
}
#[test]
fn without_the_data_the_page_has_no_piece_info() {
let pdf = export(&Document::new(200.0, 100.0), &PdfOptions::uncompressed()).unwrap();
assert!(!String::from_utf8_lossy(&pdf).contains("/PieceInfo"));
assert_eq!(crate::illustrator_data(&pdf, None), None);
}
#[test]
fn dates_are_in_utc() {
assert_eq!(pdf_date(0), "(D:19700101000000Z)");
assert_eq!(pdf_date(1_793_000_000), "(D:20261026073320Z)");
}
}
+9 -1
View File
@@ -1,5 +1,6 @@
//! What the export does to the file once it is written, in this order: the pages' thumbnails
//! (General › Embed page thumbnails), the seal of the editing data, fast web view (a linearised
//! (General › Embed page thumbnails), a `.ai` file's editing copy of the art
//! ([`crate::PrivateData`]), the seal of the editing data, fast web view (a linearised
//! file, [`crate::linearize`]) and encryption ([`crate::encrypt`]), which a linearised file goes
//! through between its two steps so that it stays linearised. Also here: what makes a file say it
//! is PDF 1.3 ([`declare_pdf13`]), for PDF 1.3 and PDF/X-1a and PDF/X-3 files, the file's
@@ -214,6 +215,13 @@ pub(crate) fn finish(pdf: Vec<u8>, opts: &PdfOptions, editing: bool, warnings: &
}
(false, _) => pdf,
};
let pdf = match &opts.private_data {
Some(p) => {
let at = opts.modified.or(opts.created).or_else(vectorcraft_doc::metadata::output_time).unwrap_or(0);
crate::piece_info::add(pdf, p, at)?
}
None => pdf,
};
let pdf = if editing { crate::editing::seal(pdf)? } else { pdf };
if !set.fast_web_view {
return crate::encrypt::protect(pdf, set);
@@ -100,6 +100,7 @@ fn label(o: &FormatOption) -> String {
"embedProfiles" => tl!("Embed ICC Profiles").into(),
"pdfCompatible" => tl!("Create PDF-Compatible File").into(),
"compress" => tl!("Use Compression").into(),
"editingData" => tl!("Include Editing Data for Other Apps").into(),
_ => form::humanize(o.name),
}
}
+1
View File
@@ -3402,6 +3402,7 @@ font-technology Variable Променливи
Use Artboards Използване на работните области
Use Black Point Compensation Използване на компенсация на черната точка
Use Compression Използване на компресия
Include Editing Data for Other Apps Включване на данни за редактиране за други приложения
Use document bleed settings Използване на настройките за изтичане на документа
Use Even-Odd Fill Rule Използване на правило за запълване четно-нечетно
Use Japanese Crop Marks Използване на японски марки за изрязване
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3561,6 +3561,7 @@ theme Appearance Motiv
Type Object Selection by Path Only Výběr textového objektu pouze podle cesty
UI Scaling Měřítko rozhraní
Use Compression Použít kompresi
Include Editing Data for Other Apps Zahrnout data pro úpravy v jiných aplikacích
Use Japanese Crop Marks Použít japonské ořezové značky
Use Low Resolution Proxy for Linked EPS Pro propojené EPS použít zástupce v nízkém rozlišení
Use Precise Cursors Používat přesné kurzory
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3310,6 +3310,7 @@ theme Appearance Udseende
Type Object Selection by Path Only Markering af tekstobjekter udelukkende via kurve
UI Scaling UI-skalering
Use Compression Anvend komprimering
Include Editing Data for Other Apps Medtag redigeringsdata til andre apps
Use Japanese Crop Marks Anvend japanske beskæringsmærker
Use Low Resolution Proxy for Linked EPS Anvend proxy med lav opløsning til koblede EPS-filer
Use Precise Cursors Anvend præcise markører
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3423,6 +3423,7 @@ effect Points Punkte
Use Artboards Zeichenflächen verwenden
Use Black Point Compensation Tiefenkompensierung verwenden
Use Compression Komprimierung verwenden
Include Editing Data for Other Apps Bearbeitungsdaten für andere Apps einschließen
Use document bleed settings Anschnitteinstellungen des Dokuments verwenden
Use Even-Odd Fill Rule Gerade-Ungerade-Füllregel verwenden
Use Japanese Crop Marks Japanische Schnittmarken verwenden
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3753,6 +3753,7 @@ theme Appearance Εμφάνιση
Use Artboards Χρήση πινάκων σχεδίασης
Use Black Point Compensation Χρήση αντιστάθμισης μαύρου σημείου
Use Compression Χρήση συμπίεσης
Include Editing Data for Other Apps Συμπερίληψη δεδομένων επεξεργασίας για άλλες εφαρμογές
Use document bleed settings Χρήση ρυθμίσεων ξακρίσματος εγγράφου
Use Even-Odd Fill Rule Χρήση κανόνα γεμίσματος άρτιου-περιττού
Use Japanese Crop Marks Χρήση ιαπωνικών σημαδιών περικοπής
Can't render this file because it contains an unexpected character in line 2070 and column 19.
+1
View File
@@ -3470,6 +3470,7 @@ font-technology Variable Variables
Use Artboards Usar mesas de trabajo
Use Black Point Compensation Usar compensación de punto negro
Use Compression Usar compresión
Include Editing Data for Other Apps Incluir datos de edición para otras aplicaciones
Use document bleed settings Usar los ajustes de sangrado del documento
Use Even-Odd Fill Rule Usar regla de relleno par-impar
Use Japanese Crop Marks Usar marcas de corte japonesas
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3487,6 +3487,7 @@ theme Appearance Ulkoasu
Type Object Selection by Path Only Tekstiobjektin valinta vain polusta
UI Scaling Käyttöliittymän skaalaus
Use Compression Käytä pakkausta
Include Editing Data for Other Apps Sisällytä muokkaustiedot muille sovelluksille
Use Japanese Crop Marks Käytä japanilaisia rajausmerkkejä
Use Low Resolution Proxy for Linked EPS Käytä matalan tarkkuuden välityskuvaa linkitetyille EPS-tiedostoille
Use Precise Cursors Käytä tarkkoja kohdistimia
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3467,6 +3467,7 @@ font-technology Variable Variables
Use Artboards Utiliser les plans de travail
Use Black Point Compensation Utiliser la compensation du point noir
Use Compression Utiliser la compression
Include Editing Data for Other Apps Inclure les données de modification pour d’autres applications
Use document bleed settings Utiliser les paramètres de fond perdu du document
Use Even-Odd Fill Rule Utiliser la règle de remplissage pair-impair
Use Japanese Crop Marks Utiliser les traits de coupe japonais
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4180,6 +4180,7 @@ mark-style Japanese Japán
Use Artboards Rajztáblák használata
Use Black Point Compensation Feketepont-kompenzáció használata
Use Compression Tömörítés használata
Include Editing Data for Other Apps Szerkesztési adatok hozzáadása más alkalmazásokhoz
Use Default Alapértelmezett használata
Use document bleed settings A dokumentum kifutóbeállításainak használata
Use Even-Odd Fill Rule Páros-páratlan kitöltési szabály
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3310,6 +3310,7 @@ theme Appearance Tampilan
Type Object Selection by Path Only Pemilihan objek teks hanya berdasarkan jalur
UI Scaling Penskalaan UI
Use Compression Gunakan kompresi
Include Editing Data for Other Apps Sertakan data penyuntingan untuk aplikasi lain
Use Japanese Crop Marks Gunakan tanda potong Jepang
Use Low Resolution Proxy for Linked EPS Gunakan proksi resolusi rendah untuk EPS tertaut
Use Precise Cursors Gunakan kursor presisi
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
3310
3311
3312
3313
3314
3315
3316
+1
View File
@@ -3505,6 +3505,7 @@ mark-style Japanese Giapponese
Use Artboards Usa tavole da disegno
Use Black Point Compensation Usa compensazione punto nero
Use Compression Usa compressione
Include Editing Data for Other Apps Includi dati di modifica per altre app
Use document bleed settings Usa impostazioni abbondanza del documento
Use Even-Odd Fill Rule Usa regola di riempimento pari-dispari
Use Japanese Crop Marks Usa segni di ritaglio giapponesi
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3153,6 +3153,7 @@ mark-style Japanese 日本式
Use Artboards アートボードを使用
Use Black Point Compensation ブラックポイントの補正を使用
Use Compression 圧縮を使用
Include Editing Data for Other Apps 他のアプリ用の編集データを含める
Use Even-Odd Fill Rule 奇偶規則を使用
Use Japanese Crop Marks 日本式トンボを使用
Use Low Resolution Proxy for Linked EPS リンク EPS に低解像度プロキシを使用
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3118,6 +3118,7 @@ theme Appearance იერსახე
Type Object Selection by Path Only ტექსტური ობიექტის მონიშვნა მხოლოდ ტრაექტორიით
UI Scaling ინტერფეისის მასშტაბირება
Use Compression შეკუმშვის გამოყენება
Include Editing Data for Other Apps სხვა აპებისთვის რედაქტირების მონაცემების ჩართვა
Use Japanese Crop Marks იაპონური ჭრის ნიშნების გამოყენება
Use Low Resolution Proxy for Linked EPS დაბალი გარჩევადობის პროქსის გამოყენება დაკავშირებული EPS-ისთვის
Use Precise Cursors ზუსტი კურსორების გამოყენება
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3147,6 +3147,7 @@ mark-style Japanese 일본식
Use Artboards 아트보드 사용
Use Black Point Compensation 블랙 포인트 보정 사용
Use Compression 압축 사용
Include Editing Data for Other Apps 다른 앱용 편집 데이터 포함
Use Even-Odd Fill Rule 짝홀 칠 규칙 사용
Use Japanese Crop Marks 일본식 자르기 표시 사용
Use Low Resolution Proxy for Linked EPS 연결된 EPS에 저해상도 프록시 사용
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3324,6 +3324,7 @@ theme Appearance Vormgeving
Type Object Selection by Path Only Tekstobjecten alleen via hun pad selecteren
UI Scaling UI-schaling
Use Compression Compressie gebruiken
Include Editing Data for Other Apps Bewerkingsgegevens voor andere apps opnemen
Use Japanese Crop Marks Japanse aftekenlijnen gebruiken
Use Low Resolution Proxy for Linked EPS Proxy met lage resolutie gebruiken voor gekoppelde EPS
Use Precise Cursors Nauwkeurige cursors gebruiken
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3423,6 +3423,7 @@ theme Appearance Wygląd
Type Object Selection by Path Only Zaznaczanie obiektów tekstowych tylko po ścieżce
UI Scaling Skalowanie interfejsu
Use Compression Używaj kompresji
Include Editing Data for Other Apps Dołącz dane edycji dla innych aplikacji
Use Japanese Crop Marks Używaj japońskich znaczników przycięcia
Use Low Resolution Proxy for Linked EPS Używaj podglądu niskiej rozdzielczości dla połączonych EPS
Use Precise Cursors Używaj precyzyjnych kursorów
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2835,6 +2835,7 @@
Use Artboards Usar Mesas de Trabalho
Use Black Point Compensation Usar Compensação de Ponto Preto
Use Compression Usar Compressão
Include Editing Data for Other Apps Incluir Dados de Edição para Outros Aplicativos
Use Even-Odd Fill Rule Usar Regra de Preenchimento Par-Ímpar
Use Japanese Crop Marks Usar Marcas de Corte Japonesas
Use Low Resolution Proxy for Linked EPS Usar Proxy de Baixa Resolução para EPS Vinculado
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -3501,6 +3501,7 @@ mark-style Japanese Японский
Use Artboards Использовать монтажные области
Use Black Point Compensation Использовать компенсацию чёрной точки
Use Compression Использовать сжатие
Include Editing Data for Other Apps Включать данные редактирования для других приложений
Use document bleed settings Использовать параметры вылета документа
Use Even-Odd Fill Rule Использовать правило заливки «чёт-нечет»
Use Japanese Crop Marks Использовать японские метки обрезки
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3402,6 +3402,7 @@ font-technology Variable Variabla
Use Artboards Använd ritytor
Use Black Point Compensation Använd svartpunktskompensering
Use Compression Använd komprimering
Include Editing Data for Other Apps Inkludera redigeringsdata för andra appar
Use document bleed settings Använd dokumentets inställningar för utfall
Use Even-Odd Fill Rule Använd fyllningsregeln jämn-udda
Use Japanese Crop Marks Använd japanska beskärningsmärken
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -3295,6 +3295,7 @@ font-technology Variable Değişken
Use Artboards Çalışma Yüzeylerini Kullan
Use Black Point Compensation Siyah Nokta Telafisi Kullan
Use Compression Sıkıştırmayı Kullan
Include Editing Data for Other Apps Diğer Uygulamalar İçin Düzenleme Verilerini Ekle
Use document bleed settings Belge taşma payı ayarlarını kullan
Use Even-Odd Fill Rule Çift-Tek Doldurma Kuralını Kullan
Use Japanese Crop Marks Japonca Kırpma İşaretlerini Kullan
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -3498,6 +3498,7 @@ effect Points Точки
Use Artboards Використовувати монтажні області
Use Black Point Compensation Використовувати компенсацію чорної точки
Use Compression Використовувати стиск
Include Editing Data for Other Apps Включати дані редагування для інших програм
Use document bleed settings Використовувати параметри вильоту документа
Use Even-Odd Fill Rule Використовувати правило заливки «парне–непарне»
Use Japanese Crop Marks Використовувати японські мітки обрізання
Can't render this file because it contains an unexpected character in line 749 and column 40.
+1
View File
@@ -3870,6 +3870,7 @@ mark-style Japanese Kiểu Nhật
Use Artboards Dùng bảng vẽ
Use Black Point Compensation Dùng bù điểm đen
Use Compression Dùng nén
Include Editing Data for Other Apps Bao gồm dữ liệu chỉnh sửa cho ứng dụng khác
Use document bleed settings Dùng cài đặt bleed của tài liệu
Use Even-Odd Fill Rule Dùng quy tắc tô chẵn-lẻ
Use Japanese Crop Marks Dùng dấu cắt kiểu Nhật
1 # Vietnamese (vi) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
3870
3871
3872
3873
3874
3875
3876
+1
View File
@@ -3342,6 +3342,7 @@ font-script Japanese 汉字
Use Artboards 使用画板
Use Black Point Compensation 使用黑场补偿
Use Compression 使用压缩
Include Editing Data for Other Apps 包含供其他应用使用的编辑数据
Use document bleed settings 使用文档的出血设置
Use Even-Odd Fill Rule 使用奇偶填色规则
Use Japanese Crop Marks 使用日式裁切标记
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4286,6 +4286,7 @@ font-script Japanese 漢字
Use Artboards 使用工作區域
Use Black Point Compensation 使用黑點補償
Use Compression 使用壓縮
Include Editing Data for Other Apps 包含供其他應用程式使用的編輯資料
Use document bleed settings 使用文件的出血設定
Use Even-Odd Fill Rule 使用奇偶填色規則
Use Japanese Crop Marks 使用日式裁切標記
Can't render this file because it contains an unexpected character in line 6 and column 47.