Merge pull request #2436 from marcoarnulfo/fix/ai3-paths-import

EPS import: an Illustrator 3 file without layers opens its paths on one layer (fixes storytold/photocraft#6036)
This commit is contained in:
BFlat
2026-10-17 15:10:31 +07:00
committed by GitHub
4 changed files with 84 additions and 12 deletions
+9 -7
View File
@@ -26,10 +26,11 @@
//! of a PostScript file without one. A file whose program can't be read (an operator the
//! interpreter doesn't know, an error, a limit reached) or that draws nothing comes in from its
//! layers when its program has them (a `.ai` in PostScript form whose prolog is named but not
//! included, as other apps write it), else as its preview (a TIFF image, a Windows metafile or an
//! EPSI bitmap), with a warning that names the error, the operator and the procedures it ran in;
//! without a preview, what was drawn before the error is kept (with that warning), else the file
//! is refused.
//! included, as other apps write it), or with its art on one layer when it is in the legacy format
//! without layers (Illustrator 3, as image editors still export their paths), else as its preview
//! (a TIFF image, a Windows metafile or an EPSI bitmap), with a warning that names the error, the
//! operator and the procedures it ran in; without a preview, what was drawn before the error is
//! kept (with that warning), else the file is refused.
mod ai;
mod ate;
@@ -198,7 +199,7 @@ pub fn import_with(bytes: &[u8], editing_data: bool) -> Result<Imported, String>
// printed).
if editing_data
&& dsc.own_legacy
&& let Some(Ok((document, warnings))) = native::program_alone(ps)
&& let Some(Ok((document, warnings))) = native::program_alone(ps, dsc.illustrator)
{
return Ok(Imported { document, warnings, preview: false });
}
@@ -224,8 +225,9 @@ pub fn import_with(bytes: &[u8], editing_data: bool) -> Result<Imported, String>
(Err(e), _) => reason(e, fault.as_ref()),
};
// A file in the legacy Illustrator format whose program can't be run (a `.ai` that names the
// prolog defining its operators without including it): its layers, which the program has.
let why = match editing_data.then(|| native::program_alone(ps)).flatten() {
// prolog defining its operators without including it): its layers, which the program has, or
// its art on one layer when it has none (Illustrator 3).
let why = match editing_data.then(|| native::program_alone(ps, dsc.illustrator)).flatten() {
Some(Ok((document, warnings))) => return Ok(Imported { document, warnings, preview: false }),
Some(Err(e)) => format!("{why}; its layers couldn't be read either ({e})"),
None => why,
+34 -3
View File
@@ -1334,9 +1334,40 @@ fn editing_copy(ps: &[u8]) -> Option<Result<Cow<'_, [u8]>, String>> {
/// A file in the legacy format whose page can't be read (a `.ai` in PostScript form that names its
/// prolog without including it) from its layers alone (`ps`: its program, which has them) → the
/// document and the import's notes. `None` when the layers aren't in the program.
pub(super) fn program_alone(ps: &[u8]) -> Option<Result<(Document, Vec<String>), String>> {
(!ai::has_eps_data(ps) && has_layers(ps)).then(|| build(ps, None, Page::Art, false, false, 1.0))
/// document and the import's notes. A file whose header says it is in the format (`illustrator`)
/// but whose program has no layers (Illustrator 3 had none; image editors still export their
/// paths so, storytold/photocraft#6036) is read with its art on one layer. `None` when the program
/// has no layers and isn't in the format: other PostScript isn't read as the format's operators.
pub(super) fn program_alone(ps: &[u8], illustrator: bool) -> Option<Result<(Document, Vec<String>), String>> {
if ai::has_eps_data(ps) {
return None;
}
if has_layers(ps) {
return Some(build(ps, None, Page::Art, false, false, 1.0));
}
let program = one_layer(ps).filter(|_| illustrator)?;
Some(build(&program, None, Page::Art, false, false, 1.0))
}
/// `ps` with its art (from its `%%EndSetup` to its `%%PageTrailer`, else its `%%Trailer`, else its
/// end) on a layer that shows, prints and isn't locked, as the version 5 format writes one. `None`
/// without a `%%EndSetup`: where the art starts isn't known.
fn one_layer(ps: &[u8]) -> Option<Vec<u8>> {
let setup = line_with(ps, 0, b"%%EndSetup")?;
let art = line_with(ps, setup + 1, b"").unwrap_or(ps.len());
let end = line_with(ps, art, b"%%PageTrailer").or_else(|| line_with(ps, art, b"%%Trailer")).unwrap_or(ps.len());
let mut out = Vec::with_capacity(ps.len() + 80);
out.extend_from_slice(ps.get(..art)?);
out.extend_from_slice(b"\n%AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n(Layer 1) Ln\n");
out.extend_from_slice(ps.get(art..end)?);
out.extend_from_slice(b"\nLB\n%AI5_EndLayer--\n");
out.extend_from_slice(ps.get(end..)?);
Some(out)
}
/// Where the first line of `ps` from `from` on that starts with `key` starts.
fn line_with(ps: &[u8], from: usize, key: &[u8]) -> Option<usize> {
(from..ps.len()).find(|&i| (i == 0 || matches!(ps.get(i - 1), Some(b'\n' | b'\r'))) && ps.get(i..).is_some_and(|rest| rest.starts_with(key)))
}
#[cfg(test)]
@@ -121,6 +121,43 @@ fn a_postscript_ai_whose_layers_cant_be_read_says_why() {
assert!(e.contains("`Adobe_packedarray`") && e.contains("its layers couldn't be read either") && e.contains("`frobnicate`"), "{e}");
}
/// An Illustrator 3 file of paths alone, as image editors export their paths (storytold/photocraft#6036):
/// no prolog and no layers, the paths unpainted (`n` closes, `N` doesn't), a compound path of two.
const PATHS_ONLY: &str = "%!PS-Adobe-2.0 EPSF-1.2\n%%Creator: an image editor\n%%BoundingBox: 0 0 200 100\n\
%AI3_Cropmarks: 0 0 200 100\n%%EndComments\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n\
%%Note: Outline\n10 10 m\n100 10 L\n50 80 60 90 10 10 C\nn\n\
*u\n20 20 m\n30 20 L\nN\n40 40 m\n50 50 L\n60 40 L\nn\n*U\n%%PageTrailer\n%%Trailer\n%%EOF\n";
#[test]
fn an_illustrator_3_file_without_layers_opens_its_paths_on_one_layer() {
let r = import(PATHS_ONLY.as_bytes()).unwrap();
assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings);
let d = &r.document;
assert_eq!(d.layers.len(), 1);
assert_eq!(d.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0));
let art = d.layers[0].children().unwrap();
assert_eq!(art.len(), 2);
// Art space has y up: (10, 10) is 10 above the artboard's bottom.
let NodeKind::Path { path, .. } = &art[0].kind else { panic!("{:?}", art[0].kind) };
let ends: Vec<_> = path.subpaths[0].anchors.iter().map(|a| (a.p.x, a.p.y)).collect();
assert_eq!(ends, [(10.0, 90.0), (100.0, 90.0)]);
assert!(path.subpaths[0].closed);
assert!(art[0].appearance.items.is_empty(), "unpainted");
let NodeKind::Compound { children, .. } = &art[1].kind else { panic!("{:?}", art[1].kind) };
let closed: Vec<_> = children.iter().map(|c| if let NodeKind::Path { path, .. } = &c.kind { path.is_closed() } else { false }).collect();
assert_eq!(closed, [false, true]);
assert_eq!(art[1].geometric_bounds(), Some(Rect::new(20.0, 50.0, 60.0, 80.0)));
}
/// The same program in a file that doesn't say it is in Illustrator's format is PostScript whose
/// operators the reader doesn't know: it is still refused.
#[test]
fn the_same_program_in_other_postscript_is_still_refused() {
let other = PATHS_ONLY.replace("%AI3_Cropmarks: 0 0 200 100\n", "");
let e = import(other.as_bytes()).unwrap_err();
assert!(e.contains("it uses `m`") && !e.contains("layers"), "{e}");
}
/// The editing text of a newer file saved as PostScript (no prolog at all, a non-printing setup):
/// it opens with its sublayers.
#[test]
File diff suppressed because one or more lines are too long