diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index ef80359e..6b886e26 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1738,6 +1738,7 @@ fn editing_text() -> String { "1 Xw".into(), "u".into(), "/AI11Text :\n0 /FreeUndo ,\n;".into(), + "/AI11Text :\n1 /FreeUndo ,\n;\n1 (Style) XW".into(), "U".into(), "0 Xw".into(), "1 0 0 0 1 0 Bg".into(), diff --git a/crates/eps/src/import/ai/frames.rs b/crates/eps/src/import/ai/frames.rs index 6667c981..24f0b8b8 100644 --- a/crates/eps/src/import/ai/frames.rs +++ b/crates/eps/src/import/ai/frames.rs @@ -40,6 +40,10 @@ pub(crate) fn text_frames(data: &[u8]) -> HashMap<(u32, usize), BezPath> { continue; } let Some(head) = seg.get(..art) else { continue }; + // The type in a drawn look is a story of another text document (see `Reader::text_slot`). + if number_before(head, b"/FreeUndo") == Some(1.0) { + continue; + } let (Some(story), frame) = (number_before(head, b"/StoryIndex"), number_before(head, b"/FrameIndex").unwrap_or(0.0)) else { continue }; if !(0.0..f64::from(u32::MAX)).contains(&story) || !(0.0..1e6).contains(&frame) { continue; @@ -123,6 +127,12 @@ mod tests { assert_eq!(f.len(), 2, "point type has no frame: {f:?}"); assert_eq!(f[&(49, 0)].bounding_box(), kurbo::Rect::new(10.0, 20.0, 110.0, 80.0)); assert_eq!(f[&(60, 1)].elements().len(), 2, "a curve, led by %_"); + // The type in a drawn look counts its stories in another text document: its frame isn't + // that of the text object which has the same number in this one. + let look = String::from_utf8_lossy(doc).replacen("0 /FreeUndo", "1 /FreeUndo", 1).replacen("10 20 m", "500 20 m", 1); + let own = "/AI11Text :\r0 /FreeUndo ,\r0 /FrameIndex ,\r49 /StoryIndex ,\r/Art :\r1 2 m\r3 2 L\r3 4 L\rN\r;\r/ConfiningPath ,\r;\r"; + let f = text_frames(format!("{look}{own}").as_bytes()); + assert_eq!(f[&(49, 0)].bounding_box(), kurbo::Rect::new(1.0, 2.0, 3.0, 4.0)); assert!(text_frames(b"/AI11Text : garbage").is_empty()); } } diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 83362f48..124701eb 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -1023,6 +1023,12 @@ impl<'a> Reader<'a> { if self.pattern.is_some() { self.unreadable("type in a pattern"); } + // `1 /FreeUndo`: the type in the drawn look of a text object that has an appearance. Its + // story is one of another text document (`/AI11UndoFreeTextDocument`), which counts its + // stories on its own; the object itself follows the look, on `%_` lines, and is the type. + if o.nums("FreeUndo").first() == Some(&1.0) { + return Ok(()); + } let index = |key: &str| o.nums(key).first().copied().filter(|v| (0.0..f64::from(u32::MAX)).contains(v)).map(|v| v as u32); let (story, frame) = (index("StoryIndex"), index("FrameIndex").unwrap_or(0)); let gs = self.gs.clone(); @@ -1288,9 +1294,20 @@ impl<'a> Reader<'a> { if !self.hidden_ids.contains(&object.id) || self.hidden_ids.contains(&look.id) || !matches!(look.kind, NodeKind::Group { clip: false, .. }) { return; } + let is_text = slot_of(object.name.as_deref()).is_some(); let Some(object) = f.children.pop() else { return }; let Some(look) = f.children.last_mut() else { return }; strip(look, &self.hidden_ids); + if is_text { + // A text object stays the type it is: over what else its look draws, or in place of a + // look that was only its type. + if look.children().is_some_and(|c| c.is_empty()) { + *look = object; + } else { + f.children.push(object); + } + return; + } look.name = object.name.clone().or(look.name.take()); look.visible = object.visible; look.locked = object.locked; diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 596aee25..dd8fdd1a 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -476,6 +476,20 @@ fn text_objects_are_slots_named_after_their_story() { let r = read_ok(&layer(&format!("/AI11Text :\n0 /StoryIndex ,\n;\n{}{}", commented(0), commented(1)))); let stories: Vec<_> = art(&r.doc).iter().map(|n| slot_of(n.name.as_deref())).collect(); assert_eq!(stories, [Some(Some(0)), Some(Some(1))]); + // A text object with an appearance: its drawn look (a group whose type is a story of the + // undo-free text document, which counts its own), then the object on `%_` lines, which is + // the type. + let look = "0 Ae\nu\n/AI11Text :\n1 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\nU\n0 0 Xd\n6 () XW\n"; + let own: String = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n7 /StoryIndex ,\n;\n".lines().map(|l| format!("%_{l}\n")).collect(); + let plain = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n"; + let r = read_ok(&layer(&format!("{look}{own}1 (Style) XW\n{plain}"))); + let stories: Vec<_> = art(&r.doc).iter().map(|n| slot_of(n.name.as_deref())).collect(); + assert_eq!(stories, [Some(Some(7)), Some(Some(0))], "the look's story 0 isn't the text document's"); + // What else the look draws stays, under the type. + let look = look.replace("U\n", &format!("{RED}{}f\nU\n", rect_path(0.0, 0.0, 10.0, 10.0))); + let r = read_ok(&layer(&format!("{look}{own}1 (Style) XW\n"))); + let a = art(&r.doc); + assert_eq!((a.len(), a[0].children().map(Vec::len), slot_of(a[1].name.as_deref())), (2, Some(1), Some(Some(7)))); } #[test] diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index 15a8e6c2..8d416f66 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -453,10 +453,11 @@ fn plan( Some(s.line_starts(t).into_iter().map(|(text, (x, y))| (text, to_doc * Point::new(x, y))).collect()) }) .collect(); - // The page's objects that are where a shown slot's lines are, nearest first. + // The page's objects that are where a slot's lines are, nearest first. A slot that doesn't show + // has them too: a file's page may draw what its layers have hidden since. let mut pairs: Vec<(f64, usize, usize)> = vec![]; - for (k, info) in infos.iter().enumerate() { - let Some(l) = lines.get(k).and_then(Option::as_ref).filter(|_| info.shown) else { continue }; + for (k, _) in infos.iter().enumerate() { + let Some(l) = lines.get(k).and_then(Option::as_ref) else { continue }; for (j, object) in shown.iter().enumerate() { if let Some(d) = object.iter().filter_map(|n| same_type(n, l)).min_by(f64::total_cmp) { pairs.push((d, k, j)); @@ -472,6 +473,8 @@ fn plan( } // Type the text document makes, shown or not: the page's pieces of it aren't needed. let mut content: Vec = infos.iter().map(|_| Content::Empty).collect(); + // Where the made type that doesn't show lies, by slot. + let mut hidden_at: Vec<(usize, Rect)> = vec![]; if let Some(t) = template { for (k, info) in infos.iter().enumerate() { let Some(Some(story)) = stories.get(k) else { continue }; @@ -493,6 +496,9 @@ fn plan( TextKind::Point => {} } } + if let (false, Some(r)) = (info.shown, region) { + hidden_at.push((k, r)); + } if let Some(c) = content.get_mut(k) { *c = Content::Made(Box::new(node), region.filter(|_| info.shown), false, vec![]); } @@ -506,9 +512,26 @@ fn plan( if !trusted { content.iter_mut().for_each(|c| *c = Content::Empty); } + // Is `object` the type of a text object that doesn't show, as the page draws it: in its frame, + // with its words? (A copied object's lines are where its original's are, so they don't say.) + let hidden_type = |object: &[Arc]| { + let Some(r) = extent(object) else { return false }; + let words: Vec = + object.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(letters(&t.plain_text())) } else { None }).collect(); + hidden_at.iter().any(|(k, place)| { + place.intersect(r).area() >= 0.5 * r.area().max(1e-9) + && lines + .get(*k) + .and_then(Option::as_ref) + .is_some_and(|l| !words.is_empty() && words.iter().all(|w| l.iter().any(|(text, _)| letters(text).contains(w.as_str())))) + }) + }; let mut spare: Vec>> = vec![]; for (j, object) in shown.iter().enumerate() { match owner.get(j).copied().flatten().filter(|_| trusted) { + // The type of a text object the layers hide: the layers have it, hidden, so the page's + // isn't another text object's. + Some(k) if infos.get(k).is_some_and(|i| !i.shown) && matches!(content.get(k), Some(Content::Made(..))) => {} Some(k) => match content.get_mut(k) { Some(Content::Made(_, _, seen, _)) => *seen = true, Some(Content::Page(p)) => p.extend(object.iter().cloned()), @@ -526,6 +549,7 @@ fn plan( } } } + None if trusted && hidden_type(object) => {} None => spare.push(object.clone()), } } @@ -857,7 +881,22 @@ fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool) -> text_objects(c, l.visible, &mut shown, &mut hidden); } } + // A `.ai` read as its PDF part has a page for each artboard, in a row: the type on each + // goes where the layers have that artboard. + let pages: Vec = visible.artboards.iter().map(|a| a.rect).collect(); + let same = + |(a, p): (&vectorcraft_doc::Artboard, &Rect)| (a.rect.width() - p.width()).abs() <= 0.5 && (a.rect.height() - p.height()).abs() <= 0.5; + let boards = (pages.len() == doc.artboards.len() && doc.artboards.iter().zip(&pages).all(same)).then_some(&doc.artboards); for object in shown.iter_mut().chain(hidden.iter_mut()) { + // The page with most of it, the first for what is on none. + let on = extent(object).and_then(|r| { + let inside = |p: &Rect| p.intersect(r).area().max(0.0); + pages.iter().enumerate().skip(1).filter(|(_, p)| inside(p) > inside(&page_rect)).max_by(|a, b| inside(a.1).total_cmp(&inside(b.1))) + }); + let shift = match on.and_then(|(i, page)| Some((boards?.get(i)?, page))) { + Some((board, page)) => Affine::translate(board.rect.origin() - page.origin()), + None => shift, + }; for n in object.iter_mut() { Arc::make_mut(n).transform(shift, false); } diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index f15dc5d6..d11e78eb 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -491,6 +491,20 @@ fn type_of_the_page_that_no_story_places_stays_with_the_nearest_text_object() { assert!(all.contains(&"Extra".to_string()), "{all:?} {:?}", r.warnings); } +#[test] +fn type_the_page_draws_of_a_text_object_on_a_hidden_layer_stays_hidden() { + // A file's page may draw what its layers have hidden since: story 1's type is on the page at + // (58.5, 30), and its text object is on a layer that doesn't show. + let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Extra\r", 0, (8200.0, 8211.5), (0.0, 0.0), 0.0)]); + let art = native_with_text(&(layer("Art", true, &text_object(0)) + &layer("Spare", false, &text_object(1))), &doc); + let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show 58.5 30 moveto (Extra) show"; + let r = import(&eps(page, &art)).unwrap(); + assert_eq!(names(&r.document), ["Art", "Spare"], "{:?}", r.warnings); + let texts = |l: usize| text_of(&r.document.layers[l]).iter().map(|t| t.plain_text()).collect::>(); + assert_eq!((texts(0), texts(1)), (vec!["Hi there".to_string()], vec!["Extra".to_string()]), "{:?}", r.warnings); + assert!(!r.warnings.iter().any(|w| w.contains("with another's")), "{:?}", r.warnings); +} + #[test] fn a_commented_copy_of_a_story_that_has_a_plain_text_object_is_not_another_object() { // The file writes some stories twice (plain, then as comments) and others only as comments. @@ -587,6 +601,37 @@ fn an_ai_file_without_its_pdf_part_reads_its_layers_and_makes_its_type() { assert!(crate::ai_alone(b"%AI24_ZStandard_Data nothing").is_err()); } +/// A `.ai`'s PDF part has a page for each artboard, in a row: the type on a later page goes to +/// its text object where the layers have that artboard, not by the first page's place. +#[test] +fn type_on_a_later_artboard_of_an_ai_file_goes_to_its_text_object() { + // Two 100-point artboards, the second 50 below the first; the type is 50 down the second + // (art space: (58.5, -100); the canvas centre 8191.5 is the template box's centre, (50, 50)). + 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 = 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); + // The PDF part: the second page 20 points right of the first, the type 50 down it. + 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); + pdf.insert(Some(layer_id), 1, Node::new(id, NodeKind::Text(Box::new(text)))).unwrap(); + let (d, w, read) = crate::layered_ai(&private(&art), pdf, vec![], false); + assert!(read, "{w:?}"); + assert_eq!(d.artboards[1].rect, vectorcraft_geom::Rect::new(0.0, 150.0, 100.0, 250.0)); + let texts = text_of(&d.layers[0]); + assert_eq!(texts.len(), 1, "{w:?}"); + let [.., e, f] = texts[0].xf.as_coeffs(); + assert!((e - 58.5).abs() < 1e-6 && (f - 200.0).abs() < 1e-6, "{:?}", texts[0].xf); + assert!(!w.iter().any(|w| w.contains("with another's") || w.contains("doesn't draw")), "{w:?}"); +} + /// 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]