From 6661f2419b0756728569e1e63ca4d7b7fd0afeb8 Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Thu, 29 Oct 2026 14:00:36 +0100 Subject: [PATCH] Fix #2033: an EPS whose layers can't be used keeps its layer tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the editing data's art is rejected (another page size, art the page doesn't draw, too many text objects…), the file still comes in as its page, but now on a layer above the editing data's layers. They come in empty, in their order, with their names, visibility, lock, print option and sublayers. The page's layer takes a free name when the file's layers have its Layer 1. --- crates/eps/src/import/native/build.rs | 16 +++++++- crates/eps/src/import/native/layers.rs | 51 +++++++++++++++++++++++--- crates/eps/src/import/tests_native.rs | 45 +++++++++++++++++++---- 3 files changed, 97 insertions(+), 15 deletions(-) diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index 995e8e8d3..24001e8f0 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -44,8 +44,20 @@ pub(super) fn build( ) -> Result<(Document, Vec), String> { // An EPS (its page is its art) draws a Large Canvas page at full size; a `.ai` keeps its art // space, as its PDF part may (#2031). - let Structure { mut doc, to_doc, bbox, artboard, template, hidden_unread, slot_names, restyled, warnings } = - ai::read(data, matches!(page, Page::Art))?; + build_from(ai::read(data, matches!(page, Page::Art))?, data, visible, page, outlined, trust, unit) +} + +/// [`build`] from `structure`, the editing copy `data` already read. +pub(super) fn build_from( + structure: Structure, + data: &[u8], + visible: Option<&Document>, + page: Page, + outlined: bool, + trust: bool, + unit: f64, +) -> Result<(Document, Vec), String> { + let Structure { mut doc, to_doc, bbox, artboard, template, hidden_unread, slot_names, restyled, 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`. let board = artboard.map(|a| to_doc.transform_rect_bbox(a)); diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index c4403cdcf..6be56c200 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, build}; +use super::build::{Page, build, build_from}; use super::slots::LAYERS_UNREAD; use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect, Vec2, ai}; @@ -6,11 +6,20 @@ use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect /// its program in the legacy format, see [`editing_copy`]): its layers (the /// hidden ones too), groups, compound paths, clipping groups and artboards, with the text and the /// strokes around it of the page. When the file has no editing copy, `visible` as it was; when it -/// has one that can't be used, `visible` with a warning that says why, and with the swatches of -/// the editing copy's palette ([`use_palette`]) when it could be read. +/// has one that can't be used, `visible` with a warning that says why, with the swatches of the +/// editing copy's palette ([`use_palette`]) when it could be read, and with its layer tree, empty, +/// below the page's layer when that could be read (#2033). pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool) -> Imported { let Some(data) = editing_copy(ps) else { return visible }; - let result = data.as_deref().map_err(Clone::clone).and_then(|data| build(data, Some(&visible.document), Page::Art, false, trust, 1.0)); + // The layer tree is kept from the structure read, for the page to come in on when the layers + // can't be used. + let (tree, result) = match data.as_deref() { + Ok(d) => match ai::read(d, true) { + Ok(s) => (layer_tree(&s.doc.layers), build_from(s, d, Some(&visible.document), Page::Art, false, trust, 1.0)), + Err(why) => (vec![], Err(why)), + }, + Err(why) => (vec![], Err(why.clone())), + }; match result { Ok((document, mut warnings)) => { for w in &visible.warnings { @@ -25,12 +34,44 @@ pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool) -> Im if let Ok(data) = &data { use_palette(data, &mut v.document); } - v.warnings.push(format!("{LAYERS_UNREAD} ({why}): it comes in as its page, in one layer")); + if tree.is_empty() { + v.warnings.push(format!("{LAYERS_UNREAD} ({why}): it comes in as its page, in one layer")); + return v; + } + let doc = &mut v.document; + let mut layers: Vec> = tree.iter().map(|l| Arc::new(doc.reid(l))).collect(); + let taken: Vec> = layers.iter().map(|l| l.name.clone()).collect(); + layers.append(&mut doc.layers); + doc.layers = layers; + // The page's layer takes a name of its own: the file's layers usually have its `Layer 1`. + for i in taken.len()..doc.layers.len() { + if doc.layers.get(i).is_some_and(|l| taken.contains(&l.name)) { + let name = doc.next_layer_name(); + if let Some(l) = doc.layers.get_mut(i) { + Arc::make_mut(l).name = Some(name); + } + } + } + v.warnings.push(format!("{LAYERS_UNREAD} ({why}): it comes in as its page, on a layer above its layers, which come in empty")); v } } } +/// The layers in `nodes` with only their sublayers in them: names, order and options without the art. +fn layer_tree(nodes: &[Arc]) -> Vec> { + let layers = nodes.iter().filter(|n| matches!(n.kind, NodeKind::Layer { .. })); + layers + .map(|l| { + let mut l = Node::clone(l); + if let Some(children) = l.children_mut() { + *children = layer_tree(children); + } + Arc::new(l) + }) + .collect() +} + /// The `.ai` `visible` (read as its PDF part), or a PDF saved with the editing data, read through /// its editing copy (`private`, the joined `AIPrivateData` or `AIPDFPrivateData` streams) instead, /// as [`layered`] reads an EPS: with the layers and objects of the file, its artboards (with the diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index c0fa58c22..4e1848f4f 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -395,8 +395,8 @@ fn editing_data_for_a_page_of_another_size_names_both_sizes() { let art = native(&layer("Jig", true, &square(10, 10))).replace(" 0 0 100 100", " 0 0 600 400"); let file = String::from_utf8(eps(&page_square(10, 10), &art)).unwrap().replace("%%BoundingBox: 0 0 100 100", "%%BoundingBox: 0 0 612 792"); let r = import(file.as_bytes()).unwrap(); - assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings); - let note = "the file's layers weren't read (its editing data is for a 600 × 400 pt page, not the file's 612 × 792 pt page): it comes in as its page, in one layer"; + assert_eq!(names(&r.document), ["Jig", "Layer 1"], "{:?}", r.warnings); + let note = "the file's layers weren't read (its editing data is for a 600 × 400 pt page, not the file's 612 × 792 pt page): it comes in as its page, on a layer above its layers, which come in empty"; assert!(r.warnings.iter().any(|w| w == note), "{:?}", r.warnings); // A `.ai` whose PDF part has an A4 page. let (_, w, read) = crate::layered_ai(&private(&art), Document::new(595.2756, 841.8898), vec![], false, false, 1.0); @@ -404,6 +404,35 @@ fn editing_data_for_a_page_of_another_size_names_both_sizes() { assert!(!read && w.iter().any(|w| w == note), "{w:?}"); } +/// #2033: an EPS whose layers can't be used (here its editing data is for another page) still +/// keeps its layer tree: the layers come in empty, in their order and with their names and +/// options (sublayers too), and the page comes in on a layer above them, under a name none of +/// them has. +#[test] +fn an_eps_whose_layers_cant_be_used_keeps_its_layer_tree() { + // Unlocked 0 (locked), printing 0. + let locked = layer("Locked", true, &layer("Sub", true, &square(20, 20))).replacen("1 1 1 1 0 0 1 0", "1 1 0 0 0 0 1 0", 1); + let layers = [layer("Layer 1", true, ""), locked, layer("Hidden", false, &square(50, 50))].concat(); + let art = native(&layers).replace(" 0 0 100 100", " 0 0 600 400"); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let d = &r.document; + assert_eq!(names(d), ["Layer 1", "Locked", "Hidden", "Layer 6"], "{:?}", r.warnings); + let [back, locked, hidden, page] = &d.layers[..] else { panic!("{:?}", names(d)) }; + let options = |l: &Node| (l.visible, l.locked, matches!(l.kind, NodeKind::Layer { printable: true, .. })); + assert_eq!( + [options(back), options(locked), options(hidden), options(page)], + [(true, false, true), (true, true, false), (false, false, true), (true, false, true)] + ); + assert!(back.children().unwrap().is_empty() && hidden.children().unwrap().is_empty()); + let [sub] = &locked.children().unwrap()[..] else { panic!("{locked:?}") }; + assert!(sub.name.as_deref() == Some("Sub") && sub.children().unwrap().is_empty()); + assert_eq!(page.children().unwrap().len(), 1, "the page's square"); + let mut ids = std::collections::BTreeSet::new(); + d.walk(|n| assert!(ids.insert(n.id), "{:?} twice", n.id)); + let note = "the file's layers weren't read (its editing data is for a 600 × 400 pt page, not the file's 100 × 100 pt page): it comes in as its page, on a layer above its layers, which come in empty"; + assert!(r.warnings.iter().any(|w| w == note), "{:?}", r.warnings); +} + /// #2034: editing data whose page is the file's at another scale (a Large Canvas file without its /// scale, for one) gives that scale, the editing data's page to the file's. #[test] @@ -411,14 +440,14 @@ fn editing_data_for_the_page_at_another_scale_names_the_scale() { let art = native(&layer("Jig", true, &square(10, 10))); let note = |file: &str| { let r = import(file.as_bytes()).unwrap(); - assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings); + assert_eq!(names(&r.document), ["Jig", "Layer 1"], "{:?}", r.warnings); r.warnings.into_iter().find(|w| w.contains("its editing data is for")).unwrap_or_default() }; let file = String::from_utf8(eps(&page_square(10, 10), &art)).unwrap(); let ten = file.replace("%%BoundingBox: 0 0 100 100", "%%BoundingBox: 0 0 1000 1000"); assert_eq!( note(&ten), - "the file's layers weren't read (its editing data is for a 100 × 100 pt page, the file's 1000 × 1000 pt page at 1:10): it comes in as its page, in one layer" + "the file's layers weren't read (its editing data is for a 100 × 100 pt page, the file's 1000 × 1000 pt page at 1:10): it comes in as its page, on a layer above its layers, which come in empty" ); let wide = file.replace("%%BoundingBox: 0 0 100 100", "%%BoundingBox: 0 0 250 250"); assert!(note(&wide).contains("for a 100 × 100 pt page, the file's 250 × 250 pt page at 1:2.5)")); @@ -466,7 +495,7 @@ fn a_large_canvas_eps_keeps_its_layers_at_the_pages_size() { // A declared scale the art doesn't fit still makes it another page's. let art = native_large_canvas(20.0, 10.0, &layer("Art", true, &square(10, 10))); let r = import(&eps(&page_square(10, 10), &art)).unwrap(); - assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings); + assert_eq!(names(&r.document), ["Art", "Layer 1"], "{:?}", r.warnings); assert!(r.warnings.iter().any(|w| w.contains("for a 200 × 200 pt page, the file's 100 × 100 pt page at 2:1")), "{:?}", r.warnings); // A `.ai` keeps its art space: its PDF part is read at the size it declares. let art = native_large_canvas(100.0, 10.0, &layer("Art", true, &square(10, 10))); @@ -1044,7 +1073,7 @@ fn more_text_objects_than_are_matched_leave_the_page() { let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &many)), &doc); let r = import(&eps(&page_square(10, 10), &art)).unwrap(); - assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings); + assert_eq!(names(&r.document), ["Back", "Spare", "Layer 1"], "{:?}", r.warnings); assert!(r.warnings.iter().any(|w| w.contains("too many text objects") && crate::is_loss(w)), "{:?}", r.warnings); } @@ -1156,7 +1185,7 @@ fn an_object_the_page_doesnt_draw_makes_the_file_come_in_as_its_page() { let extra = "0 0 1 0 k\n50 50 m\n70 50 L\n70 70 L\n50 70 L\nf"; let art = native(&layer("Back", true, &(square(10, 10) + "\n" + extra))); let r = import(&eps(&page_square(10, 10), &art)).unwrap(); - assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings); + assert_eq!(names(&r.document), ["Back", "Layer 1"], "{:?}", r.warnings); assert!(r.warnings.iter().any(|w| w.contains("layers weren't read") && w.contains("differs from the page's")), "{:?}", r.warnings); } @@ -1208,7 +1237,7 @@ fn art_the_layers_print_outside_an_eps_page_of_its_art_makes_it_come_in_as_its_p let file = |far: &str| eps_of_art([10, 10, 14, 14], &page_square(10, 10), &(layer("Art", true, &square(10, 10)) + far)); // A square the layers print far from it: the page would have it. let r = import(&file(&layer("Far", true, &square(80, 80)))).unwrap(); - assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings); + assert_eq!(names(&r.document), ["Art", "Far", "Layer 1"], "{:?}", r.warnings); assert!(r.warnings.iter().any(|w| w.contains("layers weren't read")), "{:?}", r.warnings); // Hidden, or on a layer that doesn't print, the page doesn't have it. let r = import(&file(&layer("Far", false, &square(80, 80)))).unwrap();