Read Illustrator EPS and .ai layers from their editing data

An Illustrator EPS is a flattened page plus, after %%EOF, an editing copy of
the document; a PDF-compatible .ai carries the same in its private data.
Importing only the page lost layer names, order, visibility, hidden layers
and hidden objects and hidden text.

- eps: read the editing copy (Zstandard %AI24_DataStream, older zlib
  %AI9_DataStream) into layers, sublayers, groups, compound paths, clip
  groups and hidden objects, run by the existing PostScript interpreter.
  Falls back to the page, with a warning, when a shown layer uses an
  operator that isn't read or the art differs from the page by over 5%.
- eps: read the text document (ate.rs) to place type on hidden layers and
  objects; the page's type is matched to the file's text objects by
  position (pieces of a line included) and kept with the nearest one when
  none claims it. Area type and type on a path are left out with a note.
- pdf/engine: .ai files go through the same reader.
- engine/cli: a file whose import left things out isn't overwritten unless
  asked (acknowledgeLoss, --replace-lossy).
- eps: font names re-encoded with a *<n> suffix resolve to their family.
- i18n: es/it/ru for the new import messages; ROADMAP updated.

Container and text-document formats were inferred from user-owned files; the
Adobe file format specification was not read.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Matthew Farrow
2026-10-09 03:49:49 -04:00
co-authored by Claude Sonnet 5.5
parent f149f10059
commit ec43ac5a1e
28 changed files with 2526 additions and 25 deletions
+3
View File
@@ -45,6 +45,9 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path");
if let Some(path) = path {
super::check_not_lossy_overwrite(s.doc()?, path, p, "document.export")?;
}
let f = writable("document.export", str_param(p, "format"), path)?;
let expanded = expand_presets(s, "document.export", p)?;
let p = &*expanded;
+59 -1
View File
@@ -133,6 +133,23 @@ pub fn load(name: &str, bytes: &[u8]) -> Result<Loaded> {
load_with(name, bytes, &LoadOptions::default())
}
/// A PDF-compatible `.ai` read through the editor's own copy 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.
fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warnings: Vec<String>) -> (Document, Vec<String>) {
if opts.pages.is_some() || !opts.layers {
return (doc, warnings);
}
let Some(private) = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref()) else { return (doc, warnings) };
let (doc, mut warnings) = vectorcraft_eps::layered_ai(&private, doc, warnings);
// The note that the art outside the artboard is lost is the reason the layers weren't read.
if warnings.iter().any(|w| w.starts_with("the file's layers weren't read from its editing data")) {
return (doc, warnings);
}
warnings.retain(|w| w != vectorcraft_pdf::OFF_ARTBOARD_NOTE);
(doc, warnings)
}
/// [`load`] with `document.open` options (the PDF pages, box and password; the DXF options).
pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> {
let format = detect(name, bytes).ok_or_else(|| match super::unsupported(&super::extension(name)) {
@@ -171,7 +188,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
.then(|| vectorcraft_pdf::editing_with(bytes, opts.password.as_deref()))
.flatten()
.map(|e| (e.intact, move || Some(e.data)));
restore_or_import(editing, || super::pdfimport::import(bytes, opts))?
restore_or_import(editing, || {
let (doc, warnings) = super::pdfimport::import(bytes, opts)?;
Ok(through_editing_data(bytes, opts, doc, warnings))
})?
}
"dxf" => {
let (doc, warnings) = super::dxfimport::import(bytes, &opts.dxf)?;
@@ -244,6 +264,9 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st
/// Make a loaded file the new active document; `opts` are the options it was read with.
fn open_loaded(s: &mut Session, loaded: Loaded, path: Option<String>, opts: &LoadOptions, as_template: bool) -> Result<Value> {
let Loaded { mut doc, format, warnings, restored, converted, .. } = loaded;
// What reading the file left out of the document, if anything.
let losses: Vec<String> = warnings.iter().filter(|w| is_loss(w)).cloned().collect();
let source_path = path.clone().filter(|_| !losses.is_empty());
let links = crate::cmd::links::resolve(&mut doc, path.as_deref(), s.prefs.update_links == "automatically");
// A template (saved by Save as Template, or an .ait/.vctemplate file) opens as a new untitled
// document.
@@ -266,6 +289,8 @@ fn open_loaded(s: &mut Session, loaded: Loaded, path: Option<String>, opts: &Loa
st.format = format.id;
}
st.converted = converted;
st.imported_from = source_path;
st.import_losses = losses;
let title = s.documents()[index].title();
Ok(super::merge(json!({ "index": index, "title": title, "format": format.id, "warnings": warnings, "restored": restored }), links.to_json()))
}
@@ -360,3 +385,36 @@ pub(super) fn raster_doc(name: &str, bytes: &[u8]) -> Result<Document> {
d.insert(Some(layer), 0, n).map_err(err)?;
Ok(d)
}
/// Does this import note say that the file has something the document doesn't?
fn is_loss(note: &str) -> bool {
note == vectorcraft_pdf::OFF_ARTBOARD_NOTE || vectorcraft_eps::is_loss(note)
}
/// Writing the file `target` for document `st`: not over the file `st` was read from when reading
/// it left something out (hidden text, art or layers), unless `p` has `acknowledgeLoss: true`.
/// Another name keeps the original file, with all it has.
pub(crate) fn check_not_lossy_overwrite(st: &crate::DocState, target: &str, p: &Value, cmd: &str) -> Result<()> {
let Some(source) = st.imported_from.as_deref().filter(|_| !st.import_losses.is_empty()) else { return Ok(()) };
if bool_or(p, "acknowledgeLoss", false) || !same_file(source, target) {
return Ok(());
}
let mut what = st.import_losses.iter().take(3).map(|n| format!("“{n}”")).collect::<Vec<_>>().join("; ");
if st.import_losses.len() > 3 {
what.push_str(&format!("; {} more", st.import_losses.len() - 3));
}
Err(bad(
cmd,
format!(
"{target} is the file this document was read from, and reading it left things out ({what}): writing over it would lose them for good. Write another file instead, or pass acknowledgeLoss: true to replace it anyway"
),
))
}
/// Do `a` and `b` name one file (spelled differently, or through a link)?
fn same_file(a: &str, b: &str) -> bool {
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
(Ok(a), Ok(b)) => a == b,
_ => a == b,
}
}
+1
View File
@@ -47,6 +47,7 @@ pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, enc
pub(crate) use encode::{anti_alias, background, with_single_artboard};
pub use export::export_source;
pub(crate) use export::isolated;
pub(crate) use load::check_not_lossy_overwrite;
use load::err;
pub(crate) use load::import_svg;
pub(crate) use load::native_file;
+3
View File
@@ -232,6 +232,9 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
_ => st.path.as_deref().and_then(parent_folder),
};
let modified = date_param(p, "modified", cmd)?;
if let Some(path) = &path {
super::check_not_lossy_overwrite(st, path, p, cmd)?;
}
Ok(SavePlan { mode, path, format, options, name, folder, modified })
}