.ai import: page art moved to its artboard takes its pattern tiles along

Art taken from a `.ai`'s PDF part on a later page is moved to where the
editing data puts that artboard. The move left pattern fills and strokes
where they were, so their tiles no longer lined up with the art. This
happened in three places: the page art that stands in for an unread
object (#5050), the page's type given to text objects, and the art on
each page when the file comes in as its PDF part with the artboard
layout kept. Each move now transforms the pattern tiles too.
This commit is contained in:
dotCooCoo
2026-11-02 12:03:56 +07:00
committed by bflatastic
parent cbd4dd5599
commit bb354fc92a
3 changed files with 112 additions and 4 deletions
+6 -2
View File
@@ -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, &copy)));
claimed.insert(i, art[i].end);
+2 -2
View File
@@ -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);
}
}
}
+104
View File
@@ -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]