From 9275b902a9d441546262289f04e66b2cee80be91 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 4 Nov 2026 04:01:20 -0800 Subject: [PATCH] PDF/.ai import: a file whose type all comes from its text document opens without its PDF part A PDF-compatible .ai (or a PDF saved with the editing data) now opens from its layers without its PDF part being imported, and without being compared with its page, when: - its text document makes the type of every text object, hidden ones included, at least one text object shows, and none is drawn by the appearance of the layer or group it is in; - its layers hold no unread object, no placed file with a second placement and no live envelope with drawn art to fall back on, and no image too large to read; - it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's UserUnit. A file whose layers draw differently from its page then opens from its layers instead of its PDF part. The bleed comes from the pages' bleed boxes. The check runs only when no pages are picked, layers and the editing data are on, the crop box is asked for and type comes in as text. Files saved without PDF compatibility, files whose PDF part draws only type or nothing, and files whose PDF part's content can't be read take the same path when they qualify: their bleed comes from the pages' bleed boxes, pages in units larger than a point draw them at their size in points, and the note that the PDF part couldn't be read is not given, since its content is not read. Files that don't qualify open as before, and the editing data decoded and read for the check is reused after the PDF part is imported (layered_ai_from). When a file whose PDF part can't be read is scaled up to its size in points, its text wraps are now laid out again at that size, and the default graphic styles of a file without graphic styles of its own are no longer scaled. The same applies on the new path. --- crates/engine/src/cmd/fileio/load.rs | 113 ++++++++++------ .../engine/src/cmd/fileio/tests_aiimport.rs | 124 +++++++++++++++++- crates/eps/src/import.rs | 2 +- crates/eps/src/import/native.rs | 6 +- crates/eps/src/import/native/build.rs | 88 ++++++++++++- crates/eps/src/import/native/layers.rs | 67 +++++++++- crates/eps/src/lib.rs | 3 +- crates/pdf/Cargo.toml | 2 +- crates/pdf/src/import.rs | 2 + crates/pdf/src/import/entry.rs | 15 +++ crates/pdf/src/lib.rs | 6 +- crates/pdf/src/pages.rs | 30 +++++ docs/file-format-parity.md | 2 +- 13 files changed, 404 insertions(+), 56 deletions(-) diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index eca365ca1..2b1de2eda 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -165,18 +165,18 @@ pub fn load(name: &str, bytes: &[u8]) -> Result { /// of its art, which it carries: its layers and hidden objects as they were, and the art outside /// its artboard (its PDF part has only what is on the artboards), as an EPS of the editor is read. /// Anything else as it was. `unit`: the size in points of a unit of the PDF part's user space (its -/// first page's `UserUnit`) → (document, warnings, whether its layers were read). `private`: the -/// editor's copy ([`editing_copy`]). +/// first page's `UserUnit`) → (document, warnings, whether its layers were read). `layers`: the +/// editor's copy ([`editing_copy`]), as far as it was decoded and read. fn through_editing_data( - private: Option<&[u8]>, + layers: Option, opts: &LoadOptions, doc: Document, warnings: Vec, unit: f64, ) -> (Document, Vec, bool) { - let Some(private) = private else { return (doc, warnings, false) }; + let Some(layers) = layers else { return (doc, warnings, false) }; let outlined = opts.text_as == vectorcraft_pdf::TextAs::Outlines; - let (doc, mut warnings, read) = vectorcraft_eps::layered_ai(private, doc, warnings, outlined, opts.trust_editing_data, unit); + let (doc, mut warnings, read) = vectorcraft_eps::layered_ai_from(layers, doc, warnings, outlined, opts.trust_editing_data, unit); // The layers weren't read (or couldn't be): the notes of reading the PDF part still hold. if !read { return (doc, warnings, false); @@ -216,6 +216,14 @@ fn reads_editing_copy(opts: &LoadOptions) -> bool { opts.pages.is_none() && opts.layers && opts.editing_data } +/// May a file opened with `opts` open from the editor's own copy of its art without its PDF part +/// being imported ([`vectorcraft_eps::ai_layers_alone`]): it reads the editor's copy, on the pages' +/// trim boxes (the crop box asked for, as a file with the editor's copy opens on them), with its +/// type as text? +fn reads_layers_alone(opts: &LoadOptions) -> bool { + reads_editing_copy(opts) && opts.crop == vectorcraft_pdf::CropTo::Crop && opts.text_as != vectorcraft_pdf::TextAs::Outlines +} + /// The editor's own copy of its art that `opened` carries (as [`vectorcraft_pdf::illustrator_data`] /// reads it), when `opts` read it ([`reads_editing_copy`]). fn editing_copy(opened: &vectorcraft_pdf::Opened, opts: &LoadOptions) -> Option> { @@ -365,41 +373,68 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result Ok(o) => super::pdfimport::import(bytes, o, opts, format.id != "pdf"), Err(e) => Err(err(e)), }; - let read = restore_or_import(editing, || match imported() { - Ok(r) => { - // The editor's copy, read once the import is done with the pages. - let private = opened.as_ref().ok().and_then(|o| editing_copy(o, opts)); - // Pages of text alone: the editing copy alone where it reads, as for a file - // saved without its PDF part (#1387: else the page, which is the art). So too - // pages that draw nothing at all: there is no page art to check the layers - // against, and the empty page would lose all of it. - // A page is empty only when nothing on it was skipped (an image that can't be - // decoded draws nothing either): its notes are at most the page's own. - let empty = draws_nothing(&r.document) && r.warnings.iter().all(|w| is_page_note(w)); - let alone = (r.only_text || empty) - .then(|| private.as_deref().map(editing_copy_alone)) - .flatten() - .and_then(|read| read.ok()) - .map(|(doc, warnings)| outlined_if_asked(doc, warnings, opts)); - let (doc, warnings, read) = match alone { - Some((doc, warnings)) => (doc, warnings, true), - None => through_editing_data(private.as_deref(), opts, r.document, r.warnings, r.user_unit), - }; - editing_data = read; - Ok((doc, warnings)) + let read = restore_or_import(editing, || { + // Type all from the text document and nothing that needs the PDF part: the layers + // alone, the PDF part not imported. Else what was read of the layers is kept for + // reading them with the PDF part. + let mut layers = None; + if let Some((o, private)) = opened.as_ref().ok().filter(|_| reads_layers_alone(opts)).and_then(|o| Some((o, editing_copy(o, opts)?))) + { + let pages = o.trim_pages(); + match vectorcraft_eps::ai_layers_alone(&private, &pages.sizes, pages.user_unit) { + Ok((mut doc, warnings)) => { + doc.setup.bleed = pages.bleed; + editing_data = true; + return Ok((doc, warnings)); + } + Err(read) => layers = Some(read), + } + } + match imported() { + Ok(r) => { + // The editor's copy, read once the import is done with the pages, unless + // its layers were read before it. + let private = layers.is_none().then(|| opened.as_ref().ok().and_then(|o| editing_copy(o, opts))).flatten(); + // Pages of text alone: the editing copy alone where it reads, as for a file + // saved without its PDF part (#1387: else the page, which is the art). So too + // pages that draw nothing at all: there is no page art to check the layers + // against, and the empty page would lose all of it. + // A page is empty only when nothing on it was skipped (an image that can't be + // decoded draws nothing either): its notes are at most the page's own. + let empty = draws_nothing(&r.document) && r.warnings.iter().all(|w| is_page_note(w)); + let copy_alone = || match &private { + Some(private) => Some(editing_copy_alone(private)), + None if layers.is_some() => opened.as_ref().ok().and_then(|o| editing_copy(o, opts)).map(|p| editing_copy_alone(&p)), + None => None, + }; + let alone = (r.only_text || empty) + .then(copy_alone) + .flatten() + .and_then(|read| read.ok()) + .map(|(doc, warnings)| outlined_if_asked(doc, warnings, opts)); + let (doc, warnings, read) = match alone { + Some((doc, warnings)) => (doc, warnings, true), + None => { + let layers = layers.or_else(|| private.as_deref().map(vectorcraft_eps::AiLayers::decode)); + through_editing_data(layers, opts, r.document, r.warnings, r.user_unit) + } + }; + editing_data = read; + Ok((doc, warnings)) + } + // Saved without its PDF part: the editing copy is all the file has (`pages` that + // name every page read it as the whole file). + Err(e) if is_placeholder(&e) => opened + .as_ref() + .ok() + .and_then(|o| from_editing_data_alone(o, opts)) + .unwrap_or(Err(e)) + .map(|(doc, warnings)| outlined_if_asked(doc, warnings, opts)) + .inspect(|_| editing_data = true), + // A PDF part that can't be read (a stream the import refuses): the editing copy + // alone where it reads, else the error. + Err(e) => without_pdf_part(bytes, opts, &e).inspect(|_| editing_data = true).ok_or(e), } - // Saved without its PDF part: the editing copy is all the file has (`pages` that - // name every page read it as the whole file). - Err(e) if is_placeholder(&e) => opened - .as_ref() - .ok() - .and_then(|o| from_editing_data_alone(o, opts)) - .unwrap_or(Err(e)) - .map(|(doc, warnings)| outlined_if_asked(doc, warnings, opts)) - .inspect(|_| editing_data = true), - // A PDF part that can't be read (a stream the import refuses): the editing copy - // alone where it reads, else the error. - Err(e) => without_pdf_part(bytes, opts, &e).inspect(|_| editing_data = true).ok_or(e), })?; // The File Info of a file read from its pages or its editing copy. if !read.2 { diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index 16049850c..0f97a5862 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -1221,10 +1221,16 @@ fn half_outlined_line(y: f64, rest: &str, mode: u8) -> String { /// text document (a line starting at (14, 65)), then `more` of a story the text document doesn't /// have. Without `story` the file has no text document. fn half_outlined_ai(story: Option<&str>, more: usize, type_content: &str) -> Vec { + half_outlined_ai_on(story, more, type_content, "", |page| page) +} + +/// [`half_outlined_ai`] with the editing data's comments `head` before its art, whose page is +/// `page_of` the 200 × 100 pt page it draws on (other boxes, more entries). +fn half_outlined_ai_on(story: Option<&str>, more: usize, type_content: &str, head: &str, page_of: impl FnOnce(PdfPage) -> PdfPage) -> Vec { let text = |story: u32| format!("/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n{story} /StoryIndex ,\n;\n"); let texts: String = std::iter::once(text(0)).chain((0..more).map(|_| text(99))).collect(); let mut body = - format!("%AI3_TemplateBox: 100 50 100 50\n{}", ai::layer("Art", &format!("0.5 g\n{}f\n{texts}", ai::rect(120.0, 20.0, 160.0, 60.0)))); + format!("{head}%AI3_TemplateBox: 100 50 100 50\n{}", ai::layer("Art", &format!("0.5 g\n{}f\n{texts}", ai::rect(120.0, 20.0, 160.0, 60.0)))); if let Some(story) = story { // The story's one line starts at the canvas center (8191.5, the template box's center) moved // to (14, 65): its y runs down the page. @@ -1255,7 +1261,7 @@ fn half_outlined_ai(story: Option<&str>, more: usize, type_content: &str) -> Vec }; let cmap = "begincmap\n1 begincodespacerange\n<00> \nendcodespacerange\n1 beginbfchar\n<41> \nendbfchar\nendcmap\n"; pdf_with( - &[page], + &[page_of(page)], &[&format!("<< /Length {} >>\nstream\n{data}\nendstream", data.len()), &format!("<< /Length {} >>\nstream\n{cmap}endstream", cmap.len())], None, ) @@ -1287,6 +1293,12 @@ fn texts(s: &Session) -> Vec { /// (art x 10 to 90 and 110 to 190, y 10 to 90), then a band across the first frame (y 30 to 70), /// made a wrap object with no offset when `wrap` is set. fn wrapped_thread_ai(wrap: bool) -> Vec { + ai::ai(&wrapped_thread_data(wrap.then_some(0.0)), PLACEHOLDER) +} + +/// The editing data of [`wrapped_thread_ai`], compressed, with the band a wrap object of `offset` +/// when one is given. +fn wrapped_thread_data(offset: Option) -> Vec { // The template box is centred at (100, 50), the canvas centre 8191.5: y runs down the canvas. let canvas = |x: f64, y: f64| (x + 8091.5, 8241.5 - y); let rect = |x0: f64, y0: f64, x1: f64, y1: f64| { @@ -1306,14 +1318,47 @@ fn wrapped_thread_ai(wrap: bool) -> Vec { ); let lines: Vec = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect(); let frame = |f: u32| format!("/AI11Text :\n0 /FreeUndo ,\n{f} /FrameIndex ,\n0 /StoryIndex ,\n;\n"); - let dict = if wrap { "%_/ArtDictionary :\n%_0 /Real (AI11TextWrapOffset) ,\n%_0 /Bool (AI11TextWrapInvert) ,\n%_;\n%_\n" } else { "" }; + let dict = offset + .map(|o| format!("%_/ArtDictionary :\n%_{o} /Real (AI11TextWrapOffset) ,\n%_0 /Bool (AI11TextWrapInvert) ,\n%_;\n%_\n")) + .unwrap_or_default(); let art = format!("{}{}0.5 g\n{}f\n{dict}", frame(0), frame(1), ai::rect(10.0, 30.0, 90.0, 70.0)); let body = format!( "%AI3_TemplateBox: 100 50 100 50\n{}%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n", ai::layer("Art", &art), lines.join("\n") ); - ai::ai(&compressed(ai::editing_data(200.0, 100.0, &body).as_bytes()), PLACEHOLDER) + compressed(ai::editing_data(200.0, 100.0, &body).as_bytes()) +} + +/// The wrap offset of the first frame of `doc`'s threaded type, as its text resolves it. +fn first_frame_wrap_offset(doc: &vectorcraft_doc::Document) -> f64 { + match &doc.node(doc.text_threads[0][0]).unwrap().kind { + NodeKind::Text(t) => t.wrap[0].wrap.offset, + _ => panic!("area type"), + } +} + +/// A `.ai` in units of 10 points whose threaded area type flows around a wrap object of a 2 unit +/// offset opens with the type flowing round it 20 pt off, its PDF part read or not, and when its +/// PDF part can't be read. The default graphic styles of a file without its own aren't scaled. +#[test] +fn wrapped_type_in_units_of_ten_points_flows_round_the_wrap_at_its_size_in_points() { + let private = wrapped_thread_data(Some(2.0)); + let page = "0.5 g 10 30 80 40 re f"; + let refused = ai::ai_with_entries_and_content(&private, page, "/UserUnit 10", REFUSED_CONTENT); + // On the trim boxes the PDF part is imported, and the refused stream stops it. + let trim = LoadOptions { crop: vectorcraft_pdf::CropTo::Trim, ..Default::default() }; + let files = [(ai::ai_with(&private, page, "/UserUnit 10"), LoadOptions::default()), (refused, trim)]; + for (file, opts) in &files { + let loaded = load_with("thread.ai", file, opts).unwrap(); + assert!(loaded.editing_data, "{:?}", loaded.warnings); + assert!((first_frame_wrap_offset(&loaded.doc) - 20.0).abs() < 0.01, "{:?}", loaded.warnings); + } + let loaded = load_with("thread.ai", &files[1].0, &files[1].1).unwrap(); + assert!(loaded.warnings.iter().any(|w| w.starts_with("the PDF part couldn't be read")), "{:?}", loaded.warnings); + // The file has no graphic styles of its own: the default ones aren't scaled either way. + let read = load_with("thread.ai", &files[0].0, &files[0].1).unwrap(); + assert_eq!(loaded.doc.graphic_styles, read.doc.graphic_styles); } /// Threaded area type below a wrap object flows around it when the file opens: the text the wrap @@ -1358,6 +1403,77 @@ fn type_from_the_text_document_opens_without_the_pdf_parts_note_on_outlines() { assert!(!r["warnings"].as_array().unwrap().contains(&without_outlines_note(1)), "{r}"); } +/// A `.ai` whose text document makes all of its type, with nothing unread, opens from its layers +/// without its PDF part being imported: a page that draws more than the layers isn't compared with +/// them. On its trim box it takes the pages' bleed, and pages in units of 10 points draw it ten +/// times as large, as when its PDF part is imported. With type the text document can't make, +/// editing data on a large canvas, its type asked for as outlines, another box or pages picked, the +/// PDF part is imported as before. +#[test] +fn type_all_from_the_text_document_opens_without_importing_the_pdf_part() { + let line = half_outlined_line(65.0, "rst draft", 0); + // The page draws a red square the layers don't have. + let differs = half_outlined_ai(Some("first draft"), 0, &format!("{line} 1 0 0 rg 20 20 30 30 re f")); + let mut s = Session::new(); + let before = vectorcraft_pdf::imports_of(&differs); + let loaded = load_with("draft.ai", &differs, &LoadOptions::default()).unwrap(); + assert_eq!(vectorcraft_pdf::imports_of(&differs), before, "{:?}", loaded.warnings); + assert!(loaded.editing_data, "{:?}", loaded.warnings); + assert!(!loaded.warnings.iter().any(|w| w.contains("differently")), "{:?}", loaded.warnings); + let r = open(&mut s, "draft.ai", &differs, json!({})).unwrap(); + assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}"); + assert_eq!(texts(&s), ["first draft"], "{r}"); + assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 2, "the square and the type, without the page's red square: {r}"); + // A 10 pt bleed round the trim box, the art drawn in the trim box. + let bled = half_outlined_ai_on(Some("first draft"), 0, &line, "", |p| PdfPage { + media: [0.0, 0.0, 220.0, 120.0], + bleed: Some([0.0, 0.0, 220.0, 120.0]), + trim: Some([10.0, 10.0, 210.0, 110.0]), + content: format!("1 0 0 1 10 10 cm {}", p.content), + ..p + }); + let before = vectorcraft_pdf::imports_of(&bled); + let loaded = load_with("bled.ai", &bled, &LoadOptions::default()).unwrap(); + assert_eq!(vectorcraft_pdf::imports_of(&bled), before); + assert_eq!(loaded.doc.setup.bleed, [10.0; 4]); + // Pages of 10 pt units. + let large = half_outlined_ai_on(Some("first draft"), 0, &line, "", |p| PdfPage { entries: format!("{} /UserUnit 10", p.entries), ..p }); + let before = vectorcraft_pdf::imports_of(&large); + let loaded = load_with("large.ai", &large, &LoadOptions::default()).unwrap(); + assert_eq!(vectorcraft_pdf::imports_of(&large), before); + let board = loaded.doc.artboards[0].rect; + assert!((board.width() - 2000.0).abs() < 0.01 && (board.height() - 1000.0).abs() < 0.01, "{board:?}"); + // Opened on the trim boxes with the PDF part imported and compared, as before, both files have + // the same artboards and bleed. + for file in [&bled, &large] { + let alone = load_with("draft.ai", file, &LoadOptions::default()).unwrap(); + let compared = load_with("draft.ai", file, &LoadOptions { crop: vectorcraft_pdf::CropTo::Trim, ..Default::default() }).unwrap(); + assert!(compared.editing_data, "{:?}", compared.warnings); + let boards = |d: &vectorcraft_doc::Document| d.artboards.iter().map(|a| a.rect).collect::>(); + assert_eq!(boards(&alone.doc), boards(&compared.doc)); + assert_eq!(alone.doc.setup.bleed, compared.doc.setup.bleed); + assert_eq!(names(&alone.doc.layers), names(&compared.doc.layers)); + // The file has no graphic styles of its own: the default ones aren't scaled. + assert_eq!(alone.doc.graphic_styles, compared.doc.graphic_styles); + } + // The PDF part is imported, once. + let unmade = half_outlined_ai(Some("first draft"), 1, &line); + // Editing data on a large canvas reads at its own scale when the file has no PDF part. + let canvas = half_outlined_ai_on(Some("first draft"), 0, &line, "%AI24_LargeCanvasScale: 2\n", |p| p); + let cases = [ + (&unmade, LoadOptions::default()), + (&canvas, LoadOptions::default()), + (&differs, LoadOptions { text_as: vectorcraft_pdf::TextAs::Outlines, ..Default::default() }), + (&differs, LoadOptions { crop: vectorcraft_pdf::CropTo::Trim, ..Default::default() }), + (&differs, LoadOptions { pages: Some("1".into()), ..Default::default() }), + ]; + for (file, opts) in cases { + let before = vectorcraft_pdf::imports_of(file); + load_with("draft.ai", file, &opts).unwrap(); + assert_eq!(vectorcraft_pdf::imports_of(file) - before, 1, "{opts:?}"); + } +} + /// A text object that takes the page's type lacks the glyphs next to it that the page kept as /// outlines: a note counts such objects, in place of the PDF part's notes. Stroked glyphs that /// come in with the stroked type keep the PDF part's notes. diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index f12281271..aefb80ccb 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -71,7 +71,7 @@ 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 native::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, is_loss, layered_ai, layered_ai_from}; pub use text::family_style; /// The page of a PostScript file without a bounding box (US Letter). diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index 354196555..ed1b97616 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -23,7 +23,9 @@ //! before; on a layer that doesn't show it is left out of that layer, with a warning. The page is //! also the safety net: when the layers' art looks different from it (one draws an object the //! other doesn't, or much of it differs; an EPS whose page is its art's box has nothing printed -//! outside it), the page is used. +//! outside it), the page is used. A `.ai` whose text document makes all of its type, with no +//! object unread and nothing else that the page would decide, opens from its layers without its +//! PDF part being imported, so nothing is compared ([`ai_layers_alone`]). //! //! Where a file doesn't match what this module expects, it is left alone and its page is imported. @@ -52,7 +54,7 @@ use build::{Frame, MADE, prune_unseen}; use compare::{ MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, PageView, Shares, compare, compared, comparison_render, comparison_samples, differ, differ_in_colour, }; -pub use layers::{ai_alone, ai_alone_on_page, layered_ai}; +pub use layers::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, layered_ai, layered_ai_from}; pub(super) use layers::{layered, program_alone}; pub use slots::is_loss; #[cfg(test)] diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index 31be3af5a..1498c730a 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -76,14 +76,98 @@ pub(super) fn build_on_page(data: &[u8], page: (f64, f64), unit: f64) -> Result< }; let in_units = |r: Option, 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 styles = structure.styles_read; let (mut doc, notes) = build_from(structure, data, None, Page::Artboard, false, false, 1.0)?; if let Some(k) = scale { - // The graphic styles here are all the file's, drawn in its units too. - scale_document(&mut doc, k, true); + // The graphic styles read from the file are drawn in its units too. + scale_document(&mut doc, k, styles); + relayout_wrapped(&mut doc); } Ok((doc, notes)) } +/// A document read from the editing copy and its notes, or why it can't be read. +pub(super) type Built = Result<(Document, Vec), String>; + +/// [`build`] of a `.ai` without importing its PDF part, whose pages (each its trim box in points, +/// width and height) are `pages` and are in units of `unit` points: the document read from +/// `structure` (the editing copy `data` read) with nothing compared with the pages, when the file's +/// text document makes the type of every text object (at least one shows, and none is type that a +/// look draws), no object is unread or has another placement or drawn art that the page would +/// choose, and its artboards are the pages, one for one and of their size, in points or in the pages' +/// units ([`page_scale_alone`]). The document is drawn at that scale. Else `Err(structure)`, for +/// [`build_from`] with the page. +pub(super) fn build_without_page(structure: Structure, data: &[u8], pages: &[(f64, f64)], unit: f64) -> Result> { + let Some(scale) = page_scale_alone(&structure, pages, unit).filter(|_| nothing_waits_on_page(&structure) && type_all_made(&structure, data)) + else { + return Err(Box::new(structure)); + }; + let styles = structure.styles_read; + let built = build_from(structure, data, None, Page::Artboard, false, false, 1.0).map(|(mut doc, notes)| { + if scale != 1.0 { + // The graphic styles read from the file are drawn in its units too. + scale_document(&mut doc, scale, styles); + relayout_wrapped(&mut doc); + } + (doc, notes) + }); + Ok(built) +} + +/// Is nothing in `structure` waiting on the page: no object unread (the page's art takes its +/// place), no placed file with another placement, no live envelope with the drawn art it replaced +/// (the comparison with the page chooses between them)? +fn nothing_waits_on_page(structure: &Structure) -> bool { + structure.unread.is_empty() + && structure.unread_elsewhere.is_empty() + && structure.placed_elsewhere.is_empty() + && structure.envelopes_drawn.is_empty() +} + +/// Does the text document of `data` (read as `structure`) make the type of every text object, those +/// that don't show included, with at least one that shows, and none of them type that a look draws? +fn type_all_made(structure: &Structure, data: &[u8]) -> bool { + let mut infos = vec![]; + collect_slots(&structure.doc.layers, true, &mut infos); + if !infos.iter().any(|i| i.shown) || infos.iter().any(|i| i.look.is_some()) { + return false; + } + let frames = ai::text_frames(data); + for info in &mut infos { + info.art_path = info.story.and_then(|s| frames.get(&(s, info.frame))).map(|p| PathData::from_bezpath(&(structure.to_doc * p.clone()))); + } + let Some(texts) = Texts::read(data) else { return false }; + let mut read: std::collections::BTreeMap>> = Default::default(); + let stories: Vec>> = infos + .iter() + .map(|i| { + let n = i.story?; + read.entry(n).or_insert_with(|| texts.story(n as usize).map(Arc::new)).clone() + }) + .collect(); + // A slot without its story is made from nothing; past the text `build_from` reads, nothing is + // made either: it refuses the file. + if stories.iter().any(Option::is_none) || stories.iter().flatten().map(|s| s.len()).sum::() > MAX_TEXT { + return false; + } + let template = structure.template.map(|t| ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0)); + make_slots(&infos, &stories, template, structure.to_doc, &mut vec![]).iter().all(Option::is_some) +} + +/// The scale at which the artboards of `structure` are the pages `pages` (each its width and height +/// in points), one for one and in order, each within half a point: 1 when they are in points, else +/// `unit` (the pages' `UserUnit`) when they are in those units, as [`page_scale`] finds it with the +/// page. `None` when they aren't the pages, and for a file that declares a large canvas scale (a +/// `.ai` saved without its PDF part reads one at that scale, [`super::ai_alone`]). +fn page_scale_alone(structure: &Structure, pages: &[(f64, f64)], unit: f64) -> Option { + let boards = &structure.doc.artboards; + if boards.is_empty() || boards.len() != pages.len() || structure.canvas_scale != 1.0 { + return None; + } + let fits = |k: f64| boards.iter().zip(pages).all(|(b, (w, h))| (b.rect.width() - w / k).abs() <= 0.5 && (b.rect.height() - h / k).abs() <= 0.5); + [1.0, unit].into_iter().find(|&k| k.is_finite() && k >= 1.0 && fits(k)) +} + /// [`build`] from `structure`, the editing copy `data` already read. pub(super) fn build_from( structure: Structure, diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index bb88aa124..26cca96c0 100644 --- a/crates/eps/src/import/native/layers.rs +++ b/crates/eps/src/import/native/layers.rs @@ -1,4 +1,4 @@ -use super::build::{PAGE_MOVE, Page, build, build_from, build_on_page}; +use super::build::{PAGE_MOVE, Page, build, build_from, build_on_page, build_without_page}; use super::slots::LAYERS_UNREAD; use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect, Vec2, ai}; @@ -110,11 +110,24 @@ fn layer_tree(nodes: &[Arc]) -> Vec> { /// units comes in `unit` times as large, and so does the artboard layout `place_pages` takes from /// it. pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec, outlined: bool, trust: bool, unit: f64) -> (Document, Vec, bool) { + layered_ai_from(AiLayers::decode(private), visible, warnings, outlined, trust, unit) +} + +/// [`layered_ai`] of the editing copy `layers`, as far as it was already decoded and read. +pub fn layered_ai_from( + layers: AiLayers, + visible: Document, + warnings: Vec, + outlined: bool, + trust: bool, + unit: f64, +) -> (Document, Vec, bool) { let unread = |mut all: Vec, why: &str| { all.push(format!("{LAYERS_UNREAD} from its editing data ({why}): they come from its PDF part")); all }; - let data = match ai::decode_private(private) { + let AiLayers { data, structure } = layers; + let data = match data { Ok(data) => data, Err(why) => return (visible, unread(warnings, &why), false), }; @@ -137,7 +150,10 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec, outl use_variables(&data, &mut visible, &mut all); (visible, all, false) }; - match build(&data, Some(&visible), Page::Artboard, outlined, trust, unit) { + let built = structure + .unwrap_or_else(|| ai::read(&data, false).map(Box::new)) + .and_then(|structure| build_from(*structure, &data, Some(&visible), Page::Artboard, outlined, trust, unit)); + match built { // An image too large for the editing data's reader would be missing from the layers, while // the PDF part has it: the file comes in as its PDF part instead (#1479). Ok((_, notes)) if notes.iter().any(|n| n == ai::IMAGE_TOO_LARGE) => { @@ -153,6 +169,51 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec, outl } } +/// The `.ai` whose editing copy is `private` (as [`layered_ai`] takes it) from its layers alone, +/// without its PDF part, whose pages (each its trim box in points, width and height) are `pages` +/// and are in units of `unit` points (its first page's `UserUnit`): the document and the notes of +/// reading it, when the file's text document makes all of its type and nothing in its layers needs +/// the PDF part (see `build_without_page`). Nothing is compared with the PDF part. Else, as the +/// error, what was decoded and read of the editing copy, for [`layered_ai_from`] once the PDF part +/// is imported. +pub fn ai_layers_alone(private: &[u8], pages: &[(f64, f64)], unit: f64) -> Result<(Document, Vec), AiLayers> { + let data = match ai::decode_private(private) { + Ok(data) => data, + Err(why) => return Err(AiLayers { data: Err(why), structure: None }), + }; + if !has_layers(&data) { + return Err(AiLayers { data: Ok(data), structure: None }); + } + let structure = match ai::read(&data, false) { + Ok(structure) => structure, + Err(why) => return Err(AiLayers { data: Ok(data), structure: Some(Err(why)) }), + }; + match build_without_page(structure, &data, pages, unit) { + // An image too large for the editing data's reader is missing from the layers: the PDF + // part has it (as in `layered_ai`). + Ok(Ok((doc, notes))) if !notes.iter().any(|n| n == ai::IMAGE_TOO_LARGE) => Ok((doc, notes)), + Ok(_) => Err(AiLayers { data: Ok(data), structure: None }), + Err(structure) => Err(AiLayers { data: Ok(data), structure: Some(Ok(structure)) }), + } +} + +/// A `.ai`'s editing copy decoded, and its layers read when they were, for [`layered_ai_from`]: +/// what [`ai_layers_alone`] read isn't decoded or read again. +pub struct AiLayers { + /// The editing copy decoded, or why it can't be. + data: Result, String>, + /// Its layers read with [`ai::read`] as [`layered_ai`] reads them, or why they can't be; `None` + /// when they are still to be read. + structure: Option, String>>, +} + +impl AiLayers { + /// The editing copy `private` (as [`layered_ai`] takes it), decoded. + pub fn decode(private: &[u8]) -> Self { + Self { data: ai::decode_private(private), structure: None } + } +} + /// `doc` (a file read as its page or PDF part, in place of its layers) with the variables, data /// sets, active data set and lock of the variable library of its editing data `data`, as [`build`] /// gives a document read from the layers, and the note about what of the library wasn't read diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index 8fe04326c..51d244752 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -44,7 +44,8 @@ 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, + AiLayers, Imported, POSTSCRIPT_ERRORS, ai_alone, ai_alone_on_page, ai_layers_alone, family_style, import, import_trusting, import_with, is_loss, + layered_ai, layered_ai_from, page, }; pub use legacy_ai::{HiddenObjects, LegacyAiOptions, LegacyAiOutput, export_legacy_ai}; pub use print::{PrintJob, PrintPage, print}; diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 6bd298a07..66ff8b6e3 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -9,7 +9,7 @@ description = "VectorCraft PDF export and import" workspace = true [features] -# `vectorcraft_pdf::reads_of` for other crates' tests (dev-dependencies only). +# `vectorcraft_pdf::reads_of` and `imports_of` for other crates' tests (dev-dependencies only). read-count = [] [dependencies] diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index ff04da781..6423578af 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -38,6 +38,8 @@ mod state; #[cfg(test)] pub(crate) use cid::{CidText, font_chars, identify_glyph, unify_radical}; +#[cfg(any(test, feature = "read-count"))] +pub use entry::imports_of; pub use entry::{ INVISIBLE_TEXT_NOTE, OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, holds_outlines, import, import_opened, import_with_report, is_glyph_outlines, is_outlines_note, diff --git a/crates/pdf/src/import/entry.rs b/crates/pdf/src/import/entry.rs index 3f521c026..687fcb156 100644 --- a/crates/pdf/src/import/entry.rs +++ b/crates/pdf/src/import/entry.rs @@ -221,6 +221,16 @@ fn read_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_p read_opened(bytes, opts, reader, &original) } +/// How many times each buffer's pages have been imported, by its address (tests). +#[cfg(any(test, feature = "read-count"))] +static IMPORTS: std::sync::Mutex> = std::sync::Mutex::new(std::collections::BTreeMap::new()); + +/// How many times the pages of `bytes`, at its address, have been imported on any thread (tests). +#[cfg(any(test, feature = "read-count"))] +pub fn imports_of(bytes: &[u8]) -> usize { + IMPORTS.lock().ok().and_then(|i| i.get(&(bytes.as_ptr() as usize)).copied()).unwrap_or(0) +} + /// [`read_with_reader_options`] of `original`, the PDF `bytes` read with `reader`. fn read_opened( bytes: &[u8], @@ -228,6 +238,11 @@ fn read_opened( reader: &craft_pdf::OpenOptions, original: &craft_pdf::syntax::Pdf, ) -> Result { + #[cfg(any(test, feature = "read-count"))] + if let Ok(mut imports) = IMPORTS.lock() { + let n = imports.entry(bytes.as_ptr() as usize).or_default(); + *n = n.saturating_add(1); + } let picked = crate::pages::picked(opts, original.pages().len())?; let mut ocgs = Ocgs::read(original); let route = opts.layers && !ocgs.list.is_empty(); diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index d39fc0b95..ca7e44ae7 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -76,6 +76,8 @@ pub use editing::{EDITING_FILE, Editing, LEGACY_EDITING_FILE, editing, editing_w pub use encrypt::Encryption; pub use export::{export, export_with_report, page_areas}; pub use file_info::{FileInfo, file_info}; +#[cfg(any(test, feature = "read-count"))] +pub use import::imports_of; pub use import::{ INVISIBLE_TEXT_NOTE, OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, holds_outlines, import, import_opened, import_with_report, is_glyph_outlines, is_outlines_note, @@ -83,8 +85,8 @@ pub use import::{ #[cfg(any(test, feature = "read-count"))] pub use pages::reads_of; pub use pages::{ - IllustratorData, Opened, PageInfo, PdfInfo, has_illustrator_data, illustrator_data, illustrator_data_without_pdf_part, info, is_postscript, - open_pdf, + IllustratorData, Opened, PageInfo, PdfInfo, TrimPages, has_illustrator_data, illustrator_data, illustrator_data_without_pdf_part, info, + is_postscript, open_pdf, }; pub use piece_info::{PRIVATE_CHUNK, PrivateData}; pub use post::{THUMBNAIL_SIZE, Thumbnail}; diff --git a/crates/pdf/src/pages.rs b/crates/pdf/src/pages.rs index b5cee4b1b..013d09f22 100644 --- a/crates/pdf/src/pages.rs +++ b/crates/pdf/src/pages.rs @@ -280,6 +280,36 @@ impl Opened { pub fn page_count(&self) -> usize { self.pdf.pages().len() } + + /// The pages' trim boxes and bleed, as an import of every page on its trim box takes them, + /// without importing any page's art. + pub fn trim_pages(&self) -> TrimPages { + let pages = self.pdf.pages(); + let mut bleed = [0.0; 4]; + for page in pages.iter() { + for (side, b) in bleed.iter_mut().zip(self::bleed(page)) { + *side = f64::max(*side, b); + } + } + TrimPages { + sizes: pages.iter().map(|page| frame(page, CropTo::Trim).1).map(|r| (r.width(), r.height())).collect(), + user_unit: pages.first().map_or(1.0, |page| f64::from(page.user_unit())), + bleed, + } + } +} + +/// A PDF's pages as an import on their trim boxes sizes them ([`Opened::trim_pages`]). +#[derive(Clone, Debug, PartialEq)] +pub struct TrimPages { + /// Each page's trim box (its crop box when it has none) in points, rotated as the page shows: + /// width and height. + pub sizes: Vec<(f64, f64)>, + /// The size in points of a unit of the first page's user space (its `UserUnit`). + pub user_unit: f64, + /// The bleed of the pages, `[top, bottom, left, right]` in points: on each side, the widest + /// of the pages' bleed, as the import makes it the document's bleed. + pub bleed: [f64; 4], } /// Preserve the application's existing accepted file sizes. Ordinary stream decoding still diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 65e6de0bf..c9039ea06 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -118,7 +118,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke or Convert to Shape comes in as itself with them and their saved settings; one with more, other effects, an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), a brush or dashes aligned to corners comes in as its drawn look (a group named after it). The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke or Convert to Shape comes in as itself with them and their saved settings; one with more, other effects, an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), a brush or dashes aligned to corners comes in as its drawn look (a group named after it). The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. ## Format by format