mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 02:19:56 -06:00
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:
Generated
+1
@@ -6240,6 +6240,7 @@ dependencies = [
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-metafile",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-pdf",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-testkit",
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ¶ms, native)?;
|
||||
let other_apps: AiEditingData = super::encode::options(f, ¶ms)?;
|
||||
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, ¶ms, 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());
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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(®ion, 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(®ion, 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]))
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 ----------
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
Reference in New Issue
Block a user