diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index bf0a8d4a5..b4f8ee30c 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -15,6 +15,10 @@ pub(super) enum Page { Artboard, } +/// How art moved from a page to its place in the layers' document transforms: its pattern tiles +/// move with it. +pub(super) const PAGE_MOVE: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: false, patterns: true }; + /// The page (`visible`, the file read as its page) and the place of its area in the layers' /// document. pub(super) struct Frame<'a> { @@ -177,7 +181,7 @@ pub(super) fn build_from( None => shift, }; for n in object.iter_mut() { - Arc::make_mut(n).transform(shift, false); + Arc::make_mut(n).transform(shift, PAGE_MOVE); } } // An EPS saved as its artboard has the art outside it left out of its page. @@ -681,7 +685,7 @@ fn replace_unread( for &i in &selected { let mut copy = Node::clone(&art[i].node); if let Some(shift) = moves.get(page) { - copy.transform(*shift, false); + copy.transform(*shift, PAGE_MOVE); } found.push(Arc::new(reid_page_art(doc, ©))); claimed.insert(i, art[i].end); diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index 6be56c200..c4e961c47 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, build_from}; +use super::build::{PAGE_MOVE, Page, build, build_from}; use super::slots::LAYERS_UNREAD; use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect, Vec2, ai}; @@ -204,7 +204,7 @@ fn place_art(layer: &mut Node, pages: &[Rect], moves: &[Vec2]) { let fit = |p: &Rect| (p.intersect(r).area().max(0.0), -(p.center() - r.center()).hypot()); let Some((i, _)) = pages.iter().map(fit).enumerate().max_by(|(_, a), (_, b)| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1))) else { continue }; if let Some(v) = moves.get(i).filter(|v| **v != Vec2::ZERO) { - Arc::make_mut(child).transform(Affine::translate(*v), false); + Arc::make_mut(child).transform(Affine::translate(*v), PAGE_MOVE); } } } diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 49c1f1f61..15b3e21ca 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -832,6 +832,43 @@ fn unread_object_on_second_artboard_takes_second_page_art() { assert_eq!(bounds, vectorcraft_geom::Rect::new(50.0, 196.0, 54.0, 200.0)); } +#[test] +fn page_art_from_second_artboard_moves_its_pattern_tile() { + let boards = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\ + %_/Dictionary :\n%_0 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\ + %_/Dictionary :\n%_0 -50 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 -150 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\ + %_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n"; + let dots = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 4 4\n%_0 0 0 1 k\n%_1 1 m\n%_3 1 L\n%_3 3 L\n%_1 3 L\n%_f\nE\n%AI3_EndPattern\n"; + let second = "u\n0 0 1 0 k\n50 -100 m\n54 -100 L\n54 -96 L\n50 -96 L\nf\nZZ\nU"; + let art = native(&(boards.to_string() + dots + &layer("Live", true, &square(10, 10)) + &layer("Mixed", true, second))); + let (editing, _) = crate::ai_alone(&private(&native(&format!("{dots}{}", layer("Live", true, &square(10, 10)))))).unwrap(); + let source = editing.pattern("Dots").unwrap(); + let mut page = pdf_part(10.0, 86.0); + let mut pattern = source.clone(); + pattern.art = source.art.iter().map(|node| std::sync::Arc::new(page.reid(node))).collect(); + page.patterns.push(pattern); + let mut board = page.artboards[0].clone(); + (board.id, board.rect) = (2, vectorcraft_geom::Rect::new(120.0, 0.0, 220.0, 100.0)); + page.artboards.push(board); + let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(170.0, 46.0, 174.0, 50.0)); + let paint = vectorcraft_color::Paint::Pattern { pattern: "Dots".into(), xf: vectorcraft_geom::Affine::IDENTITY }; + let appearance = vectorcraft_doc::Appearance { + items: vec![vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(paint))], + ..Default::default() + }; + let node = Node::path(page.alloc_id(), path, appearance); + let layer = page.layers[0].id; + page.insert(Some(layer), 1, node).unwrap(); + let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0); + assert!(read, "{notes:?}"); + let replacement = &doc.layers[1].children().unwrap()[0].children().unwrap()[0]; + assert_eq!(replacement.visual_bounds().unwrap(), vectorcraft_geom::Rect::new(50.0, 196.0, 54.0, 200.0)); + let Some(vectorcraft_color::Paint::Pattern { xf, .. }) = replacement.appearance.fill().map(|f| &f.paint) else { + panic!("the page art's pattern fill is kept: {:?}", replacement.appearance); + }; + assert_eq!(*xf, vectorcraft_geom::Affine::translate(vectorcraft_geom::Vec2::new(-120.0, 150.0))); +} + #[test] fn unknown_operator_between_objects_falls_back() { let body = format!("{}\nZZ\n{}", square(50, 10), square(70, 10)); @@ -1443,6 +1480,38 @@ fn an_ai_file_read_as_its_pdf_part_keeps_its_artboard_layout() { assert!((moved[1] - vectorcraft_geom::Point::new(100.0, 1600.0)).hypot() < 1e-6, "the second's art goes with its page: {moved:?}"); } +#[test] +fn art_moved_with_its_page_moves_its_pattern_tile() { + // Two 100-point artboards, the second 50 below the first; an operator this doesn't know, so + // the layers aren't read. + let boards = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\ + %_/Dictionary :\n%_0 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\ + %_/Dictionary :\n%_0 -50 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 -150 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\ + %_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n"; + let art = native(&(boards.to_string() + &layer("Art", true, &(square(10, 10) + "\n1 Zz")))); + // The PDF part: the second page 20 points right of the first, with a pattern-filled square. + let mut pdf = pdf_part(10.0, 10.0); + let mut page = pdf.artboards[0].clone(); + (page.id, page.rect) = (2, vectorcraft_geom::Rect::new(120.0, 0.0, 220.0, 100.0)); + pdf.artboards.push(page); + let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(130.0, 10.0, 134.0, 14.0)); + let paint = vectorcraft_color::Paint::Pattern { pattern: "Dots".into(), xf: vectorcraft_geom::Affine::IDENTITY }; + let appearance = vectorcraft_doc::Appearance { + items: vec![vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(paint))], + ..Default::default() + }; + let (id, layer_id) = (pdf.alloc_id(), pdf.layers[0].id); + pdf.insert(Some(layer_id), 1, Node::path(id, path, appearance)).unwrap(); + let (d, w, read) = crate::layered_ai(&private(&art), pdf, vec![], false, false, 1.0); + assert!(!read, "{w:?}"); + let square = &d.layers[0].children().unwrap()[1]; + assert_eq!(square.visual_bounds().unwrap().origin(), vectorcraft_geom::Point::new(10.0, 160.0)); + let Some(vectorcraft_color::Paint::Pattern { xf, .. }) = square.appearance.fill().map(|f| &f.paint) else { + panic!("the square keeps its pattern fill: {:?}", square.appearance); + }; + assert_eq!(*xf, vectorcraft_geom::Affine::translate(vectorcraft_geom::Vec2::new(-120.0, 150.0))); +} + #[test] fn a_gray_is_a_grayscale_colour() { // `g` is a gray: 0.25 is 75 % of the black ink, as the page's own CMYK black says; the colour @@ -2126,6 +2195,41 @@ fn type_on_a_later_artboard_of_an_ai_file_goes_to_its_text_object() { assert!(!w.iter().any(|w| w.contains("with another's") || w.contains("doesn't draw")), "{w:?}"); } +#[test] +fn page_type_on_a_later_artboard_moves_its_pattern_tile() { + // As above, with a frame this doesn't read, so the text object takes the page's type, which + // has a pattern fill. + let boards = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\ + %_/Dictionary :\n%_0 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\ + %_/Dictionary :\n%_0 -50 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 -150 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\ + %_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n"; + let doc = last_frame_unread(&text_document(&[("Hi there\r", 0, (8200.0, 8341.5), (0.0, 0.0), 0.0)])); + let art = native_with_text(&(boards.to_string() + &layer("Art", true, &(square(10, 10) + &text_object(0)))), &doc); + let mut pdf = pdf_part(10.0, 86.0); + let mut page = pdf.artboards[0].clone(); + (page.id, page.rect) = (2, vectorcraft_geom::Rect::new(120.0, 0.0, 220.0, 100.0)); + pdf.artboards.push(page); + let style = vectorcraft_doc::CharStyle { size: 14.0, ..Default::default() }; + let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(178.5, 50.0), "Hi there", style); + let (id, layer_id) = (pdf.alloc_id(), pdf.layers[0].id); + let mut node = Node::new(id, NodeKind::Text(Box::new(text))); + let paint = vectorcraft_color::Paint::Pattern { pattern: "Dots".into(), xf: vectorcraft_geom::Affine::IDENTITY }; + node.appearance = vectorcraft_doc::Appearance { + items: vec![vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(paint))], + ..Default::default() + } + .into(); + pdf.insert(Some(layer_id), 1, node).unwrap(); + let (d, w, read) = crate::layered_ai(&private(&art), pdf, vec![], false, false, 1.0); + assert!(read, "{w:?}"); + let texts: Vec<&Node> = d.layers[0].children().unwrap().iter().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| &**n).collect(); + assert_eq!(texts.len(), 1, "{w:?}"); + let Some(vectorcraft_color::Paint::Pattern { xf, .. }) = texts[0].appearance.fill().map(|f| &f.paint) else { + panic!("the page's type keeps its pattern fill: {:?}", texts[0].appearance); + }; + assert_eq!(*xf, vectorcraft_geom::Affine::translate(vectorcraft_geom::Vec2::new(-120.0, 150.0))); +} + /// A global process colour (`Xk`) or a pattern fill on a sublayer no longer sends a `.ai` to its /// PDF part, which has only the top-level layers (#1025). #[test]