mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 04:54:45 +00:00
Merge pull request #4968 from dotCooCoo/feat/ai-editing-data
This commit is contained in:
Generated
+1
@@ -6284,6 +6284,7 @@ dependencies = [
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-metafile",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-pdf",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-testkit",
|
||||
|
||||
@@ -333,9 +333,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)));
|
||||
|
||||
@@ -105,6 +105,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 every 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)?;
|
||||
// The pages' background colors, behind the art (the editing data keeps them as colors).
|
||||
let pages = opts.artboards.clone().unwrap_or_else(|| (0..doc.artboards.len()).collect());
|
||||
@@ -295,6 +302,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,14 +95,14 @@ 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
|
||||
/// box, a password, without its hidden layers)? Writing it back would lose the rest, so Save
|
||||
/// doesn't (it asks for a name).
|
||||
/// box, a password, without its hidden layers, without the VectorCraft document it carries)?
|
||||
/// Writing it back would lose the rest, so Save doesn't (it asks for a name).
|
||||
pub fn is_partial(&self) -> bool {
|
||||
self.pages.is_some() || self.crop != CropTo::default() || self.password.is_some() || !self.layers
|
||||
self.pages.is_some() || self.crop != CropTo::default() || self.password.is_some() || !self.layers || !self.native
|
||||
}
|
||||
|
||||
/// The PDF import options: `pages` resolved against the file's page count. A PDF-compatible
|
||||
|
||||
@@ -576,6 +576,9 @@ fn blank_pages(doc: &Document) -> Document {
|
||||
|
||||
/// Why a `.ai` file saved without PDF content looks empty elsewhere.
|
||||
const NOT_PDF_COMPATIBLE: &str = "saved without PDF content: VectorCraft opens it as before, other apps show empty pages";
|
||||
/// The same, for a file that carries the editing copy of its art.
|
||||
const NOT_PDF_COMPATIBLE_EDITING: &str =
|
||||
"saved without PDF content: VectorCraft and apps that read its editing data open it, other apps show empty pages";
|
||||
|
||||
/// `f` (`.ai` or `.ait`) named, in its option errors, as a file saved without PDF content.
|
||||
fn without_pdf(f: &Format) -> Format {
|
||||
@@ -605,6 +608,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 `.ai` files (and `.ait` templates, the same file):
|
||||
/// PDF-compatible by default, without PDF content (`pdfCompatible: false`), or legacy version 7
|
||||
/// `.ai` files (`aiVersion: "7"`).
|
||||
@@ -636,16 +647,46 @@ 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 carries_editing = std::cell::Cell::new(false);
|
||||
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, mut resolved_warnings): (&Document, Vec<String>) = if pdf_compatible {
|
||||
(&pages, Vec::new())
|
||||
} else {
|
||||
let (d, w) = crate::cmd::place::document::full_documents(&edited);
|
||||
resolved = d;
|
||||
(&resolved, w)
|
||||
};
|
||||
// With the artboards' background colors, as the pages draw them.
|
||||
let art = super::export::with_artboard_fills(art, ¶ms, 0..art.artboards.len()).map_err(|e| bad(cmd, e))?;
|
||||
let o = vectorcraft_eps::AiEditingOptions {
|
||||
title: doc.title.clone(),
|
||||
created: doc.metadata.created.or_else(vectorcraft_doc::metadata::output_time),
|
||||
};
|
||||
let (data, lost) = vectorcraft_eps::ai_private_data(&art, &o).map_err(|e| bad(cmd, e))?;
|
||||
// Placed files that weren't found are written as their previews here too.
|
||||
resolved_warnings.extend(lost);
|
||||
carries_editing.set(true);
|
||||
Ok(Some((data, resolved_warnings)))
|
||||
};
|
||||
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());
|
||||
warnings.push(if carries_editing.get() { NOT_PDF_COMPATIBLE_EDITING } else { NOT_PDF_COMPATIBLE }.to_string());
|
||||
}
|
||||
Ok(Encoded { warnings, ..Encoded::one(bytes) })
|
||||
}
|
||||
|
||||
@@ -97,6 +97,18 @@ fn pdf_and_svg_show_the_color_and_their_editing_data_keeps_it_as_the_artboards()
|
||||
assert_eq!(back.layers.len(), doc.layers.len(), "no art made of it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ai_editing_copy_for_other_apps_draws_the_color_as_the_pages_do() {
|
||||
let mut s = session();
|
||||
let opts = LoadOptions { native: false, ..Default::default() };
|
||||
for (more, fill) in [(json!({}), true), (json!({"includeArtboardFill": false}), false)] {
|
||||
let l = load::load_with("a.ai", &file(&mut s, with(json!({"format": "ai"}), &more)), &opts).unwrap();
|
||||
assert!(l.editing_data && !l.restored, "{more}: {:?}", l.warnings);
|
||||
let names: Vec<String> = l.doc.layers.iter().filter_map(|l| l.name.clone()).collect();
|
||||
assert_eq!(names.iter().any(|n| n == "Artboard Fill"), fill, "{more}: {names:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eps_and_postscript_print_draw_the_color() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -403,3 +403,77 @@ 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());
|
||||
}
|
||||
|
||||
/// A `.ai` whose editing copy for other apps differs from the VectorCraft document it carries (the
|
||||
/// copy has no transparency) opens in VectorCraft from the VectorCraft document.
|
||||
#[test]
|
||||
fn an_ai_file_opens_from_its_vectorcraft_document_when_the_editing_copy_differs() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
|
||||
s.execute("transparency.set", &json!({"opacity": 50})).unwrap();
|
||||
let bytes = b64(&s.execute("document.export", &json!({"format": "ai"})).unwrap());
|
||||
let opacities = |doc: &vectorcraft_doc::Document| {
|
||||
let mut out = vec![];
|
||||
doc.walk(|n| {
|
||||
if matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. }) {
|
||||
out.push(n.opacity);
|
||||
}
|
||||
});
|
||||
out
|
||||
};
|
||||
let copy = load::load_with("art.ai", &bytes, &LoadOptions { native: false, trust_editing_data: true, ..Default::default() }).unwrap();
|
||||
assert!(copy.editing_data && !copy.restored);
|
||||
assert_eq!(opacities(©.doc), [1.0]);
|
||||
let opened = load::load_with("art.ai", &bytes, &LoadOptions::default()).unwrap();
|
||||
assert!(opened.restored);
|
||||
assert_eq!(opacities(&opened.doc), [0.5]);
|
||||
}
|
||||
|
||||
@@ -141,6 +141,8 @@ fn load_options_read_text_and_layers() {
|
||||
assert!(o.is_partial(), "without its hidden layers, Save doesn't write the file back");
|
||||
let d = LoadOptions::default();
|
||||
assert_eq!((d.text_as, d.layers, d.is_partial()), (TextAs::Text, true, false));
|
||||
let n = LoadOptions { native: false, ..Default::default() };
|
||||
assert!(n.is_partial(), "without the document it carries, Save doesn't write the file back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -290,6 +290,30 @@ fn ai_export_public_aliases_preserve_missing_placed_links() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `.ai` editing copy for other apps draws a missing placed file as its preview, with the
|
||||
/// pages or without them, and says so.
|
||||
#[test]
|
||||
fn ai_editing_copy_warns_about_missing_placed_files() {
|
||||
let dir = Folder::new("placed-ai-editing-copy-missing");
|
||||
let src = dir.file("logo.vectorcraft");
|
||||
star_source(&src, "placed-ai-editing-copy-missing-unique");
|
||||
let mut s = session();
|
||||
place(&mut s, &src);
|
||||
let parent = dir.file("parent.vectorcraft");
|
||||
save(&mut s, &parent);
|
||||
std::fs::remove_file(&src).unwrap();
|
||||
open(&mut s, &parent);
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let (_, missing) = cmd::place::document::full_documents(doc);
|
||||
assert!(!missing.is_empty());
|
||||
for compatible in [true, false] {
|
||||
let enc = cmd::fileio::encode_all(doc, "ai", &json!({"pdfCompatible": compatible})).unwrap();
|
||||
for w in &missing {
|
||||
assert!(enc.warnings.contains(w), "pdfCompatible {compatible}: {w:?} not in {:?}", enc.warnings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Include Linked Files must retain relinkable objects when only their previews are available.
|
||||
#[test]
|
||||
fn native_included_links_keep_missing_placed_documents_editable() {
|
||||
|
||||
@@ -196,7 +196,11 @@ fn ai_without_pdf_content_and_compression() {
|
||||
};
|
||||
let (full, _) = ai(&mut s, json!({}));
|
||||
let (blank, warnings) = ai(&mut s, json!({"pdfCompatible": false}));
|
||||
assert!(warnings.as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("without PDF content")), "{warnings}");
|
||||
let says = |w: &Value, text: &str| w.as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains(text));
|
||||
assert!(says(&warnings, "without PDF content") && says(&warnings, "apps that read its editing data open it"), "{warnings}");
|
||||
// Without the editing copy, other apps show empty pages.
|
||||
let (_, plain) = ai(&mut s, json!({"pdfCompatible": false, "editingData": false}));
|
||||
assert!(says(&plain, "VectorCraft opens it as before") && !says(&plain, "editing data"), "{plain}");
|
||||
// The pages are empty (an import of a page finds no art), the document comes back whole.
|
||||
let page = |s: &mut Session, b: &[u8]| {
|
||||
s.execute("document.open", &json!({"name": "x.ai", "dataBase64": vectorcraft_format::base64_encode(b), "pages": "1"})).unwrap();
|
||||
|
||||
@@ -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,380 @@
|
||||
//! The editing copy of the art that a PDF-compatible `.ai` file carries in its pages'
|
||||
//! `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: `%%Creator` (naming the format's app, see [`CREATOR_TEXT`]), `%%For` (the
|
||||
//! document's author), `%%Canvassize`, `%%BoundingBox`,
|
||||
//! `%%HiResBoundingBox`, `%AI5_FileFormat 14.0`, `%AI7_ImageSettings`, the first artboard as
|
||||
//! `%AI3_Cropmarks` and `%AI3_TileBox`, the canvas's center as `%AI3_TemplateBox`,
|
||||
//! `%AI5_ArtSize 14400 14400`, `%AI5_RulerUnits`, `%AI24_LargeCanvasScale` (1, or 10 when the
|
||||
//! artboards together don't fit on the canvas: the art, boxes and artboards are then written at
|
||||
//! 1/10 of their size), `%AI9_ColorModel` (1 RGB, 2 CMYK),
|
||||
//! `%AI5_ArtFlags`, `%AI5_TargetResolution`, `%AI5_NumLayers`, `%AI5_OpenViewLayers` (a digit for
|
||||
//! each layer, 7 shown and 6 hidden), `%AI7_GridSettings` (§2.1.1), and the version comment
|
||||
//! `%%AI8_CreatorVersion` ([`CREATOR_VERSION`]).
|
||||
//! - Setup: `0 Bn` (no gradient definitions, §3), the color swatches (`%AI5_BeginPalette`,
|
||||
//! `0 0 Pb`, the Registration swatch, 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's `/Recorded` part and the canvas size as its `AIDocumentCanvasSize`, 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`, the layer's name as an XML id in an `/ArtDictionary`, 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).
|
||||
//!
|
||||
//! Lines end in CR LF. Strings are written in UTF-8. Art space is ruler space: y up, its origin at
|
||||
//! the first artboard's top-left corner. On the canvas ([`CANVAS_SIZE`] points square, y down) the
|
||||
//! artboards together are centered; each artboard's `RulerOrigin` is its top-left corner there, and
|
||||
//! `%AI3_TemplateBox` holds the canvas's center in art space. What the form above can't hold is written as plain filled and stroked paths, each such
|
||||
//! loss a warning: type as its outlines and its inline graphics' art, 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 (symbol art past
|
||||
//! [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects in all left out), 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";
|
||||
/// `%%Canvassize`: the side of the canvas in points, as the files users sent have it.
|
||||
const CANVAS_SIZE: u32 = 16383;
|
||||
/// The scale of a large canvas: its sides are ten times the canvas's, as the format's large canvas
|
||||
/// documents have them (2270 inches square).
|
||||
const LARGE_CANVAS_SCALE: f64 = 10.0;
|
||||
/// `%AI5_ArtSize`, as the files users sent have it.
|
||||
const ART_SIZE: u32 = 14400;
|
||||
/// `%AI5_ArtFlags` (§2.1.1, Table 4), as the files users sent have them: page setup, print tiles,
|
||||
/// placed images, preview patterns, split long paths, tile full pages, printer's default
|
||||
/// screens, auto default screens, compatible gradient printing.
|
||||
const ART_FLAGS: &str = "0 0 0 1 0 0 1 0 0";
|
||||
/// `%AI5_TargetResolution` (§2.1.1).
|
||||
const TARGET_RESOLUTION: u32 = 800;
|
||||
/// The gridlines' gray: VectorCraft's default grid color (200 of 255).
|
||||
const GRID_GRAY: f64 = 200.0 / 255.0;
|
||||
|
||||
/// `%%Creator`: the creator line VectorCraft writes in the header of the editing data. It names
|
||||
/// VectorCraft and the format's app, and holds no version number; the version is written on its
|
||||
/// own in `%%AI8_CreatorVersion` ([`CREATOR_VERSION`]).
|
||||
const CREATOR_TEXT: &str = "VectorCraft, editing data for Illustrator"; // brand-ok: the creator line VectorCraft writes
|
||||
|
||||
/// 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);
|
||||
// Artboards that don't fit on the canvas are written on a large canvas, at 1/scale.
|
||||
let scale = canvas_scale(doc);
|
||||
let reduced = (scale != 1.0).then(|| {
|
||||
let mut d = doc.clone();
|
||||
crate::import::scale_document(&mut d, 1.0 / scale, true);
|
||||
// Symbol and pattern art is placed without scaling its strokes, so its strokes and effects
|
||||
// are scaled here.
|
||||
for s in d.symbols.iter_mut() {
|
||||
scale_strokes(Arc::make_mut(&mut s.art), 1.0 / scale);
|
||||
}
|
||||
for p in d.patterns.iter_mut() {
|
||||
for a in &mut p.art {
|
||||
scale_strokes(Arc::make_mut(a), 1.0 / scale);
|
||||
}
|
||||
}
|
||||
d
|
||||
});
|
||||
let doc = reduced.as_ref().unwrap_or(doc);
|
||||
let board = doc.artboards.first().map_or(board, |a| a.rect);
|
||||
// The document is y down; art space is ruler space: y up, its origin the first artboard's
|
||||
// top-left corner.
|
||||
let to_art = Affine::new([1.0, 0.0, 0.0, -1.0, -board.x0, board.y0]);
|
||||
let mut w = Writer::new(doc, to_art);
|
||||
for layer in &doc.layers {
|
||||
w.layer(layer);
|
||||
}
|
||||
let first = to_art.transform_rect_bbox(board);
|
||||
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(first);
|
||||
let origin = ruler_origin(doc, to_art);
|
||||
let header = header(doc, o, &w, art, first, origin, scale);
|
||||
let mut s = header.clone();
|
||||
// No gradient definitions: gradients are written as their average color.
|
||||
s.push_str("%%BeginProlog\n%%EndProlog\n%%BeginSetup\n0 Bn\n");
|
||||
w.palette(&mut s);
|
||||
artboards(&mut s, doc, to_art, origin);
|
||||
s.push_str("%%EndSetup\n");
|
||||
s.push_str(&w.body);
|
||||
s.push_str("%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n");
|
||||
// Lines end in CR LF, as in the files users sent; line breaks inside strings are escaped.
|
||||
Ok(AiEditingOutput { program: crlf(&s), header: crlf(&header), warnings: w.warnings })
|
||||
}
|
||||
|
||||
/// `s` with each line ending in CR LF.
|
||||
fn crlf(s: &str) -> Vec<u8> {
|
||||
s.replace('\n', "\r\n").into_bytes()
|
||||
}
|
||||
|
||||
/// `doc` with its geometry effects and group paint baked ([`vectorcraft_effects::bake_document`]),
|
||||
/// in its symbols' art and its patterns' tile art too (which instances and pattern fills 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);
|
||||
let needs = vectorcraft_effects::needs_bake;
|
||||
if !d.symbols.iter().any(|s| needs(&s.art)) && !d.patterns.iter().any(|p| p.art.iter().any(|a| needs(a))) {
|
||||
return baked;
|
||||
}
|
||||
// The symbols' art, then each pattern's art, baked as the layers of a copy of the document.
|
||||
let mut art = d.clone();
|
||||
let symbols = d.symbols.iter().map(|s| Arc::new(Node::group(s.art.id, vec![s.art.clone()])));
|
||||
let patterns = d.patterns.iter().map(|p| Arc::new(Node::group(vectorcraft_doc::NodeId(0), p.art.clone())));
|
||||
art.layers = symbols.chain(patterns).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 layers = std::mem::replace(&mut out.layers, d.layers.clone());
|
||||
let mut members = layers.iter().map(|l| l.children().cloned().unwrap_or_default());
|
||||
for s in out.symbols.iter_mut() {
|
||||
if let Some(a) = members.next().and_then(|m| m.first().cloned()) {
|
||||
s.art = a;
|
||||
}
|
||||
}
|
||||
for p in out.patterns.iter_mut() {
|
||||
if let Some(m) = members.next() {
|
||||
p.art = m;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The editing data of `doc` as a `.ai` file's pages carry 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))
|
||||
}
|
||||
|
||||
/// Where art space's origin lies on the canvas (y down, [`CANVAS_SIZE`] points square): the
|
||||
/// artboards together centered on the canvas's center, on whole points, as the files users sent
|
||||
/// place them.
|
||||
fn ruler_origin(doc: &Document, to_art: Affine) -> (f64, f64) {
|
||||
let all = doc.artboards.iter().map(|ab| to_art.transform_rect_bbox(ab.rect)).filter(|r| r.is_finite()).reduce(|a, b| a.union(b));
|
||||
let c = all.map_or((0.0, 0.0), |r| (r.center().x, r.center().y));
|
||||
let mid = canvas_center();
|
||||
((mid - c.0).floor(), (mid + c.1).floor())
|
||||
}
|
||||
|
||||
/// `%AI24_LargeCanvasScale`: 1 when the artboards together fit on the canvas, with a point to
|
||||
/// spare on each side for the rounding of [`ruler_origin`], else [`LARGE_CANVAS_SCALE`]: then the
|
||||
/// art, boxes and artboards are written at 1/[`LARGE_CANVAS_SCALE`] of their size.
|
||||
fn canvas_scale(doc: &Document) -> f64 {
|
||||
let all = doc.artboards.iter().map(|ab| ab.rect).filter(|r| r.is_finite()).reduce(|a, b| a.union(b));
|
||||
let room = f64::from(CANVAS_SIZE) - 2.0;
|
||||
if all.is_some_and(|r| r.width() > room || r.height() > room) { LARGE_CANVAS_SCALE } else { 1.0 }
|
||||
}
|
||||
|
||||
/// The strokes and effects of `n` and of what it holds, `k` times as large.
|
||||
fn scale_strokes(n: &mut Node, k: f64) {
|
||||
n.appearance.scale_strokes(k);
|
||||
n.appearance.scale_effects(k, vectorcraft_effects::scale_effect);
|
||||
if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
|
||||
scale_strokes(Arc::make_mut(&mut mask.art), k);
|
||||
}
|
||||
if let Some(children) = n.children_mut() {
|
||||
for c in children {
|
||||
scale_strokes(Arc::make_mut(c), k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The canvas's center, on its own axes and in both directions.
|
||||
fn canvas_center() -> f64 {
|
||||
f64::from(CANVAS_SIZE) / 2.0
|
||||
}
|
||||
|
||||
/// The header comments, through `%%EndComments`. `art`: the visible art's bounds, `first` the first
|
||||
/// artboard, in art space; `origin` art space's origin on the canvas ([`ruler_origin`]); `scale`
|
||||
/// the large canvas scale ([`canvas_scale`]).
|
||||
fn header(doc: &Document, o: &AiEditingOptions, w: &Writer, art: Rect, first: Rect, origin: (f64, f64), scale: f64) -> String {
|
||||
let mut s = String::new();
|
||||
s.push_str(PS_HEADER);
|
||||
s.push('\n');
|
||||
let _ = writeln!(s, "%%Creator: {CREATOR_TEXT}");
|
||||
let _ = writeln!(s, "%%AI8_CreatorVersion: {CREATOR_VERSION}");
|
||||
// The user and the organization (§2.1.1).
|
||||
let _ = writeln!(s, "%%For: {} ()", ps::bytes(doc.metadata.author.as_bytes()));
|
||||
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, "%%Canvassize: {CANVAS_SIZE}");
|
||||
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");
|
||||
// Linked images aren't embedded (the data holds no images).
|
||||
s.push_str("%AI7_ImageSettings: 0\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()));
|
||||
}
|
||||
// The first artboard, left, bottom, right, top.
|
||||
let mut board = String::new();
|
||||
ps::push_nums(&mut board, &[first.x0, first.y0, first.x1, first.y1]);
|
||||
board.pop();
|
||||
let _ = writeln!(s, "%AI3_Cropmarks: {board}");
|
||||
// The canvas's center in art space.
|
||||
let (cx, cy) = (ps::num(canvas_center() - origin.0), ps::num(origin.1 - canvas_center()));
|
||||
let _ = writeln!(s, "%AI3_TemplateBox: {cx} {cy} {cx} {cy}");
|
||||
let _ = writeln!(s, "%AI3_TileBox: {board}");
|
||||
s.push_str("%AI3_DocumentPreview: None\n");
|
||||
let _ = writeln!(s, "%AI5_ArtSize: {ART_SIZE} {ART_SIZE}");
|
||||
let _ = writeln!(s, "%AI5_RulerUnits: {}", ruler_units(doc.units));
|
||||
let _ = writeln!(s, "%AI24_LargeCanvasScale: {}", ps::num(scale));
|
||||
let model = if doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { 2 } else { 1 };
|
||||
let _ = writeln!(s, "%AI9_ColorModel: {model}");
|
||||
let _ = writeln!(s, "%AI5_ArtFlags: {ART_FLAGS}");
|
||||
let _ = writeln!(s, "%AI5_TargetResolution: {TARGET_RESOLUTION}");
|
||||
let _ = writeln!(s, "%AI5_NumLayers: {}", doc.layers.len());
|
||||
// A digit for each layer: 7 shown, 6 hidden.
|
||||
let views: String = doc.layers.iter().map(|l| if l.visible { '7' } else { '6' }).collect();
|
||||
if !views.is_empty() {
|
||||
let _ = writeln!(s, "%AI5_OpenViewLayers: {views}");
|
||||
}
|
||||
// Gridlines and subdivisions (§2.1.1, Table 6): in back, lines, in VectorCraft's grid gray.
|
||||
let (space, subdiv) = (ps::num(doc.grid.spacing), doc.grid.subdivisions);
|
||||
let gray = ps::num(GRID_GRAY);
|
||||
let _ = writeln!(s, "%AI7_GridSettings: {space} {subdiv} {space} {subdiv} 1 0 {gray} {gray} {gray} {gray} {gray} {gray}");
|
||||
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 | Unit::Q | Unit::Ha => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// The artboards as the `ArtboardArray` of the `/Document` dictionary: each one's top-left and
|
||||
/// bottom-right corners in art space (`PositionPoint1`, `PositionPoint2`), its top-left corner on
|
||||
/// the canvas (`RulerOrigin`, `origin` being art space's origin there), its pixel aspect ratio
|
||||
/// (`PAR`), `DisplayMark` 0 (as in the files users sent), whether it is the selected one (the
|
||||
/// first) and its name; then the canvas's size. Every record holds all of these entries. The
|
||||
/// artboards and the canvas size are written in the `/Recorded` part of the `/Document`
|
||||
/// dictionary, followed by an empty `/NotRecorded` part.
|
||||
fn artboards(s: &mut String, doc: &Document, to_art: Affine, origin: (f64, f64)) {
|
||||
s.push_str("%AI9_BeginDocumentData\n%_/Document :\n%_/Dictionary :\n%_/Array :\n");
|
||||
for (i, ab) in doc.artboards.iter().enumerate() {
|
||||
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%_");
|
||||
ps::push_nums(s, &[origin.0 + r.x0, origin.1 - r.y1]);
|
||||
s.push_str("/RealPoint\n%_ (RulerOrigin) ,\n%_");
|
||||
let par = ab.video_ruler_pixel_aspect_ratio;
|
||||
ps::push_num(s, if par.is_finite() && par > 0.0 { par } else { 1.0 });
|
||||
// Display marks: 1 the center mark, 2 cross hairs, 4 video safe areas.
|
||||
let mark = u8::from(ab.show_center_mark) | u8::from(ab.show_cross_hairs) << 1 | u8::from(ab.show_video_safe_areas) << 2;
|
||||
let _ = writeln!(s, " /Real (PAR) ,\n%_{mark} /Int (DisplayMark) ,\n%_{} /Bool (IsArtboardSelected) ,", u8::from(i == 0));
|
||||
let _ = writeln!(s, "%_{} /UnicodeString (Name) ,\n%_; ,", ps::bytes(ab.name.as_bytes()));
|
||||
}
|
||||
s.push_str("%_; (ArtboardArray) ,\n");
|
||||
let _ = writeln!(s, "%_{CANVAS_SIZE} /Int (AIDocumentCanvasSize) ,");
|
||||
s.push_str("%_; /Recorded ,\n%_/Dictionary : /NotRecorded ,\n%_; /NotRecorded ,\n%_;\n%AI9_EndDocumentData\n");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,819 @@
|
||||
//! 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 PATTERN_TILES: &str = "pattern fills with more tiles than a fill places are written filled gray 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, dots, dashes fitted to corners, 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";
|
||||
pub(super) const SYMBOL_LIMIT: &str = "symbol instances past 16,000,000 objects of symbol art in all 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>>,
|
||||
/// The objects of the symbol art written so far, within [`vectorcraft_doc::MAX_SYMBOL_NODES`].
|
||||
symbol_nodes: u64,
|
||||
}
|
||||
|
||||
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,
|
||||
symbol_nodes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the art of a symbol, `art`, may be written: the objects of the symbol art written
|
||||
/// so far stay within [`vectorcraft_doc::MAX_SYMBOL_NODES`], as in the PDF part (instances of
|
||||
/// symbols that hold instances multiply). Past it the art is left out, with a warning.
|
||||
fn take_symbol(&mut self, art: &Node) -> bool {
|
||||
let total = self.symbol_nodes.saturating_add(art.count() as u64);
|
||||
if total > vectorcraft_doc::MAX_SYMBOL_NODES {
|
||||
self.warn(SYMBOL_LIMIT);
|
||||
return false;
|
||||
}
|
||||
self.symbol_nodes = total;
|
||||
true
|
||||
}
|
||||
|
||||
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` and as an XML id, 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 name = n.name.as_deref().unwrap_or("Layer");
|
||||
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(name.as_bytes()),
|
||||
);
|
||||
self.xml_id(if name.is_empty() { "Layer" } else { name });
|
||||
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");
|
||||
let doc = self.doc;
|
||||
if let Some(sym) = doc.symbols.iter().find(|s| &s.name == symbol)
|
||||
&& self.nest < MAX_NEST
|
||||
{
|
||||
// Stained by the instance's fill, as the canvas draws it.
|
||||
let stained = vectorcraft_brush::instance_art(&sym.art, n);
|
||||
// Through the symbol's 9-slice guides when it has them, as the canvas draws it;
|
||||
// placing through them needs room for the most objects it can make, and the
|
||||
// objects it makes count.
|
||||
let map = doc.symbol_slice_map(symbol, *xf);
|
||||
let most = map.map_or(stained.count() as u64, |m| m.most_placed(&stained, doc));
|
||||
let placed = vectorcraft_doc::charge_symbol_art(&mut self.symbol_nodes, most, || {
|
||||
let art = match map {
|
||||
Some(m) => m.place(&stained, doc),
|
||||
None => {
|
||||
let mut a = stained.clone();
|
||||
a.transform(*xf, false);
|
||||
a
|
||||
}
|
||||
};
|
||||
let count = if map.is_some() { art.count_with_masks() } else { art.count() } as u64;
|
||||
(art, count)
|
||||
});
|
||||
if let Some(mut art) = placed {
|
||||
art.visible = true;
|
||||
art.locked = false;
|
||||
art.name = None;
|
||||
self.nest += 1;
|
||||
self.node(&art);
|
||||
self.nest -= 1;
|
||||
} else {
|
||||
self.warn(SYMBOL_LIMIT);
|
||||
}
|
||||
}
|
||||
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 (see [`Self::xml_id`]).
|
||||
fn name(&mut self, n: &Node) {
|
||||
let Some(name) = n.name.as_deref().filter(|s| !s.is_empty()) else { return };
|
||||
self.xml_id(name);
|
||||
}
|
||||
|
||||
/// An `/ArtDictionary` whose `AI10_ArtUID` is `name` as an XML id, numbered (`_` and 20
|
||||
/// digits, `_`) when a layer or another object has that id.
|
||||
fn xml_id(&mut self, name: &str) {
|
||||
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());
|
||||
// More tiles than a fill places come back as none: the shape is filled gray, as a
|
||||
// pattern that isn't found is.
|
||||
if tiles.is_empty() && !def.art.is_empty() {
|
||||
self.warn(PATTERN_TILES);
|
||||
let flat = FillLayer { paint: Paint::solid(Color::gray(0.5)), ..f.clone() };
|
||||
return self.path_object(path, rule, Some(&flat), None);
|
||||
}
|
||||
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; then its inline
|
||||
/// graphics as their symbols' art where the layout places them, as the PDF page draws them.
|
||||
fn text(&mut self, n: &Node) {
|
||||
self.warn(TYPE);
|
||||
self.line("u");
|
||||
let doc = self.doc;
|
||||
let resolved = match &n.kind {
|
||||
NodeKind::Text(t) => Some(doc.inline_resolved(t).into_owned()),
|
||||
_ => None,
|
||||
};
|
||||
let node = match &resolved {
|
||||
Some(t) => Node { kind: NodeKind::Text(Box::new(t.clone())), ..n.clone() },
|
||||
None => n.clone(),
|
||||
};
|
||||
let state = self.state;
|
||||
let outlined = vectorcraft_effects::outline_text(&node);
|
||||
let pieces = outlined.as_ref().and_then(|o| o.children()).map(Vec::as_slice).unwrap_or_default();
|
||||
// The object's fills and strokes above the Characters row, when they paint, are the last
|
||||
// piece: the inline graphics go under it, as the PDF page draws them.
|
||||
let above = !node.appearance.split_contents().1.is_empty() && {
|
||||
let bare = Node { appearance: Default::default(), ..node.clone() };
|
||||
let glyphs = vectorcraft_effects::outline_text(&bare).and_then(|o| o.children().map(Vec::len)).unwrap_or_default();
|
||||
pieces.len() > glyphs
|
||||
};
|
||||
let (under, over) = pieces.split_at(pieces.len() - usize::from(above));
|
||||
for c in under {
|
||||
self.node(c);
|
||||
}
|
||||
if let Some(t) = resolved.as_ref().filter(|_| self.nest < MAX_NEST) {
|
||||
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
for ig in &layout.inlines {
|
||||
let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref()) else { continue };
|
||||
let Some(sym) = doc.symbols.iter().find(|x| x.name == art.symbol) else { continue };
|
||||
if !self.take_symbol(&sym.art) {
|
||||
continue;
|
||||
}
|
||||
self.warn(LIVE);
|
||||
let mut art_node = (*sym.art).clone();
|
||||
// Strokes scale with the art, as on the canvas.
|
||||
art_node.transform(t.xf * ig.xf * doc.symbol_natural_xf(&art.symbol), true);
|
||||
(art_node.visible, art_node.locked, art_node.name) = (true, false, None);
|
||||
self.nest += 1;
|
||||
self.node(&art_node);
|
||||
self.nest -= 1;
|
||||
}
|
||||
}
|
||||
for c in over {
|
||||
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`, the Registration swatch, 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");
|
||||
self.registration_cell(s);
|
||||
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");
|
||||
}
|
||||
|
||||
/// The Registration swatch, which every palette in the files users sent starts with: in a
|
||||
/// CMYK document `1 1 1 1 ([Registration]) 0 Xs`, in an RGB one black's CMYK equivalent,
|
||||
/// `0 0 0 ([Registration]) 0 1 Xz`; then `([Registration]) Pc`.
|
||||
fn registration_cell(&self, s: &mut String) {
|
||||
let name = ps::bytes(REGISTRATION.as_bytes());
|
||||
if self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk {
|
||||
let _ = writeln!(s, "1 1 1 1 {name} 0 Xs");
|
||||
} else {
|
||||
let black = Color::rgb(0.0, 0.0, 0.0);
|
||||
let cmyk = self.cms.to_cmyk(&black, self.cms.settings().intent).map(unit);
|
||||
ps::push_nums(s, &cmyk.map(f64::from));
|
||||
let _ = writeln!(s, "0 0 0 {name} 0 1 Xz");
|
||||
}
|
||||
let _ = writeln!(s, "{name} Pc");
|
||||
}
|
||||
|
||||
/// 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");
|
||||
}
|
||||
@@ -70,6 +70,7 @@ use graphics::{GState, Out};
|
||||
use interp::{Fault, Interp};
|
||||
use obj::PsError;
|
||||
|
||||
pub(crate) use native::scale_document;
|
||||
pub use native::{ai_alone, ai_alone_on_page, is_loss, layered_ai};
|
||||
pub use text::family_style;
|
||||
|
||||
|
||||
@@ -159,6 +159,8 @@ pub struct Structure {
|
||||
pub envelopes_drawn: Vec<(NodeId, Node)>,
|
||||
/// The document's graphic styles are those of the file's style list, in place of the defaults.
|
||||
pub styles_read: bool,
|
||||
/// The file's `%AI24_LargeCanvasScale` (1 when it declares none), applied or not.
|
||||
pub canvas_scale: f64,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -325,6 +327,8 @@ struct Reader<'a> {
|
||||
/// (see [`Reader::ensure_space`]); 1 (no scaling) when the file doesn't declare one, `None`
|
||||
/// when the declared factor is not applied ([`read`]).
|
||||
large_canvas: Option<f64>,
|
||||
/// The declared `%AI24_LargeCanvasScale` (1 without one), applied or not.
|
||||
canvas_scale: f64,
|
||||
/// `%AI5_RulerUnits` from the header (Illustrator 7 spec table 3, plus centimeters and pixels).
|
||||
ruler_units: Option<u8>,
|
||||
/// This reader reads a pattern's definition (see [`Reader::define_pattern`]).
|
||||
@@ -435,6 +439,7 @@ impl<'a> Reader<'a> {
|
||||
art_size: None,
|
||||
template_center: None,
|
||||
large_canvas: None,
|
||||
canvas_scale: 1.0,
|
||||
ruler_units: None,
|
||||
pattern: None,
|
||||
pattern_tiles: HashMap::new(),
|
||||
|
||||
@@ -208,21 +208,29 @@ impl<'a> Reader<'a> {
|
||||
self.template_center = two(v);
|
||||
self.template = four(v).or(self.template);
|
||||
} else if let Some(v) = line.strip_prefix("AI24_LargeCanvasScale:") {
|
||||
// Illustrator's Large Canvas writes the editing data (art and boxes) at 1/N scale, N
|
||||
// being this; the printed page applies the same factor, so the art reaches the page's
|
||||
// size once it is applied to the art space ([`Self::ensure_space`]). A value outside
|
||||
// the range Illustrator writes is left alone.
|
||||
if let (Some(scale), Ok(n)) = (&mut self.large_canvas, v.trim().parse::<f64>())
|
||||
&& (1.0..=100.0).contains(&n)
|
||||
{
|
||||
*scale = n;
|
||||
}
|
||||
self.large_canvas_scale(v);
|
||||
} else if let Some(v) = line.strip_prefix("AI9_ColorModel:") {
|
||||
self.color_model = Some(v.trim() == "2");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `%AI24_LargeCanvasScale:` with its value `v`.
|
||||
fn large_canvas_scale(&mut self, v: &str) {
|
||||
// Illustrator's Large Canvas writes the editing data (art and boxes) at 1/N scale, N
|
||||
// being this; the printed page applies the same factor, so the art reaches the page's
|
||||
// size once it is applied to the art space ([`Self::ensure_space`]). A value outside
|
||||
// the range Illustrator writes is left alone.
|
||||
if let Ok(n) = v.trim().parse::<f64>()
|
||||
&& (1.0..=100.0).contains(&n)
|
||||
{
|
||||
self.canvas_scale = n;
|
||||
if let Some(scale) = &mut self.large_canvas {
|
||||
*scale = n;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `%AI3_Note:` (or `%%Note:`) sets the note of the paths that follow, an empty one clearing it,
|
||||
/// and `%AI5_NoteMore:` adds a line to it. The text is kept as written, up to [`MAX_NOTE`]
|
||||
/// characters.
|
||||
|
||||
@@ -723,6 +723,7 @@ impl<'a> Reader<'a> {
|
||||
placed_elsewhere: self.placed_elsewhere,
|
||||
envelopes_drawn: self.envelopes_drawn,
|
||||
styles_read,
|
||||
canvas_scale: self.canvas_scale,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ mod layers;
|
||||
mod plan;
|
||||
mod slots;
|
||||
|
||||
pub(crate) use build::scale_document;
|
||||
#[cfg(test)]
|
||||
use build::{Frame, MADE, prune_unseen};
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -56,7 +56,8 @@ pub(super) fn build(
|
||||
/// [`build`] without a page, for a `.ai` whose PDF part can't be read, its first page `page`
|
||||
/// (width and height, either way round) points large in units of `unit` points: the document is
|
||||
/// drawn `unit` times as large when its first artboard, or its art's box, has the page's size in
|
||||
/// those units and not in points, as [`build_from`] decides with a page.
|
||||
/// those units and not in points, as [`build_from`] decides with a page; else as many times as
|
||||
/// large as its declared large canvas scale when it has the page's size at that scale.
|
||||
pub(super) fn build_on_page(data: &[u8], page: (f64, f64), unit: f64) -> Result<(Document, Vec<String>), String> {
|
||||
let structure = ai::read(data, false)?;
|
||||
let board = structure.artboard.map(|a| structure.to_doc.transform_rect_bbox(a));
|
||||
@@ -65,12 +66,12 @@ pub(super) fn build_on_page(data: &[u8], page: (f64, f64), unit: f64) -> Result<
|
||||
let fits = |r: Option<Rect>, k: f64| {
|
||||
r.is_some_and(|r| [(w, h), (h, w)].iter().any(|(a, b)| (r.width() - a / k).abs() <= 0.5 && (r.height() - b / k).abs() <= 0.5))
|
||||
};
|
||||
let in_units = |r: Option<Rect>| unit.is_finite() && unit > 1.0 && fits(r, unit) && !fits(r, 1.0);
|
||||
let scale = in_units(board) || (!fits(board, 1.0) && in_units(art));
|
||||
let in_units = |r: Option<Rect>, k: f64| k.is_finite() && k > 1.0 && fits(r, k) && !fits(r, 1.0);
|
||||
let scale = [unit, structure.canvas_scale].into_iter().find(|&k| in_units(board, k) || (!fits(board, 1.0) && in_units(art, k)));
|
||||
let (mut doc, notes) = build_from(structure, data, None, Page::Artboard, false, false, 1.0)?;
|
||||
if scale {
|
||||
if let Some(k) = scale {
|
||||
// The graphic styles here are all the file's, drawn in its units too.
|
||||
scale_document(&mut doc, unit, true);
|
||||
scale_document(&mut doc, k, true);
|
||||
}
|
||||
Ok((doc, notes))
|
||||
}
|
||||
@@ -99,16 +100,19 @@ pub(super) fn build_from(
|
||||
placed_elsewhere,
|
||||
envelopes_drawn,
|
||||
styles_read,
|
||||
canvas_scale,
|
||||
warnings,
|
||||
} = structure;
|
||||
// Editing data in the units of the page's user space ([`in_units`]) is compared with the page
|
||||
// scaled down to those units, and the document read from it is scaled up by `unit`.
|
||||
// Editing data in the units of the page's user space ([`in_units`]), or at its declared large
|
||||
// canvas scale ([`page_scale`]), is compared with the page scaled down to those units, and the
|
||||
// document read from it is scaled up by `unit`.
|
||||
let board = artboard.map(|a| to_doc.transform_rect_bbox(a));
|
||||
// A page cropped to the art (see below) is in those units as well, when the art's box is and
|
||||
// the artboard isn't the page in points.
|
||||
let art = bbox.map(|[a, b, c, d]| to_doc.transform_rect_bbox(Rect::new(a, b, c, d)));
|
||||
let units = |v: &Document| in_units(board, v, unit) || (!same_size(board, v) && in_units(art, v, unit));
|
||||
let small = visible.filter(|v| matches!(page, Page::Artboard) && units(v)).map(|v| {
|
||||
let scaled = page_scale(board, art, visible.filter(|_| matches!(page, Page::Artboard)), unit, canvas_scale);
|
||||
let unit = scaled.unwrap_or(unit);
|
||||
let small = visible.filter(|_| scaled.is_some()).map(|v| {
|
||||
let mut v = v.clone();
|
||||
scale_document(&mut v, 1.0 / unit, false);
|
||||
v
|
||||
@@ -1142,6 +1146,23 @@ fn placed_files(nodes: &[Arc<Node>], out: &mut Vec<Rect>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the editing data in units of `unit` points beside `page` ([`in_units`])? Its first artboard
|
||||
/// `board` is, or, when that isn't the page in points, its art's box `art` is (a page cropped to the
|
||||
/// art).
|
||||
fn in_units_or_art(board: Option<Rect>, art: Option<Rect>, page: &Document, unit: f64) -> bool {
|
||||
in_units(board, page, unit) || (!same_size(board, page) && in_units(art, page, unit))
|
||||
}
|
||||
|
||||
/// The size in points of a unit of the editing data whose first artboard is `board` and whose
|
||||
/// art's box is `art` (the document's coordinates), read beside `page`: `unit` (the page's
|
||||
/// `UserUnit`) when the data is in those units ([`in_units_or_art`]), else `canvas_scale` (its
|
||||
/// declared large canvas scale) when the data is at that scale; `None` when it is in points or
|
||||
/// there is no page.
|
||||
fn page_scale(board: Option<Rect>, art: Option<Rect>, page: Option<&Document>, unit: f64, canvas_scale: f64) -> Option<f64> {
|
||||
let page = page?;
|
||||
[unit, canvas_scale].into_iter().find(|&k| in_units_or_art(board, art, page, k))
|
||||
}
|
||||
|
||||
/// Does the editing data's first artboard `board` (in the document read from it: art space through
|
||||
/// `to_doc`) have the size of `page`'s first artboard in units of `unit` points, `unit` being above
|
||||
/// 1, and not its size in points? `page` is a PDF part read in points from pages of `unit` points a
|
||||
@@ -1195,7 +1216,7 @@ fn drop_guides_off_boards(doc: &mut Document) {
|
||||
/// Draw `doc`'s layers, artboards and guides `k` times as large, from the origin, with their
|
||||
/// strokes, effects and pattern tiles, and with `styles` the strokes and effects of its graphic
|
||||
/// styles.
|
||||
fn scale_document(doc: &mut Document, k: f64, styles: bool) {
|
||||
pub(crate) fn scale_document(doc: &mut Document, k: f64, styles: bool) {
|
||||
let a = Affine::scale(k);
|
||||
let scaling = Scaling { strokes: true, effects: Some(vectorcraft_effects::scale_effect), patterns: true, ..Default::default() };
|
||||
for layer in &mut doc.layers {
|
||||
|
||||
@@ -242,9 +242,10 @@ fn place_art(layer: &mut Node, pages: &[Rect], moves: &[Vec2]) {
|
||||
|
||||
/// A `.ai` saved without its PDF part (its pages show only a placeholder) from its editing copy
|
||||
/// alone (`private`, the joined `AIPrivateData` streams) → the document and the import's notes.
|
||||
/// Editing data written on a large canvas comes in at its declared scale: no page holds the size.
|
||||
pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec<String>), String> {
|
||||
let data = ai::decode_private(private)?;
|
||||
build(&data, None, Page::Artboard, false, false, 1.0)
|
||||
build_from(ai::read(&data, true)?, &data, None, Page::Artboard, false, false, 1.0)
|
||||
}
|
||||
|
||||
/// [`ai_alone`] for a `.ai` whose PDF part can't be read, its first page `page` (width and height)
|
||||
|
||||
@@ -65,13 +65,13 @@ use std::sync::Arc;
|
||||
use vectorcraft_color::cms::Cms;
|
||||
use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint};
|
||||
use vectorcraft_doc::{
|
||||
AppearanceItem, AreaOptions, CharPosition, CharStyle, Document, FillLayer, ImageBlob, Justify, LAYER_COLORS, LayerColor, LineCap, LineJoin, Node,
|
||||
NodeKind, ParaDirection, ParaStyle, PathEffect, StrokeLayer, TextKind, TextObject,
|
||||
AppearanceItem, AreaOptions, CharPosition, CharStyle, Document, FillLayer, ImageBlob, Justify, LAYER_COLORS, LayerColor, Node, NodeKind,
|
||||
ParaDirection, ParaStyle, PathEffect, StrokeLayer, TextKind, TextObject,
|
||||
};
|
||||
use vectorcraft_effects::stroke;
|
||||
use vectorcraft_geom::{Affine, AnchorKind, BezPath, FillRule, PathData, Point, Rect, Shape, SubPath, Vec2};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, SubPath, Vec2};
|
||||
|
||||
use crate::{latin, ps};
|
||||
use crate::{ai_ops, latin, ps};
|
||||
|
||||
mod encoding;
|
||||
mod prolog;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::writer::Writer;
|
||||
use super::{
|
||||
Affine, AreaOptions, BezPath, CharPosition, CharStyle, Color, Gradient, GradientKind, Paint, ParaStyle, PathEffect, StrokeLayer, TextKind,
|
||||
TextObject, Write, latin, ps, stroke,
|
||||
Affine, AreaOptions, BezPath, CharPosition, CharStyle, Gradient, GradientKind, Paint, ParaStyle, PathEffect, StrokeLayer, TextKind, TextObject,
|
||||
Write, latin, ps, stroke,
|
||||
};
|
||||
|
||||
/// A gradient definition: `%AI5_BeginGradient: (name)`, `(name) type stops Bd`, `[`, the stops
|
||||
@@ -63,9 +63,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 {
|
||||
@@ -189,10 +187,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))
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@ use super::encoding::{
|
||||
unit, writable,
|
||||
};
|
||||
use super::{
|
||||
Affine, AnchorKind, AppearanceItem, Arc, BezPath, BlendMode, CharStyle, Cms, Color, Document, ELLIPTICAL, FREEFORM, FillLayer, FillRule,
|
||||
Gradient, GradientKind, GradientPaint, HEX_LINE, HIDDEN, HIDDEN_LAYER, HIDDEN_SHOWN, HiddenObjects, IMAGE_ALPHA, ImageBlob, Justify, LARGE_IMAGE,
|
||||
LAYER_COLORS, LAYER_NAME, LIVE, LayerColor, LegacyAiOptions, LineCap, LineJoin, MAX_IMAGE_PIXELS, MAX_NEST, NAMES, Node, NodeKind, PATTERNS,
|
||||
Paint, ParaDirection, ParaStyle, PathData, Point, ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES,
|
||||
TRANSPARENCY, TYPE, TYPE_ATTRIBUTES, TextKind, TextObject, UNDECODED, Vec2, Write, latin, ps, stroke,
|
||||
Affine, AppearanceItem, Arc, BezPath, BlendMode, CharStyle, Cms, Color, Document, ELLIPTICAL, FREEFORM, FillLayer, FillRule, Gradient,
|
||||
GradientKind, GradientPaint, HEX_LINE, HIDDEN, HIDDEN_LAYER, HIDDEN_SHOWN, HiddenObjects, IMAGE_ALPHA, ImageBlob, Justify, LARGE_IMAGE,
|
||||
LAYER_COLORS, LAYER_NAME, LIVE, LayerColor, LegacyAiOptions, MAX_IMAGE_PIXELS, MAX_NEST, NAMES, Node, NodeKind, PATTERNS, Paint, ParaDirection,
|
||||
ParaStyle, PathData, ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE,
|
||||
TYPE_ATTRIBUTES, TextKind, TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke,
|
||||
};
|
||||
|
||||
pub(super) struct Writer<'a> {
|
||||
@@ -363,13 +363,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
|
||||
@@ -528,71 +524,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, ai_alone_on_page, family_style, import, import_trusting, import_with, is_loss, layered_ai, page,
|
||||
};
|
||||
|
||||
@@ -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::{IllustratorData, PageInfo, PdfInfo, has_illustrator_data, illustrator_data, illustrator_data_without_pdf_part, 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 every 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,158 @@
|
||||
//! An editor's private data on every 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.
|
||||
//!
|
||||
//! - Every page dictionary gets `/PieceInfo << /Illustrator n 0 R >>`, the same object `n` on
|
||||
//! each page, and its own `/LastModified` (which §14.5 requires with `PieceInfo`). A file with
|
||||
//! several pages whose pages do not all refer to the one data dictionary opens in other apps as
|
||||
//! a PDF, without the editing data.
|
||||
//! - 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 every 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)?;
|
||||
if pages.is_empty() {
|
||||
return Err(failed("a page can't be read"));
|
||||
}
|
||||
let dicts = pages.iter().map(|&n| xref.dict(&pdf, n).map(|(at, _)| at)).collect::<Option<Vec<usize>>>();
|
||||
let dicts = dicts.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());
|
||||
for dict in dicts {
|
||||
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), with the offset written
|
||||
/// as hours and minutes, as in the files users sent.
|
||||
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}+00'00')")
|
||||
}
|
||||
|
||||
#[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 every_page_refers_to_the_one_data_dictionary() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let mut second = doc.artboards[0].clone();
|
||||
(second.id, second.name, second.rect) = (2, "Back".into(), vectorcraft_geom::Rect::new(240.0, 0.0, 440.0, 100.0));
|
||||
doc.artboards.push(second);
|
||||
let opts = PdfOptions { private_data: Some(data(10)), ..PdfOptions::uncompressed() };
|
||||
let pdf = export(&doc, &opts).unwrap();
|
||||
let Parsed { xref, pages, .. } = linearize::parse(&pdf).unwrap();
|
||||
assert_eq!(pages.len(), 2);
|
||||
let owners: Vec<String> = pages
|
||||
.iter()
|
||||
.map(|&n| {
|
||||
let (at, end) = xref.dict(&pdf, n).unwrap();
|
||||
let page = String::from_utf8_lossy(&pdf[at..end]).into_owned();
|
||||
assert!(page.contains("/LastModified (D:"), "{page}");
|
||||
let i = page.find("/PieceInfo << /Illustrator ").expect("every page carries the data");
|
||||
page[i..].split(">>").next().unwrap().to_string()
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(owners[0], owners[1]);
|
||||
assert_eq!(String::from_utf8_lossy(&pdf).matches("/AIPrivateData1 ").count(), 1, "the data is written once");
|
||||
}
|
||||
|
||||
#[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:19700101000000+00'00')");
|
||||
assert_eq!(pdf_date(1_793_000_000), "(D:20261026073320+00'00')");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -105,6 +105,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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3501,6 +3501,7 @@ digits Malayalam Малаялам
|
||||
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.
|
@@ -3635,6 +3635,7 @@ theme Appearance Izgled
|
||||
Type Object Selection by Path Only Odabir tekstualnog objekta samo putanjom
|
||||
UI Scaling Skaliranje interfejsa
|
||||
Use Compression Koristi kompresiju
|
||||
Include Editing Data for Other Apps Uključi podatke za uređivanje za druge aplikacije
|
||||
Use Japanese Crop Marks Koristi japanske oznake za izrezivanje
|
||||
Use Low Resolution Proxy for Linked EPS Koristi proksi niske rezolucije za povezani EPS
|
||||
Use Precise Cursors Koristi precizne kursore
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4508,6 +4508,7 @@ digits Malayalam Malaiàlam
|
||||
Use Artboards Utilitza els taulers
|
||||
Use Black Point Compensation Utilitza la compensació del punt negre
|
||||
Use Compression Utilitza la compressió
|
||||
Include Editing Data for Other Apps Inclou les dades d’edició per a altres aplicacions
|
||||
Use Default Utilitza el valor per defecte
|
||||
Use document bleed settings Utilitza la configuració de sangrat del document
|
||||
Use Document Raster Effects Resolution Utilitza la resolució dels efectes de ràster del document
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3677,6 +3677,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.
|
@@ -3404,6 +3404,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.
|
@@ -3529,6 +3529,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.
|
@@ -3871,6 +3871,7 @@ digits Malayalam Μαλαγιάλαμ
|
||||
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 163 and column 5.
|
@@ -3585,6 +3585,7 @@ digits Malayalam Malayálam
|
||||
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.
|
@@ -3601,6 +3601,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.
|
@@ -3577,6 +3577,7 @@ digits Malayalam Malayalam
|
||||
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.
|
@@ -4298,6 +4298,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.
|
@@ -3404,6 +3404,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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2219 and column 5.
|
@@ -3622,6 +3622,7 @@ digits Malayalam Malayalam
|
||||
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.
|
@@ -3280,6 +3280,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.
|
@@ -3214,6 +3214,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 is too large.
|
@@ -3269,6 +3269,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.
|
@@ -3418,6 +3418,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.
|
@@ -3540,6 +3540,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.
|
@@ -2947,6 +2947,7 @@ digits Malayalam Malaiala
|
||||
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.
|
@@ -3617,6 +3617,7 @@ digits Malayalam Малаялам
|
||||
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.
|
@@ -3507,6 +3507,7 @@ digits Malayalam Malayalam
|
||||
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.
|
@@ -3397,6 +3397,7 @@ digits Malayalam Malayalam
|
||||
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.
|
@@ -3615,6 +3615,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 177 and column 5.
|
@@ -3990,6 +3990,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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 199 and column 5.
|
@@ -3457,6 +3457,7 @@ digits Malayalam 马拉雅拉姆数字
|
||||
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.
|
@@ -4479,6 +4479,7 @@ digits Malayalam 馬來亞拉姆數字
|
||||
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.
|
@@ -18,7 +18,7 @@ from the public specifications, and users' own `.ai` files are used locally only
|
||||
| | Illustrator | VectorCraft | Notes |
|
||||
|---|---:|---:|---|
|
||||
| Formats opened or placed | ~30 | 29 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, PICT, PS |
|
||||
| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (no Illustrator editing data) or as legacy version 7 (layers and groups, one artboard per file) |
|
||||
| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, artboards) or as legacy version 7 (layers and groups, one artboard per file) |
|
||||
| Formats exported | ~16 | 17 | missing: DWG, 3D (OBJ/USDA/glTF); extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD |
|
||||
| **Overall (weighted by use)** | | **~75%** | `.ai` and PDF dominate the weighting |
|
||||
|
||||
@@ -58,8 +58,13 @@ private editing data. VectorCraft:
|
||||
When the PDF part of either can't be read (a stream the import refuses), the file opens from its editing
|
||||
data alone, with the PDF part's error as its first warning, and without the bleed and File Info the PDF
|
||||
part would give (part of #4366).
|
||||
- **Writes** a PDF-compatible `.ai` (Save As › `ai`): Illustrator opens it as plain art. It never writes
|
||||
Illustrator's private editing data, which is undocumented.
|
||||
- **Writes** a PDF-compatible `.ai` (Save As › `ai`). With the option `editingData` (on by default, Include
|
||||
Editing Data for Other Apps) every page's `PieceInfo` also carries an editing copy of the art in
|
||||
`AIPrivateData` streams, in the form VectorCraft's `.ai` reader reads: layers and sublayers, groups, paths with
|
||||
their fills and strokes (a compound path as one path of all its subpaths), process, spot and global colors, swatches and color groups, guides,
|
||||
hidden and locked objects, object names and every artboard with its name. Type, gradients, patterns, images,
|
||||
symbols, clipping groups and live objects are written as plain filled and stroked paths, each with a warning;
|
||||
transparency and effects other than geometry effects are left out.
|
||||
- **Writes** a legacy `.ai` of version 7 (Save As › `ai` › Version: Legacy (version 7), `aiVersion: "7"`): the
|
||||
PostScript form, from the published *Adobe Illustrator File Format Specification* version 7.0
|
||||
([PRONOM fmt/423](https://www.nationalarchives.gov.uk/PRONOM/fmt/423)), which apps reading `.ai` open with its
|
||||
|
||||
Reference in New Issue
Block a user